1212 lines
45 KiB
Python
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()
|