Files
hcie-rust-v3.05/hcie-io/tools/generate_fx_tests.py
T
2026-07-09 02:59:53 +03:00

1212 lines
45 KiB
Python

#!/usr/bin/env python3
"""
PSD Layer Effects Test File Generator
======================================
Generates correctly-formatted PSD files for testing layer FX rendering.
Each test file: 512x512, 1 layer, 1 effect, asymmetric L-shape.
Plus 1 multi-layer file with all effects on separate layers.
Uses both lrFX (legacy) and lfx2 (descriptor-based) tagged blocks
for maximum compatibility with Photopea/Photoshop.
Usage:
python3 generate_fx_tests.py [--output-dir DIR]
"""
import struct
import os
import sys
import math
OUTPUT_DIR = "/home/hc/Pictures/_psd_stil_test"
CANVAS_SIZE = 512
# ============================================================================
# Binary helpers
# ============================================================================
def be_u16(v):
return struct.pack('>H', v & 0xFFFF)
def be_i16(v):
return struct.pack('>h', v)
def be_u32(v):
return struct.pack('>I', v)
def be_i32(v):
return struct.pack('>i', v)
def be_f64(v):
return struct.pack('>d', v)
def be_fixed(v):
"""16.16 fixed-point"""
return struct.pack('>i', int(v * 65536))
# ============================================================================
# PackBits RLE encoder
# ============================================================================
def packbits_row(row):
out = bytearray()
n = len(row)
i = 0
while i < n:
run_len = 1
while run_len < 128 and i + run_len < n and row[i + run_len] == row[i]:
run_len += 1
if run_len > 1:
out.append((1 - run_len) & 0xFF)
out.append(row[i])
i += run_len
else:
start = i
lit_len = 0
while lit_len < 128 and i + lit_len < n:
if i + lit_len + 1 < n and row[i + lit_len] == row[i + lit_len + 1]:
r = 2
while r < 128 and i + lit_len + r < n and row[i + lit_len + r] == row[i + lit_len]:
r += 1
if r >= 3:
break
lit_len += 1
if lit_len == 0:
lit_len = 1
out.append(lit_len - 1)
out.extend(row[start:start + lit_len])
i += lit_len
return bytes(out)
def packbits_plane(plane, w, h):
row_lengths = []
compressed_rows = []
for y in range(h):
row = plane[y * w:(y + 1) * w]
cr = packbits_row(row)
row_lengths.append(len(cr))
compressed_rows.append(cr)
out = bytearray()
for rl in row_lengths:
out.extend(be_u16(rl))
for cr in compressed_rows:
out.extend(cr)
return bytes(out)
# ============================================================================
# PSD Color helpers
# ============================================================================
def psd_color_rgb(r, g, b):
"""10-byte PSD color: 2B space + 4x2B components (RGB color space = 0)"""
buf = bytearray()
buf.extend(be_u16(0)) # color space: RGB
buf.extend(be_u16(r * 256)) # red (0-65535)
buf.extend(be_u16(g * 256)) # green
buf.extend(be_u16(b * 256)) # blue
buf.extend(be_u16(0)) # padding
return bytes(buf)
# ============================================================================
# PSD Descriptor writer (for lfx2)
# ============================================================================
def desc_key(key_bytes):
"""Write a 4-byte descriptor key (key_format=0 + 4B key)"""
return be_u32(0) + key_bytes[:4].ljust(4, b'\x00')
def desc_bool(key, value):
return desc_key(key) + b'bool' + (b'\x01' if value else b'\x00')
def desc_enum(key, type_id, value_id):
# PSD enum: [key][type='enum'][type_len=0][type_id][value_len=0][value_id]
return desc_key(key) + b'enum' + be_u32(0) + type_id[:4].ljust(4, b'\x00') + be_u32(0) + value_id[:4].ljust(4, b'\x00')
def desc_unit_float(key, unit, value):
return desc_key(key) + b'UntF' + unit[:4].ljust(4, b'\x00') + be_f64(value)
def desc_double(key, value):
return desc_key(key) + b'doub' + be_f64(value)
def desc_long(key, value):
return desc_key(key) + b'long' + be_u32(value)
def desc_object(key, class_id, fields_data):
"""Object with class_id and pre-serialized fields.
fields_data = (count_bytes + field_bytes)"""
body = bytearray()
if len(class_id) == 4:
body.extend(be_u32(0)) # classID length = 0 → 4-byte follows
body.extend(class_id)
else:
body.extend(be_u32(len(class_id)))
body.extend(class_id)
body.extend(fields_data)
if key is None:
return bytes(body)
return desc_key(key) + b'Objc' + bytes(body)
def desc_object_top(class_id, fields_data):
"""Top-level descriptor (no key prefix, no Objc type tag)"""
body = bytearray()
if len(class_id) == 4:
body.extend(be_u32(0))
body.extend(class_id)
else:
body.extend(be_u32(len(class_id)))
body.extend(class_id)
body.extend(fields_data)
return bytes(body)
def desc_color_rgb(r, g, b):
"""RGB color descriptor object"""
fields = bytearray()
fields.extend(desc_double(b'Rd ', float(r)))
fields.extend(desc_double(b'Grn ', float(g)))
fields.extend(desc_double(b'Bl ', float(b)))
return desc_object(None, b'RGBC', be_u32(3) + bytes(fields))
def desc_gradient_basic():
"""Basic black-to-white gradient descriptor"""
# Color stops
cstop0_fields = desc_double(b'Clr ', 0) # placeholder - need color object
# Build properly
cstop0 = bytearray()
cstop0.extend(be_u32(0))
cstop0.extend(b'Clrt')
cstop0.extend(be_u32(2)) # 2 fields
cstop0.extend(desc_object(b'Clr ', b'RGBC', be_u32(3) + desc_double(b'Rd ', 0) + desc_double(b'Grn ', 0) + desc_double(b'Bl ', 0)))
cstop0.extend(desc_long(b'In ', 0))
cstop0.extend(desc_long(b'Lctn ', 0))
cstop0.extend(desc_long(b'Mdpn ', 50))
cstop1 = bytearray()
cstop1.extend(be_u32(0))
cstop1.extend(b'Clrt')
cstop1.extend(be_u32(2))
cstop1.extend(desc_object(b'Clr ', b'RGBC', be_u32(3) + desc_double(b'Rd ', 255) + desc_double(b'Grn ', 255) + desc_double(b'Bl ', 255)))
cstop1.extend(desc_long(b'In ', 0))
cstop1.extend(desc_long(b'Lctn ', 4096))
cstop1.extend(desc_long(b'Mdpn ', 50))
# Transparency stops
tstop0 = bytearray()
tstop0.extend(be_u32(0))
tstop0.extend(b'Trns')
tstop0.extend(be_u32(2))
tstop0.extend(desc_double(b'Opct', 100.0))
tstop0.extend(desc_long(b'Lctn ', 0))
tstop0.extend(desc_long(b'Mdpn ', 50))
tstop1 = bytearray()
tstop1.extend(be_u32(0))
tstop1.extend(b'Trns')
tstop1.extend(be_u32(2))
tstop1.extend(desc_double(b'Opct', 100.0))
tstop1.extend(desc_long(b'Lctn ', 4096))
tstop1.extend(desc_long(b'Mdpn ', 50))
grad_fields = bytearray()
# Gradient class ID
grad_fields.extend(desc_key(b'Nm ') + b'TEXT' + be_u32(0)) # empty name (0 chars)
grad_fields.extend(desc_enum(b'GrdF', b'GrdF', b'Lnr '))
grad_fields.extend(desc_long(b'Intr', 4096))
# Color stops list
cs_list = bytearray()
cs_list.extend(desc_key(b'Clrs') + b'VlLs' + be_u32(2))
cs_list.extend(b'Objc' + bytes(cstop0))
cs_list.extend(b'Objc' + bytes(cstop1))
# Transparency stops list
ts_list = bytearray()
ts_list.extend(desc_key(b'Trns') + b'VlLs' + be_u32(2))
ts_list.extend(b'Objc' + bytes(tstop0))
ts_list.extend(b'Objc' + bytes(tstop1))
grad_fields.extend(bytes(cs_list))
grad_fields.extend(bytes(ts_list))
return desc_object(None, b'Grdn', bytes(grad_fields))
# ============================================================================
# lrFX (legacy) effect writers
# ============================================================================
def write_lrfx_block(effects):
"""Write complete lrFX tagged block data."""
buf = bytearray()
buf.extend(be_u32(0)) # version
buf.extend(be_u16(len(effects))) # effect count
for fx in effects:
buf.extend(b'8BIM') # signature before each effect
buf.extend(fx['lrfx_sig']) # effect signature (dsdw, isdw, etc.)
buf.extend(fx['lrfx_data'])
return bytes(buf)
def lrfx_common_state():
"""cmnS common state block (required by spec)."""
data = bytearray()
data.extend(be_u32(7)) # size of remaining items
data.extend(be_u32(0)) # version
data.append(1) # visible = true
data.extend(be_u16(0)) # unused
return {
'lrfx_sig': b'cmnS',
'lrfx_data': bytes(data),
}
def lrfx_drop_shadow(blur, angle, distance, color, blend_mode, enabled, opacity):
data = bytearray()
data.extend(be_u32(41)) # size
data.extend(be_u32(0)) # version
data.extend(be_u32(blur)) # blur (pixels)
data.extend(be_u32(0)) # intensity
data.extend(be_i32(int(angle * 65536))) # angle (16.16 fixed)
data.extend(be_i32(int(distance * 65536))) # distance (16.16 fixed)
data.extend(psd_color_rgb(*color[:3])) # 10B color
data.extend(b'8BIM')
data.extend(blend_mode) # 4B blend key
data.append(1 if enabled else 0) # enabled
data.append(1) # use this angle
data.append(int(opacity * 255)) # opacity
return {
'lrfx_sig': b'dsdw',
'lrfx_data': bytes(data),
}
def lrfx_inner_shadow(blur, angle, distance, color, blend_mode, enabled, opacity):
data = bytearray()
data.extend(be_u32(41))
data.extend(be_u32(0))
data.extend(be_u32(blur))
data.extend(be_u32(0))
data.extend(be_i32(int(angle * 65536)))
data.extend(be_i32(int(distance * 65536)))
data.extend(psd_color_rgb(*color[:3]))
data.extend(b'8BIM')
data.extend(blend_mode)
data.append(1 if enabled else 0)
data.append(1)
data.append(int(opacity * 255))
return {
'lrfx_sig': b'isdw',
'lrfx_data': bytes(data),
}
def lrfx_outer_glow(blur, color, blend_mode, enabled, opacity):
data = bytearray()
data.extend(be_u32(32))
data.extend(be_u32(0))
data.extend(be_u32(blur))
data.extend(be_u32(0)) # intensity
data.extend(psd_color_rgb(*color[:3]))
data.extend(b'8BIM')
data.extend(blend_mode)
data.append(1 if enabled else 0)
data.append(int(opacity * 255))
return {
'lrfx_sig': b'oglw',
'lrfx_data': bytes(data),
}
def lrfx_inner_glow(blur, color, blend_mode, enabled, opacity):
data = bytearray()
data.extend(be_u32(33))
data.extend(be_u32(0))
data.extend(be_u32(blur))
data.extend(be_u32(0))
data.extend(psd_color_rgb(*color[:3]))
data.extend(b'8BIM')
data.extend(blend_mode)
data.append(1 if enabled else 0)
data.append(int(opacity * 255))
data.append(0) # invert = false
return {
'lrfx_sig': b'iglw',
'lrfx_data': bytes(data),
}
def lrfx_bevel_emboss(angle, depth, blur, hl_blend, sh_blend, hl_color, sh_color,
style, hl_opacity, sh_opacity, enabled, use_angle, direction):
data = bytearray()
data.extend(be_u32(58)) # size
data.extend(be_u32(0)) # version
data.extend(be_i32(int(angle * 65536))) # angle
data.extend(be_u32(depth)) # depth
data.extend(be_u32(blur)) # blur
data.extend(b'8BIM' + hl_blend) # highlight blend
data.extend(b'8BIM' + sh_blend) # shadow blend
data.extend(psd_color_rgb(*hl_color[:3])) # highlight color
data.extend(psd_color_rgb(*sh_color[:3])) # shadow color
data.append(style) # bevel style
data.append(int(hl_opacity * 255)) # highlight opacity
data.append(int(sh_opacity * 255)) # shadow opacity
data.append(1 if enabled else 0) # enabled
data.append(1 if use_angle else 0) # use this angle
data.append(direction) # up/down
return {
'lrfx_sig': b'bevl',
'lrfx_data': bytes(data),
}
def lrfx_color_overlay(blend_mode, color, enabled, opacity):
data = bytearray()
data.extend(be_u32(34)) # size
data.extend(be_u32(2)) # version
data.extend(b'8BIM')
data.extend(blend_mode)
data.extend(psd_color_rgb(*color[:3]))
data.append(int(opacity * 255))
data.append(1 if enabled else 0)
data.extend(psd_color_rgb(*color[:3])) # native color
return {
'lrfx_sig': b'sofi',
'lrfx_data': bytes(data),
}
# ============================================================================
# lfx2 (descriptor-based) effect writers
# ============================================================================
def write_lfx2_block(effects):
"""Write lfx2 tagged block data."""
buf = bytearray()
buf.extend(be_u32(0)) # object effects version
buf.extend(be_u32(16)) # descriptor version (PS6+)
buf.extend(be_u32(16)) # descriptor format version
# Top-level descriptor body
# classID = 'null' (4-byte)
buf.extend(be_u32(0))
buf.extend(b'null')
# item count
buf.extend(be_u32(len(effects)))
for fx in effects:
# fx is raw bytes from build_lfx2_* functions (already includes key+Objc prefix)
buf.extend(fx if isinstance(fx, (bytes, bytearray)) else fx['lfx2_data'])
return bytes(buf)
def _desc_color(key, r, g, b):
"""Descriptor: color object with RGBC class."""
fields = desc_double(b'Rd ', float(r))
fields += desc_double(b'Grn ', float(g))
fields += desc_double(b'Bl ', float(b))
return desc_object(key, b'RGBC', be_u32(3) + fields)
def lfx2_drop_shadow(blur, angle, distance, color, blend_mode_enum, enabled, opacity):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_mode_enum))
fields.extend(desc_object(b'Clr ', b'RGBC',
be_u32(3) + desc_double(b'Rd ', float(color[0])) + desc_double(b'Grn ', float(color[1])) + desc_double(b'Bl ', float(color[2]))))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100.0))
fields.extend(desc_unit_float(b'lagl', b'#Ang', angle))
fields.extend(desc_unit_float(b'Slag', b'#Ang', angle)) # global angle
fields.extend(desc_unit_float(b'Dstn', b'#Pxl', distance))
fields.extend(desc_unit_float(b'Ckmt', b'#Pxl', 0)) # spread
fields.extend(desc_unit_float(b'blur', b'#Pxl', blur))
fields.extend(desc_bool(b'Nose', False)) # noise
fields.extend(desc_long(b'AIDT', 0)) # anti-alias
# Transfer function (contour) - default linear
fields.extend(desc_key(b'Trns') + b'Objc' + be_u32(0) + b'null' + be_u32(2)
+ desc_long(b'Crv ', 0) + desc_key(b'Crv ') + b'VlLs' + be_u32(0))
return {
'lfx2_key': b'DrSh',
'lfx2_data': desc_key(b'DrSh') + b'Objc' + be_u32(0) + b'null' + be_u32(len([1 for _ in range(1)])) # dummy
}
# Actually, let me simplify - write the fields directly
def lfx2_effect(key, class_hint, fields_bytes):
"""Generic lfx2 effect entry."""
return desc_key(key) + b'Objc' + be_u32(0) + b'null' + be_u32(class_hint) + fields_bytes
def build_lfx2_drop_shadow(blur, angle, distance, color, blend_enum, enabled, opacity):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(_desc_color(b'Clr ', *color[:3]))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_unit_float(b'lagl', b'#Ang', angle))
fields.extend(desc_unit_float(b'Dstn', b'#Pxl', distance))
fields.extend(desc_unit_float(b'Ckmt', b'#Pxl', 0))
fields.extend(desc_unit_float(b'blur', b'#Pxl', blur))
fields.extend(desc_bool(b'Nose', False))
n = 9
return desc_key(b'DrSh') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_inner_shadow(blur, angle, distance, color, blend_enum, enabled, opacity):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(_desc_color(b'Clr ', *color[:3]))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_unit_float(b'lagl', b'#Ang', angle))
fields.extend(desc_unit_float(b'Dstn', b'#Pxl', distance))
fields.extend(desc_unit_float(b'Ckmt', b'#Pxl', 0))
fields.extend(desc_unit_float(b'blur', b'#Pxl', blur))
fields.extend(desc_bool(b'Nose', False))
n = 9
return desc_key(b'IrSh') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_outer_glow(blur, color, blend_enum, enabled, opacity):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(_desc_color(b'Clr ', *color[:3]))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_unit_float(b'Ckmt', b'#Pxl', 0))
fields.extend(desc_unit_float(b'blur', b'#Pxl', blur))
fields.extend(desc_bool(b'Nose', False))
n = 7
return desc_key(b'OrGl') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_inner_glow(blur, color, blend_enum, enabled, opacity):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(_desc_color(b'Clr ', *color[:3]))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_unit_float(b'Ckmt', b'#Pxl', 0))
fields.extend(desc_unit_float(b'blur', b'#Pxl', blur))
fields.extend(desc_bool(b'Nose', False))
fields.extend(desc_enum(b'glwS', b'BETE', b'Intr')) # source: center
n = 8
return desc_key(b'IrGl') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_bevel_emboss(style, technique, depth, direction, size, soften,
angle, altitude, hl_blend, hl_color, hl_opacity,
sh_blend, sh_color, sh_opacity, enabled):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'bvlS', b'BESl', style))
fields.extend(desc_enum(b'bvlT', b'BETE', technique))
fields.extend(desc_unit_float(b'srgR', b'#Prc', depth))
fields.extend(desc_enum(b'bvlD', b'BESs', direction))
fields.extend(desc_unit_float(b'Sftn', b'#Pxl', soften))
fields.extend(desc_unit_float(b'blur', b'#Pxl', size))
fields.extend(desc_long(b'Hlag', 1)) # use global angle
fields.extend(desc_unit_float(b'lagl', b'#Ang', angle))
fields.extend(desc_long(b'Lald', 0)) # altitude key (legacy)
fields.extend(desc_unit_float(b'Lald', b'#Ang', altitude))
fields.extend(desc_enum(b'hglM', b'BlnM', hl_blend))
fields.extend(_desc_color(b'hglC', *hl_color[:3]))
fields.extend(desc_unit_float(b'hglO', b'#Prc', hl_opacity * 100))
fields.extend(desc_enum(b'sdwM', b'BlnM', sh_blend))
fields.extend(_desc_color(b'sdwC', *sh_color[:3]))
fields.extend(desc_unit_float(b'sdwO', b'#Prc', sh_opacity * 100))
# TrnsS (highlight transfer), TrnS (shadow transfer) - linear
n = 18
return desc_key(b'ebbl') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_satin(blur, angle, distance, color, blend_enum, enabled, opacity, invert):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(_desc_color(b'Clr ', *color[:3]))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_unit_float(b'lagl', b'#Ang', angle))
fields.extend(desc_unit_float(b'Dstn', b'#Pxl', distance))
fields.extend(desc_unit_float(b'blur', b'#Pxl', blur))
fields.extend(desc_bool(b'MpgS', True))
fields.extend(desc_bool(b'Invr', invert))
n = 9
return desc_key(b'lagl') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_color_overlay(blend_enum, color, enabled, opacity):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(_desc_color(b'Clr ', *color[:3]))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
n = 4
return desc_key(b'SoFi') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_gradient_overlay(blend_enum, enabled, opacity, angle, scale):
grad = desc_gradient_basic()
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(desc_object(b'Grad', b'Grdn',
be_u32(7) + desc_key(b'Nm ') + b'TEXT' + be_u32(0)
+ desc_enum(b'GrdF', b'GrdF', b'Lnr ')
+ desc_long(b'Intr', 4096)
+ desc_key(b'Clrs') + b'VlLs' + be_u32(2)
+ b'Objc' + be_u32(0) + b'Clrt' + be_u32(4)
+ desc_object(b'Clr ', b'RGBC', be_u32(3) + desc_double(b'Rd ', 0.0) + desc_double(b'Grn ', 0.0) + desc_double(b'Bl ', 0.0))
+ desc_long(b'In ', 0) + desc_long(b'Lctn ', 0) + desc_long(b'Mdpn ', 50)
+ b'Objc' + be_u32(0) + b'Clrt' + be_u32(4)
+ desc_object(b'Clr ', b'RGBC', be_u32(3) + desc_double(b'Rd ', 255.0) + desc_double(b'Grn ', 255.0) + desc_double(b'Bl ', 255.0))
+ desc_long(b'In ', 0) + desc_long(b'Lctn ', 4096) + desc_long(b'Mdpn ', 50)
+ desc_key(b'Trns') + b'VlLs' + be_u32(2)
+ b'Objc' + be_u32(0) + b'Trns' + be_u32(2) + desc_double(b'Opct', 100.0) + desc_long(b'Lctn ', 0) + desc_long(b'Mdpn ', 50)
+ b'Objc' + be_u32(0) + b'Trns' + be_u32(2) + desc_double(b'Opct', 100.0) + desc_long(b'Lctn ', 4096) + desc_long(b'Mdpn ', 50)
))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_long(b'Angl', int(angle)))
fields.extend(desc_enum(b'Type ', b'GrdT', b'Lnr '))
fields.extend(desc_bool(b'Algn', True))
fields.extend(desc_unit_float(b'Scl ', b'#Prc', scale))
fields.extend(desc_long(b'Ofst', 0)) # offset
n = 8
return desc_key(b'GrFl') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_pattern_overlay(blend_enum, enabled, opacity, scale):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_unit_float(b'Scl ', b'#Prc', scale))
# Pttr (pattern) - use a minimal pattern reference
fields.extend(desc_key(b'Pttr') + b'Objc' + be_u32(0) + b'null' + be_u32(2)
+ desc_key(b'Nm ') + b'TEXT' + be_u32(0)
+ desc_key(b'Idnt') + b'TEXT' + be_u32(0))
fields.extend(desc_bool(b'Lnkd', True))
n = 6
return desc_key(b'PtFl') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
def build_lfx2_stroke(position, blend_enum, enabled, opacity, size, color):
fields = bytearray()
fields.extend(desc_bool(b'enab', enabled))
fields.extend(desc_enum(b'Stly', b'FStl', position))
fields.extend(desc_enum(b'PntT', b'FrFl', b'SClr'))
fields.extend(desc_enum(b'Md ', b'BlnM', blend_enum))
fields.extend(desc_unit_float(b'Opct', b'#Prc', opacity * 100))
fields.extend(desc_unit_float(b'Sz ', b'#Pxl', size))
fields.extend(_desc_color(b'Clr ', *color[:3]))
n = 7
return desc_key(b'FrFX') + b'Objc' + be_u32(0) + b'null' + be_u32(n) + bytes(fields)
# ============================================================================
# Asymmetric shape generator
# ============================================================================
def generate_asymmetric_shape(w, h):
"""Generate an L-shaped RGBA pixel buffer.
Asymmetric in both X and Y to prevent equivalent transforms.
Hard edges (no anti-aliasing) for clean comparison.
Layout (within 512x512):
- Horizontal arm: x=[100..380], y=[180..230] (280x50)
- Vertical arm: x=[140..190], y=[180..400] (50x220)
This creates an L with:
- Horizontal arm wider than vertical (280 vs 50 in x)
- Vertical arm taller than horizontal (220 vs 50 in y)
- Asymmetric overlap position
"""
pixels = bytearray(w * h * 4)
fill = (70, 130, 180) # steel blue
for y in range(h):
for x in range(w):
idx = (y * w + x) * 4
in_horiz = (100 <= x < 380) and (180 <= y < 230)
in_vert = (140 <= x < 190) and (180 <= y < 400)
if in_horiz or in_vert:
pixels[idx] = fill[0]
pixels[idx + 1] = fill[1]
pixels[idx + 2] = fill[2]
pixels[idx + 3] = 255
return bytes(pixels)
def generate_diamond_shape(w, h):
"""An asymmetric diamond/rhombus for the multi-layer file.
Different horizontal and vertical extents."""
pixels = bytearray(w * h * 4)
cx, cy = 256, 250
rx, ry = 160, 120 # asymmetric radii
for y in range(h):
for x in range(w):
dx = abs(x - cx) / rx
dy = abs(y - cy) / ry
if dx + dy <= 1.0:
idx = (y * w + x) * 4
pixels[idx] = 180
pixels[idx + 1] = 80
pixels[idx + 2] = 60
pixels[idx + 3] = 255
return bytes(pixels)
def generate_triangle_shape(w, h):
"""Asymmetric triangle for variety in multi-layer file."""
pixels = bytearray(w * h * 4)
# Triangle vertices - deliberately asymmetric
x0, y0 = 200, 140
x1, y1 = 380, 320
x2, y2 = 150, 350
def sign(px, py, ax, ay, bx, by):
return (px - bx) * (ay - by) - (ax - bx) * (py - by)
for y in range(h):
for x in range(w):
d1 = sign(x, y, x0, y0, x1, y1)
d2 = sign(x, y, x1, y1, x2, y2)
d3 = sign(x, y, x2, y2, x0, y0)
has_neg = (d1 < 0) or (d2 < 0) or (d3 < 0)
has_pos = (d1 > 0) or (d2 > 0) or (d3 > 0)
if not (has_neg and has_pos):
idx = (y * w + x) * 4
pixels[idx] = 60
pixels[idx + 1] = 160
pixels[idx + 2] = 80
pixels[idx + 3] = 255
return bytes(pixels)
# ============================================================================
# PSD file writer
# ============================================================================
class PsdFile:
def __init__(self, w, h):
self.w = w
self.h = h
self.layers = []
def add_layer(self, name, pixels, lrfx_effects=None, lfx2_effects=None,
opacity=1.0, visible=True, blend_mode=b'norm'):
self.layers.append({
'name': name,
'pixels': pixels,
'lrfx': lrfx_effects or [],
'lfx2': lfx2_effects or [],
'opacity': opacity,
'visible': visible,
'blend_mode': blend_mode,
})
def save(self, path):
buf = bytearray()
self._header(buf)
self._color_mode(buf)
self._image_resources(buf)
self._layer_mask_info(buf)
self._merged_image(buf)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'wb') as f:
f.write(buf)
print(f" Saved: {path} ({len(buf)} bytes)")
def _header(self, buf):
buf.extend(b'8BPS')
buf.extend(be_u16(1)) # version
buf.extend(b'\x00' * 6) # reserved
buf.extend(be_u16(4)) # channels (RGBA)
buf.extend(be_u32(self.h))
buf.extend(be_u32(self.w))
buf.extend(be_u16(8)) # depth
buf.extend(be_u16(3)) # color mode = RGB
def _color_mode(self, buf):
buf.extend(be_u32(0))
def _image_resources(self, buf):
# Resolution info block (0x03ED)
# Format: 16 bytes = h_res(4) + h_unit(2) + h_display_unit(2) + v_res(4) + v_unit(2) + v_display_unit(2)
res = bytearray()
res.extend(b'8BIM')
res.extend(be_u16(0x03ED))
res.extend(b'\x00\x00') # no name
rd = bytearray()
rd.extend(struct.pack('>I', 72 << 16)) # h_res = 72 dpi (Fixed 16.16)
rd.extend(be_u16(1)) # h_res_unit: 1 = pixels/inch
rd.extend(be_u16(1)) # h_display_unit: 1 = pixels/inch
rd.extend(struct.pack('>I', 72 << 16)) # v_res = 72 dpi
rd.extend(be_u16(1)) # v_res_unit
rd.extend(be_u16(1)) # v_display_unit
res.extend(be_u32(len(rd)))
res.extend(rd)
if len(res) % 2 != 0:
res.append(0)
buf.extend(be_u32(len(res)))
buf.extend(res)
def _layer_mask_info(self, buf):
li = self._build_layer_info()
# Global Layer Mask Info (empty but MUST be present for Photopea)
glmi = be_u32(0)
lami_len = 4 + len(li) + len(glmi)
buf.extend(be_u32(lami_len))
buf.extend(be_u32(len(li)))
buf.extend(li)
buf.extend(glmi)
def _build_layer_info(self):
buf = bytearray()
if not self.layers:
buf.extend(be_i16(0))
return buf
count = len(self.layers)
buf.extend(be_i16(-count)) # negative = use transparency
all_ch_data = []
for layer in self.layers:
w, h = self.w, self.h
px = layer['pixels']
# Bounds
buf.extend(be_u32(0)) # top
buf.extend(be_u32(0)) # left
buf.extend(be_u32(h)) # bottom
buf.extend(be_u32(w)) # right
# Channels
buf.extend(be_u16(4))
ch_compressed = []
for ch_id in [-1, 0, 1, 2]:
plane = bytearray(w * h)
for y in range(h):
for x in range(w):
idx = (y * w + x) * 4
if ch_id == -1:
plane[y * w + x] = px[idx + 3]
elif ch_id == 0:
plane[y * w + x] = px[idx]
elif ch_id == 1:
plane[y * w + x] = px[idx + 1]
else:
plane[y * w + x] = px[idx + 2]
ch_compressed.append(packbits_plane(plane, w, h))
for i, ch_id in enumerate([-1, 0, 1, 2]):
buf.extend(be_i16(ch_id))
buf.extend(be_u32(len(ch_compressed[i]) + 2))
# Blend mode signature
buf.extend(b'8BIM')
buf.extend(layer['blend_mode'])
# Opacity, clipping, flags, filler
buf.append(int(layer['opacity'] * 255))
buf.append(0) # clipping
buf.append(0 if layer['visible'] else 2)
buf.append(0)
# Extra data
extra = bytearray()
extra.extend(be_u32(0)) # mask data size
extra.extend(be_u32(0)) # blending ranges size
# Layer name (Pascal string, padded to 4)
name_bytes = layer['name'].encode('utf-8')[:255]
extra.append(len(name_bytes))
extra.extend(name_bytes)
written = 1 + len(name_bytes)
padded = (written + 3) & ~3
extra.extend(b'\x00' * (padded - written))
# lrFX block - DISABLED (lfx2 is sufficient, lrFX has format ambiguities)
# if layer['lrfx']:
# lrfx_data = write_lrfx_block(layer['lrfx'])
# eff = bytearray()
# eff.extend(b'8BIMlrFX')
# eff.extend(be_u32(len(lrfx_data)))
# eff.extend(lrfx_data)
# if len(eff) % 2:
# eff.append(0)
# extra.extend(eff)
# lfx2 block (modern descriptor-based format)
if layer['lfx2']:
lfx2_data = write_lfx2_block(layer['lfx2'])
blk = bytearray()
blk.extend(b'8BIMlfx2')
blk.extend(be_u32(len(lfx2_data)))
blk.extend(lfx2_data)
if len(blk) % 2:
blk.append(0)
extra.extend(blk)
buf.extend(be_u32(len(extra)))
buf.extend(extra)
all_ch_data.append(ch_compressed)
# Channel image data
for ch_list in all_ch_data:
for cd in ch_list:
buf.extend(be_u16(1)) # PackBits compression
buf.extend(cd)
if len(buf) % 2:
buf.append(0)
return buf
def _merged_image(self, buf):
"""Write merged (composited) image data using raw (uncompressed) format."""
if len(self.layers) == 1:
merged = self.layers[0]['pixels']
else:
merged = bytearray(self.w * self.h * 4)
for i in range(self.w * self.h * 4):
merged[i] = 255
for layer in reversed(self.layers):
px = layer['pixels']
for y in range(self.h):
for x in range(self.w):
idx = (y * self.w + x) * 4
sa = px[idx + 3] / 255.0 * layer['opacity']
if sa > 0:
da = merged[idx + 3] / 255.0
out_a = sa + da * (1 - sa)
if out_a > 0:
for c in range(3):
sc = px[idx + c] / 255.0
dc = merged[idx + c] / 255.0
merged[idx + c] = int((sc * sa + dc * da * (1 - sa)) / out_a * 255)
merged[idx + 3] = int(out_a * 255)
merged = bytes(merged)
# compression = 0 (raw, no compression)
buf.extend(be_u16(0))
# Write raw pixel data channel-by-channel (R, G, B, A)
for ch_idx in range(4):
for y in range(self.h):
for x in range(self.w):
idx = (y * self.w + x) * 4
buf.append(merged[idx + ch_idx])
# ============================================================================
# Effect definitions for test files
# ============================================================================
BLEND_NORMAL = b'norm'
BLEND_MULTIPLY = b'mul '
BLEND_SCREEN = b'scrn'
BLEND_OVERLAY = b'over'
BLEND_ENUM_NORMAL = b'Nrml'
BLEND_ENUM_MULTIPLY = b'Mltp'
BLEND_ENUM_SCREEN = b'Scrn'
BLEND_ENUM_OVERLAY = b'Ovrl'
def make_effects_drop_shadow():
lrfx = [
lrfx_common_state(),
lrfx_drop_shadow(blur=10, angle=135, distance=15, color=(0,0,0),
blend_mode=BLEND_NORMAL, enabled=True, opacity=0.75),
]
lfx2 = [build_lfx2_drop_shadow(blur=10, angle=135, distance=15, color=(0,0,0),
blend_enum=BLEND_ENUM_NORMAL, enabled=True, opacity=0.75)]
return lrfx, lfx2
def make_effects_inner_shadow():
lrfx = [
lrfx_common_state(),
lrfx_inner_shadow(blur=8, angle=135, distance=10, color=(0,0,0),
blend_mode=BLEND_NORMAL, enabled=True, opacity=0.75),
]
lfx2 = [build_lfx2_inner_shadow(blur=8, angle=135, distance=10, color=(0,0,0),
blend_enum=BLEND_ENUM_NORMAL, enabled=True, opacity=0.75)]
return lrfx, lfx2
def make_effects_outer_glow():
lrfx = [
lrfx_common_state(),
lrfx_outer_glow(blur=20, color=(255, 200, 0), blend_mode=BLEND_SCREEN,
enabled=True, opacity=0.75),
]
lfx2 = [build_lfx2_outer_glow(blur=20, color=(255, 200, 0), blend_enum=BLEND_ENUM_SCREEN,
enabled=True, opacity=0.75)]
return lrfx, lfx2
def make_effects_inner_glow():
lrfx = [
lrfx_common_state(),
lrfx_inner_glow(blur=15, color=(255, 100, 0), blend_mode=BLEND_SCREEN,
enabled=True, opacity=0.8),
]
lfx2 = [build_lfx2_inner_glow(blur=15, color=(255, 100, 0), blend_enum=BLEND_ENUM_SCREEN,
enabled=True, opacity=0.8)]
return lrfx, lfx2
def make_effects_bevel_inner():
lrfx = [
lrfx_common_state(),
lrfx_bevel_emboss(angle=120, depth=200, blur=10,
hl_blend=BLEND_SCREEN, sh_blend=BLEND_MULTIPLY,
hl_color=(255, 255, 255), sh_color=(0, 0, 0),
style=1, hl_opacity=0.75, sh_opacity=0.75,
enabled=True, use_angle=True, direction=0),
]
lfx2 = [build_lfx2_bevel_emboss(
style=b'InrB', technique=b'SfBL', depth=200, direction=b'In ',
size=10, soften=0, angle=120, altitude=30,
hl_blend=BLEND_ENUM_SCREEN, hl_color=(255, 255, 255), hl_opacity=0.75,
sh_blend=BLEND_ENUM_MULTIPLY, sh_color=(0, 0, 0), sh_opacity=0.75,
enabled=True)]
return lrfx, lfx2
def make_effects_bevel_outer():
lrfx = [
lrfx_common_state(),
lrfx_bevel_emboss(angle=120, depth=200, blur=10,
hl_blend=BLEND_SCREEN, sh_blend=BLEND_MULTIPLY,
hl_color=(255, 255, 255), sh_color=(0, 0, 0),
style=2, hl_opacity=0.75, sh_opacity=0.75,
enabled=True, use_angle=True, direction=0),
]
lfx2 = [build_lfx2_bevel_emboss(
style=b'OtrB', technique=b'SfBL', depth=200, direction=b'In ',
size=10, soften=0, angle=120, altitude=30,
hl_blend=BLEND_ENUM_SCREEN, hl_color=(255, 255, 255), hl_opacity=0.75,
sh_blend=BLEND_ENUM_MULTIPLY, sh_color=(0, 0, 0), sh_opacity=0.75,
enabled=True)]
return lrfx, lfx2
def make_effects_emboss():
lrfx = [
lrfx_common_state(),
lrfx_bevel_emboss(angle=120, depth=200, blur=8,
hl_blend=BLEND_SCREEN, sh_blend=BLEND_MULTIPLY,
hl_color=(255, 255, 255), sh_color=(0, 0, 0),
style=3, hl_opacity=0.75, sh_opacity=0.75,
enabled=True, use_angle=True, direction=0),
]
lfx2 = [build_lfx2_bevel_emboss(
style=b'Embs', technique=b'SfBL', depth=200, direction=b'In ',
size=8, soften=0, angle=120, altitude=30,
hl_blend=BLEND_ENUM_SCREEN, hl_color=(255, 255, 255), hl_opacity=0.75,
sh_blend=BLEND_ENUM_MULTIPLY, sh_color=(0, 0, 0), sh_opacity=0.75,
enabled=True)]
return lrfx, lfx2
def make_effects_pillow_emboss():
lrfx = [
lrfx_common_state(),
lrfx_bevel_emboss(angle=120, depth=200, blur=8,
hl_blend=BLEND_SCREEN, sh_blend=BLEND_MULTIPLY,
hl_color=(255, 255, 255), sh_color=(0, 0, 0),
style=4, hl_opacity=0.75, sh_opacity=0.75,
enabled=True, use_angle=True, direction=0),
]
lfx2 = [build_lfx2_bevel_emboss(
style=b'PlEb', technique=b'SfBL', depth=200, direction=b'In ',
size=8, soften=0, angle=120, altitude=30,
hl_blend=BLEND_ENUM_SCREEN, hl_color=(255, 255, 255), hl_opacity=0.75,
sh_blend=BLEND_ENUM_MULTIPLY, sh_color=(0, 0, 0), sh_opacity=0.75,
enabled=True)]
return lrfx, lfx2
def make_effects_satin():
lrfx = [
lrfx_common_state(),
# Satin is not in original lrFX spec, but we include a placeholder
]
lfx2 = [build_lfx2_satin(blur=20, angle=19, distance=11, color=(0, 0, 0),
blend_enum=BLEND_ENUM_MULTIPLY, enabled=True, opacity=0.5, invert=False)]
return lrfx, lfx2
def make_effects_color_overlay():
lrfx = [
lrfx_common_state(),
lrfx_color_overlay(blend_mode=BLEND_NORMAL, color=(220, 50, 50),
enabled=True, opacity=0.8),
]
lfx2 = [build_lfx2_color_overlay(blend_enum=BLEND_ENUM_NORMAL, color=(220, 50, 50),
enabled=True, opacity=0.8)]
return lrfx, lfx2
def make_effects_gradient_overlay():
lrfx = [
lrfx_common_state(),
]
lfx2 = [build_lfx2_gradient_overlay(blend_enum=BLEND_ENUM_NORMAL, enabled=True,
opacity=1.0, angle=90, scale=100)]
return lrfx, lfx2
def make_effects_pattern_overlay():
lrfx = [
lrfx_common_state(),
]
lfx2 = [build_lfx2_pattern_overlay(blend_enum=BLEND_ENUM_NORMAL, enabled=True,
opacity=1.0, scale=100)]
return lrfx, lfx2
def make_effects_stroke_inside():
lrfx = [
lrfx_common_state(),
]
lfx2 = [build_lfx2_stroke(position=b'InsF', blend_enum=BLEND_ENUM_NORMAL,
enabled=True, opacity=1.0, size=6, color=(255, 0, 0))]
return lrfx, lfx2
def make_effects_stroke_center():
lrfx = [
lrfx_common_state(),
]
lfx2 = [build_lfx2_stroke(position=b'CtrF', blend_enum=BLEND_ENUM_NORMAL,
enabled=True, opacity=1.0, size=6, color=(0, 255, 0))]
return lrfx, lfx2
def make_effects_stroke_outside():
lrfx = [
lrfx_common_state(),
]
lfx2 = [build_lfx2_stroke(position=b'OutF', blend_enum=BLEND_ENUM_NORMAL,
enabled=True, opacity=1.0, size=6, color=(0, 0, 255))]
return lrfx, lfx2
# ============================================================================
# Test file definitions
# ============================================================================
TEST_FILES = [
("drop_shadow", "Drop Shadow", make_effects_drop_shadow),
("inner_shadow", "Inner Shadow", make_effects_inner_shadow),
("outer_glow", "Outer Glow", make_effects_outer_glow),
("inner_glow", "Inner Glow", make_effects_inner_glow),
("bevel_inner", "Bevel Inner", make_effects_bevel_inner),
("bevel_outer", "Bevel Outer", make_effects_bevel_outer),
("emboss", "Emboss", make_effects_emboss),
("pillow_emboss", "Pillow Emboss", make_effects_pillow_emboss),
("satin", "Satin", make_effects_satin),
("color_overlay", "Color Overlay", make_effects_color_overlay),
("gradient_overlay", "Gradient Overlay", make_effects_gradient_overlay),
("pattern_overlay", "Pattern Overlay", make_effects_pattern_overlay),
("stroke_inside", "Stroke Inside", make_effects_stroke_inside),
("stroke_center", "Stroke Center", make_effects_stroke_center),
("stroke_outside", "Stroke Outside", make_effects_stroke_outside),
]
def generate_individual_tests(output_dir):
"""Generate one PSD per effect type."""
shape = generate_asymmetric_shape(CANVAS_SIZE, CANVAS_SIZE)
PREFIX = "fx_"
for file_name, layer_name, effect_fn in TEST_FILES:
fname = f"{PREFIX}{file_name}"
print(f"Generating {fname}...")
lrfx, lfx2 = effect_fn()
psd = PsdFile(CANVAS_SIZE, CANVAS_SIZE)
psd.add_layer(layer_name, shape, lrfx_effects=lrfx, lfx2_effects=lfx2)
psd.save(os.path.join(output_dir, f"{fname}.psd"))
def generate_multi_layer_test(output_dir):
"""Generate one PSD with all effects on separate layers."""
fname = "fx_multi_all_fx"
print(f"Generating {fname}...")
psd = PsdFile(CANVAS_SIZE, CANVAS_SIZE)
shapes = [
generate_asymmetric_shape(CANVAS_SIZE, CANVAS_SIZE),
generate_diamond_shape(CANVAS_SIZE, CANVAS_SIZE),
generate_triangle_shape(CANVAS_SIZE, CANVAS_SIZE),
]
# Assign shapes cyclically
for i, (file_name, layer_name, effect_fn) in enumerate(TEST_FILES):
lrfx, lfx2 = effect_fn()
shape = shapes[i % len(shapes)]
psd.add_layer(f"{layer_name}", shape, lrfx_effects=lrfx, lfx2_effects=lfx2)
psd.save(os.path.join(output_dir, f"{fname}.psd"))
def generate_no_effect_baseline(output_dir):
"""Generate a baseline PSD with no effects for reference."""
fname = "fx_baseline"
print(f"Generating {fname}...")
shape = generate_asymmetric_shape(CANVAS_SIZE, CANVAS_SIZE)
psd = PsdFile(CANVAS_SIZE, CANVAS_SIZE)
psd.add_layer("Baseline Shape", shape)
psd.save(os.path.join(output_dir, f"{fname}.psd"))
# ============================================================================
# Main
# ============================================================================
def main():
output_dir = OUTPUT_DIR
if len(sys.argv) > 2 and sys.argv[1] == '--output-dir':
output_dir = sys.argv[2]
print(f"Output directory: {output_dir}")
os.makedirs(output_dir, exist_ok=True)
generate_no_effect_baseline(output_dir)
generate_individual_tests(output_dir)
generate_multi_layer_test(output_dir)
print(f"\nDone! Generated {len(TEST_FILES) + 2} PSD files in {output_dir}")
print("\nNext steps:")
print(" 1. Open each PSD in Photopea (https://www.photopea.com)")
print(" 2. File → Export as → PNG → save as {name}_reference.png")
print(" 3. Run: python3 tools/compare_renders.py")
if __name__ == '__main__':
main()