#!/usr/bin/env python3 """ORA operations wrapper for the web editor.""" 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.""" groups = [] root_stack = root.find('stack') if root_stack is None: return groups for child in root_stack: if child.tag == 'stack': 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': 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.""" 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.""" if mask_image.mode != 'L': mask_image = mask_image.convert('L') if mask_image.size != source_image.size: mask_image = mask_image.resize(source_image.size, Image.Resampling.BILINEAR) 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'): 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: 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) 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) stack_xml = build_stack_xml(width, height, groups) with open(os.path.join(tmp, 'stack.xml'), 'wb') as f: f.write(stack_xml) with open(os.path.join(tmp, 'mimetype'), 'w') as f: f.write('image/openraster') 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: merged = Image.new('RGBA', (width, height), (0, 0, 0, 0)) merged.save(os.path.join(tmp, 'mergedimage.png')) thumb = merged.copy() thumb.thumbnail((256, 256)) thumb.save(os.path.join(thumb_dir, 'thumbnail.png')) 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 load_ora(ora_path: str) -> dict[str, Any]: """Load an ORA file and return its structure and layer images.""" root = parse_stack_xml(ora_path) width, height = get_image_size(root) groups = get_groups_and_layers(root) # Flatten layers with their group info layers = [] for group in groups: for layer in group['layers']: layers.append({ 'name': layer['name'], 'src': layer['src'], 'group': group['name'] if group.get('is_group') else None, 'visible': layer.get('visibility', 'visible') != 'hidden' }) return { 'width': width, 'height': height, 'layers': layers, 'groups': groups } def get_layer_image(ora_path: str, layer_name: str) -> Image.Image | None: """Get a specific layer image from the ORA.""" root = parse_stack_xml(ora_path) groups = get_groups_and_layers(root) result = find_layer(groups, layer_name) if result is None: return None _, layer = result return extract_layer_image(ora_path, layer['src']) def get_base_image(ora_path: str) -> Image.Image | None: """Get the base/merged image from the ORA.""" try: with zipfile.ZipFile(ora_path, 'r') as zf: return Image.open(zf.open('mergedimage.png')).convert('RGBA') except Exception: return None def save_ora(ora_path: str) -> bool: """Save the ORA structure back to disk (uses existing content).""" if not os.path.exists(ora_path): return False try: root = parse_stack_xml(ora_path) width, height = get_image_size(root) groups = get_groups_and_layers(root) # Extract all layer images from the current 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 create_ora_from_structure(groups, width, height, layer_images, ora_path) return True except Exception as e: print(f"Error saving ORA: {e}", file=sys.stderr) return False def add_masked_layer(ora_path: str, entity_name: str, mask_path: str, source_layer: str = 'base') -> dict[str, Any]: """Add a new masked layer to the ORA.""" # Check if ORA exists, create from PNG if not if not os.path.exists(ora_path): png_path = ora_path.replace('.ora', '.png') if os.path.exists(png_path): base = Image.open(png_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) else: return {'success': False, 'error': f"No ORA or PNG found at {ora_path}"} 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, source_layer) if layer_result is None: return {'success': False, 'error': f"Layer '{source_layer}' not found"} source_group, source_layer_info = layer_result # Load source image with zipfile.ZipFile(ora_path, 'r') as zf: source_image_data = zf.read(source_layer_info['src']) source_image = Image.open(io.BytesIO(source_image_data)).convert('RGBA') # Load mask if not os.path.exists(mask_path): return {'success': False, 'error': f"Mask file not found: {mask_path}"} 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: entity_group = { 'name': entity_name, 'is_group': True, 'layers': [] } 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 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 # Add the new masked image layer_images[new_layer_src] = masked_image create_ora_from_structure(groups, width, height, layer_images, ora_path) return {'success': True, 'layer_name': new_layer_name} def rename_layer(ora_path: str, old_name: str, new_name: str) -> dict[str, Any]: """Rename a layer in the ORA.""" root = parse_stack_xml(ora_path) width, height = get_image_size(root) groups = get_groups_and_layers(root) # Find the layer result = find_layer(groups, old_name) if result is None: return {'success': False, 'error': f"Layer '{old_name}' not found"} group, layer = result # Update name in both places old_src = layer['src'] layer['name'] = new_name # Extract all layer images layer_images = {} with zipfile.ZipFile(ora_path, 'r') as zf: for g in groups: for l in g['layers']: if l['src'] not in layer_images: try: data = zf.read(l['src']) layer_images[l['src']] = Image.open(io.BytesIO(data)) except KeyError: pass # Get the image and create new entry with updated name if old_src in layer_images: img = layer_images.pop(old_src) ext = Path(old_src).suffix layer_dir = str(Path(old_src).parent) new_src = f"{layer_dir}/{new_name}{ext}" layer['src'] = new_src layer_images[new_src] = img create_ora_from_structure(groups, width, height, layer_images, ora_path) return {'success': True} def delete_layer(ora_path: str, layer_name: str) -> dict[str, Any]: """Delete a layer from the ORA.""" root = parse_stack_xml(ora_path) width, height = get_image_size(root) groups = get_groups_and_layers(root) # Find and remove the layer result = find_layer(groups, layer_name) if result is None: return {'success': False, 'error': f"Layer '{layer_name}' not found"} group, _ = result group['layers'] = [l for l in group['layers'] if l['name'] != layer_name] # Remove empty groups groups = [g for g in groups if len(g['layers']) > 0] # Extract remaining layer images layer_images = {} with zipfile.ZipFile(ora_path, 'r') as zf: for g in groups: for l in g['layers']: if l['src'] not in layer_images: try: data = zf.read(l['src']) layer_images[l['src']] = Image.open(io.BytesIO(data)) except KeyError: pass create_ora_from_structure(groups, width, height, layer_images, ora_path) return {'success': True} def reorder_layer(ora_path: str, layer_name: str, direction: str) -> dict[str, Any]: """Move a layer up or down in the stack.""" root = parse_stack_xml(ora_path) width, height = get_image_size(root) groups = get_groups_and_layers(root) # Find the layer result = find_layer(groups, layer_name) if result is None: return {'success': False, 'error': f"Layer '{layer_name}' not found"} current_group, _ = result # Flatten all layers with their group info for reordering flat_layers = [] for g in groups: for l in g['layers']: flat_layers.append({'group': g, 'layer': l}) # Find index of the layer idx = None for i, item in enumerate(flat_layers): if item['layer']['name'] == layer_name: idx = i break if idx is None: return {'success': False, 'error': f"Layer '{layer_name}' not found"} # Determine swap index swap_idx = None if direction == 'up' and idx > 0: swap_idx = idx - 1 elif direction == 'down' and idx < len(flat_layers) - 1: swap_idx = idx + 1 if swap_idx is None: return {'success': False, 'error': "Cannot move layer further in that direction"} # Swap the layers flat_layers[idx], flat_layers[swap_idx] = flat_layers[swap_idx], flat_layers[idx] # Rebuild groups new_groups = [] current_group_obj = None for item in flat_layers: g_name = item['group']['name'] is_group = item['group'].get('is_group') if is_group: # Entity layer - add to or create group existing_group = next((g for g in new_groups if g['name'] == g_name and g.get('is_group')), None) if existing_group is None: existing_group = {'name': g_name, 'is_group': True, 'layers': []} new_groups.append(existing_group) existing_group['layers'].append(item['layer']) else: # Base layer - standalone new_groups.append({ 'name': item['group']['name'], 'is_group': False, 'layers': [item['layer']] }) groups = new_groups # Extract layer images layer_images = {} with zipfile.ZipFile(ora_path, 'r') as zf: for g in groups: for l in g['layers']: if l['src'] not in layer_images: try: data = zf.read(l['src']) layer_images[l['src']] = Image.open(io.BytesIO(data)) except KeyError: pass create_ora_from_structure(groups, width, height, layer_images, ora_path) return {'success': True} def create_ora_from_png(png_path: str, output_ora: str, layer_name: str = 'base') -> dict[str, Any]: """Create an ORA file from a PNG.""" if not os.path.exists(png_path): return {'success': False, 'error': f"File not found: {png_path}"} base = Image.open(png_path).convert('RGBA') width, height = base.size 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) return {'success': True, 'ora_path': output_ora} def get_layer_visibility(ora_path: str, layer_name: str) -> bool | None: """Get the visibility state of a layer.""" root = parse_stack_xml(ora_path) groups = get_groups_and_layers(root) result = find_layer(groups, layer_name) if result is None: return None _, layer = result return layer.get('visibility', 'visible') != 'hidden' def set_layer_visibility(ora_path: str, layer_name: str, visible: bool) -> dict[str, Any]: """Set the visibility of a layer.""" root = parse_stack_xml(ora_path) width, height = get_image_size(root) groups = get_groups_and_layers(root) # Find and update the layer result = find_layer(groups, layer_name) if result is None: return {'success': False, 'error': f"Layer '{layer_name}' not found"} _, layer = result layer['visibility'] = 'visible' if visible else 'hidden' # Extract layer images layer_images = {} with zipfile.ZipFile(ora_path, 'r') as zf: for g in groups: for l in g['layers']: if l['src'] not in layer_images: try: data = zf.read(l['src']) layer_images[l['src']] = Image.open(io.BytesIO(data)) except KeyError: pass create_ora_from_structure(groups, width, height, layer_images, ora_path) return {'success': True}