#!/usr/bin/env python3 """ ORA (OpenRaster) editing utility. Supports creating ORA files from PNGs, extracting layers as PNGs, and masking elements with alpha channels. Commands: create Create an ORA file from a PNG mask_element Create a masked layer for an entity in an ORA file inspect Show the structure of an ORA file extract_png Extract a layer from an ORA file as a PNG """ import argparse import io import os import re import shutil import sys import tempfile import zipfile from pathlib import Path from typing import Any from xml.etree import ElementTree as ET from PIL import Image, ImageChops def parse_stack_xml(ora_path: str) -> ET.Element: """Parse stack.xml from an ORA file.""" with zipfile.ZipFile(ora_path, 'r') as zf: return ET.fromstring(zf.read('stack.xml')) def get_image_size(root: ET.Element) -> tuple[int, int]: """Get image dimensions from stack.xml root element.""" return int(root.get('w', 0)), int(root.get('h', 0)) def get_groups_and_layers(root: ET.Element) -> list[dict[str, Any]]: """ Extract groups and their layers from stack.xml. Returns a list of group dicts with 'name', 'layers', and 'is_group' keys. """ groups = [] root_stack = root.find('stack') if root_stack is None: return groups for child in root_stack: if child.tag == 'stack': # This is a group group_name = child.get('name', 'unnamed') layers = [] for layer in child: if layer.tag == 'layer': layers.append({ 'name': layer.get('name', 'unnamed'), 'src': layer.get('src', ''), 'opacity': layer.get('opacity', '1.0'), 'visibility': layer.get('visibility', 'visible') }) groups.append({ 'name': group_name, 'is_group': True, 'layers': layers }) elif child.tag == 'layer': # This is a standalone layer (base layer) groups.append({ 'name': child.get('name', 'unnamed'), 'is_group': False, 'layers': [{ 'name': child.get('name', 'unnamed'), 'src': child.get('src', ''), 'opacity': child.get('opacity', '1.0'), 'visibility': child.get('visibility', 'visible') }] }) return groups def get_next_layer_index(group: dict, entity_name: str) -> int: """ Get the next auto-increment index for layers in an entity group. Looks for pattern: entity_name_N """ max_index = -1 pattern = re.compile(rf'^{re.escape(entity_name)}_(\d+)$') for layer in group['layers']: match = pattern.match(layer['name']) if match: max_index = max(max_index, int(match.group(1))) return max_index + 1 def find_entity_group(groups: list, entity_name: str) -> dict | None: """Find an existing entity group by name.""" for group in groups: if group.get('is_group') and group['name'] == entity_name: return group return None def find_layer(groups: list, layer_name: str) -> tuple[dict, dict] | None: """ Find a layer by name across all groups. Returns (group, layer) tuple or None. """ for group in groups: for layer in group['layers']: if layer['name'] == layer_name: return group, layer return None def extract_layer_image(ora_path: str, layer_src: str) -> Image.Image: """Extract a layer image from the ORA file.""" with zipfile.ZipFile(ora_path, 'r') as zf: image_data = zf.read(layer_src) return Image.open(io.BytesIO(image_data)).convert('RGBA') def apply_mask(source_image: Image.Image, mask_image: Image.Image) -> Image.Image: """ Apply a mask as the alpha channel of an image. Mask can be grayscale or RGB; will be converted to grayscale for alpha. """ # Ensure mask is grayscale if mask_image.mode != 'L': mask_image = mask_image.convert('L') # Resize mask to match source if needed if mask_image.size != source_image.size: mask_image = mask_image.resize(source_image.size, Image.Resampling.BILINEAR) # Apply mask to alpha channel result = source_image.copy() r, g, b, a = result.split() new_alpha = ImageChops.multiply(a, mask_image) result.putalpha(new_alpha) return result def build_stack_xml(width: int, height: int, groups: list[dict]) -> bytes: """Build stack.xml content from group structure.""" image = ET.Element('image', { 'version': '0.0.3', 'w': str(width), 'h': str(height) }) root_stack = ET.SubElement(image, 'stack') for group in groups: if group.get('is_group'): # Create group element group_el = ET.SubElement(root_stack, 'stack', { 'name': group['name'] }) for layer in group['layers']: layer_attrs = { 'name': layer['name'], 'src': layer['src'], 'opacity': layer.get('opacity', '1.0') } if layer.get('visibility') and layer['visibility'] != 'visible': layer_attrs['visibility'] = layer['visibility'] ET.SubElement(group_el, 'layer', layer_attrs) else: # Standalone layer (base layer) layer = group['layers'][0] layer_attrs = { 'name': layer['name'], 'src': layer['src'], 'opacity': layer.get('opacity', '1.0') } if layer.get('visibility') and layer['visibility'] != 'visible': layer_attrs['visibility'] = layer['visibility'] ET.SubElement(root_stack, 'layer', layer_attrs) return ET.tostring(image, encoding='utf-8', xml_declaration=True) def create_ora_from_structure( groups: list[dict], width: int, height: int, layer_images: dict[str, Image.Image], output_path: str ) -> None: """Create an ORA file from the structure and layer images.""" tmp = tempfile.mkdtemp() try: data_dir = os.path.join(tmp, 'data') thumb_dir = os.path.join(tmp, 'Thumbnails') os.makedirs(data_dir) os.makedirs(thumb_dir) # Save all layer images for src_path, img in layer_images.items(): full_path = os.path.join(tmp, src_path) os.makedirs(os.path.dirname(full_path), exist_ok=True) img.save(full_path) # Build and save stack.xml stack_xml = build_stack_xml(width, height, groups) with open(os.path.join(tmp, 'stack.xml'), 'wb') as f: f.write(stack_xml) # Create mimetype with open(os.path.join(tmp, 'mimetype'), 'w') as f: f.write('image/openraster') # Create mergedimage.png (use first base layer) merged = None for group in groups: if not group.get('is_group') and group['layers']: layer_src = group['layers'][0]['src'] merged = layer_images.get(layer_src) break if merged is None: # Create blank image if no base layer merged = Image.new('RGBA', (width, height), (0, 0, 0, 0)) merged.save(os.path.join(tmp, 'mergedimage.png')) # Create thumbnail thumb = merged.copy() thumb.thumbnail((256, 256)) thumb.save(os.path.join(thumb_dir, 'thumbnail.png')) # Build ORA zip with zipfile.ZipFile(output_path, 'w') as zf: zf.write(os.path.join(tmp, 'mimetype'), 'mimetype', compress_type=zipfile.ZIP_STORED) for root, _, files in os.walk(tmp): for file in files: if file == 'mimetype': continue full = os.path.join(root, file) rel = os.path.relpath(full, tmp) zf.write(full, rel) finally: shutil.rmtree(tmp) def print_ora_summary(groups: list[dict]) -> None: """Print an itemized summary of the ORA structure.""" print("\n" + "=" * 50) print("ORA STRUCTURE SUMMARY") print("=" * 50) for i, group in enumerate(groups, 1): if group.get('is_group'): print(f"\nšŸ“ Group {i}: {group['name']}") for j, layer in enumerate(group['layers'], 1): print(f" └─ Layer {j}: {layer['name']}") else: layer = group['layers'][0] print(f"\nšŸ–¼ļø Base Layer {i}: {layer['name']}") print("\n" + "=" * 50) def cmd_create(args: argparse.Namespace) -> int: """Handle 'create' subcommand.""" input_png = args.input_png output_ora = args.output_ora layer_name = args.layer_name if output_ora is None: output_ora = str(Path(input_png).with_suffix('.ora')) # Load base image base = Image.open(input_png).convert('RGBA') width, height = base.size # Build structure layer_src = f'data/{layer_name}.png' groups = [{ 'name': layer_name, 'is_group': False, 'layers': [{ 'name': layer_name, 'src': layer_src, 'opacity': '1.0', 'visibility': 'visible' }] }] layer_images = {layer_src: base} create_ora_from_structure(groups, width, height, layer_images, output_ora) print(f"āœ“ Created ORA: {output_ora}") print_ora_summary(groups) return 0 def cmd_mask_element(args: argparse.Namespace) -> int: """Handle 'mask_element' subcommand.""" import io input_path = args.input mask_path = args.mask entity_name = args.entity layer_name = args.layer output_path = args.output # Handle PNG input: auto-create ORA first is_png_input = input_path.lower().endswith('.png') ora_path = input_path if is_png_input: ora_path = str(Path(input_path).with_suffix('.ora')) if output_path is None: output_path = ora_path # Create ORA from PNG with 'base' layer base = Image.open(input_path).convert('RGBA') width, height = base.size layer_src = 'data/base.png' groups = [{ 'name': 'base', 'is_group': False, 'layers': [{ 'name': 'base', 'src': layer_src, 'opacity': '1.0', 'visibility': 'visible' }] }] layer_images = {layer_src: base} create_ora_from_structure(groups, width, height, layer_images, ora_path) print(f"āœ“ Converted PNG to ORA: {ora_path}") # Parse existing ORA root = parse_stack_xml(ora_path) width, height = get_image_size(root) groups = get_groups_and_layers(root) # Find source layer layer_result = find_layer(groups, layer_name) if layer_result is None: print(f"Error: Layer '{layer_name}' not found in ORA file", file=sys.stderr) print("Available layers:", file=sys.stderr) for group in groups: for layer in group['layers']: print(f" - {layer['name']}", file=sys.stderr) return 1 source_group, source_layer = layer_result # Load source image with zipfile.ZipFile(ora_path, 'r') as zf: source_image_data = zf.read(source_layer['src']) source_image = Image.open(io.BytesIO(source_image_data)).convert('RGBA') # Load mask mask_image = Image.open(mask_path) if mask_image.mode not in ['L', 'RGBA', 'RGB']: mask_image = mask_image.convert('RGBA') # Apply mask masked_image = apply_mask(source_image, mask_image) # Find or create entity group entity_group = find_entity_group(groups, entity_name) if entity_group is None: # Create new group before base layers entity_group = { 'name': entity_name, 'is_group': True, 'layers': [] } # Insert before first non-group (base layer) insert_idx = 0 for i, g in enumerate(groups): if not g.get('is_group'): insert_idx = i break insert_idx = i + 1 groups.insert(insert_idx, entity_group) # Generate new layer name (auto-increment) next_index = get_next_layer_index(entity_group, entity_name) new_layer_name = f"{entity_name}_{next_index}" new_layer_src = f"data/{entity_name}/{new_layer_name}.png" # Add new layer to entity group entity_group['layers'].append({ 'name': new_layer_name, 'src': new_layer_src, 'opacity': '1.0', 'visibility': 'visible' }) # Extract all layer images from ORA layer_images = {} with zipfile.ZipFile(ora_path, 'r') as zf: for group in groups: for layer in group['layers']: if layer['src'] not in layer_images: try: data = zf.read(layer['src']) layer_images[layer['src']] = Image.open(io.BytesIO(data)) except KeyError: pass # New layer, will be added later # Add the new masked image layer_images[new_layer_src] = masked_image # Determine output path if output_path is None: output_path = ora_path # Create new ORA create_ora_from_structure(groups, width, height, layer_images, output_path) print(f"āœ“ Applied mask to create: {entity_name}/{new_layer_name}") print(f" Source layer: {layer_name}") print(f" Mask file: {mask_path}") print(f" Output: {output_path}") print_ora_summary(groups) return 0 def cmd_inspect(args: argparse.Namespace) -> int: """Handle 'inspect' subcommand.""" ora_path = args.ora_file if not os.path.exists(ora_path): print(f"Error: File not found: {ora_path}", file=sys.stderr) return 1 # Parse existing ORA try: root = parse_stack_xml(ora_path) except (zipfile.BadZipFile, KeyError) as e: print(f"Error: Invalid ORA file: {ora_path}", file=sys.stderr) return 1 groups = get_groups_and_layers(root) print(f"\nFile: {ora_path}") print_ora_summary(groups) return 0 def cmd_extract_png(args: argparse.Namespace) -> int: """Handle 'extract_png' subcommand.""" ora_path = args.ora_file layer_name = args.layer output_path = args.output if not os.path.exists(ora_path): print(f"Error: File not found: {ora_path}", file=sys.stderr) return 1 # Parse existing ORA try: root = parse_stack_xml(ora_path) except (zipfile.BadZipFile, KeyError) as e: print(f"Error: Invalid ORA file: {ora_path}", file=sys.stderr) return 1 groups = get_groups_and_layers(root) # Find the layer layer_result = find_layer(groups, layer_name) if layer_result is None: print(f"Error: Layer '{layer_name}' not found in ORA file", file=sys.stderr) print("Available layers:", file=sys.stderr) for group in groups: for layer in group['layers']: print(f" - {layer['name']}", file=sys.stderr) return 1 _, layer = layer_result # Determine output path if output_path is None: output_path = f"{layer_name}.png" # Extract and save the layer image image = extract_layer_image(ora_path, layer['src']) image.save(output_path) print(f"āœ“ Extracted layer '{layer_name}' to: {output_path}") return 0 def main() -> int: parser = argparse.ArgumentParser( prog='ora_edit.py', description='ORA (OpenRaster) editing utility' ) subparsers = parser.add_subparsers(dest='command', required=True) # Create subcommand create_parser = subparsers.add_parser( 'create', help='Create an ORA file from a PNG' ) create_parser.add_argument( 'input_png', help='Input PNG file' ) create_parser.add_argument( 'output_ora', nargs='?', default=None, help='Output ORA file (default: same name as input with .ora extension)' ) create_parser.add_argument( '--layer-name', default='base', help='Name for the root layer (default: base)' ) create_parser.set_defaults(func=cmd_create) # Mask element subcommand mask_parser = subparsers.add_parser( 'mask_element', help='Create a masked layer for an entity in an ORA file' ) mask_parser.add_argument( 'input', help='Input ORA or PNG file' ) mask_parser.add_argument( '--mask', required=True, help='Mask file to use as alpha channel' ) mask_parser.add_argument( '--entity', required=True, help='Entity name (group name)' ) mask_parser.add_argument( '--layer', default='base', help='Source layer to copy and mask (default: base)' ) mask_parser.add_argument( '--output', default=None, help='Output ORA file (default: same as input)' ) mask_parser.set_defaults(func=cmd_mask_element) # Inspect subcommand inspect_parser = subparsers.add_parser( 'inspect', help='Show the structure of an ORA file' ) inspect_parser.add_argument( 'ora_file', help='ORA file to inspect' ) inspect_parser.set_defaults(func=cmd_inspect) # Extract PNG subcommand extract_parser = subparsers.add_parser( 'extract_png', help='Extract a layer from an ORA file as a PNG' ) extract_parser.add_argument( 'ora_file', help='ORA file to extract from' ) extract_parser.add_argument( '--layer', required=True, help='Name of the layer to extract' ) extract_parser.add_argument( '--output', default=None, help='Output PNG file (default: .png)' ) extract_parser.set_defaults(func=cmd_extract_png) args = parser.parse_args() return args.func(args) if __name__ == '__main__': sys.exit(main())