Files
ai-game-2/tools/ora_editor/ora_ops.py
Bryce 319c2565c6 Add ora_ops module for ORA file operations
- Implements core ORA reading/writing functions
- Layer add, rename, delete, reorder, visibility operations
- Full test suite with 8 passing tests
2026-03-27 08:46:49 -07:00

627 lines
20 KiB
Python

#!/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}