Files
ai-game-2/tools/ora_edit.py
2026-03-09 10:54:10 -07:00

609 lines
18 KiB
Python
Executable File

#!/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: <layer_name>.png)'
)
extract_parser.set_defaults(func=cmd_extract_png)
args = parser.parse_args()
return args.func(args)
if __name__ == '__main__':
sys.exit(main())