Files
2026-07-09 02:59:53 +03:00

284 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""
Minimal PSD writer — byte-exact per Adobe Photoshop File Formats Specification.
Creates a 256x256 RGBA document with one layer (red rectangle).
"""
import struct, os
def be16(v): return struct.pack(">H", v)
def be32(v): return struct.pack(">I", v)
def bei16(v): return struct.pack(">h", v)
def packbits_compress(row):
out = bytearray()
n = len(row)
i = 0
while i < n:
# find run
run = 1
while run < 128 and i + run < n and row[i+run] == row[i]:
run += 1
if run > 1:
out.append((1 - run) & 0xFF)
out.append(row[i])
i += run
else:
# literal
start = i
lit = 0
while lit < 128 and i + lit < n:
nxt = 0
if i + lit + 1 < n and row[i+lit] == row[i+lit+1]:
r = 2
while r < 128 and i+lit+r < n and row[i+lit+r] == row[i+lit]:
r += 1
nxt = r
if nxt >= 3:
break
lit += 1
if lit == 0:
lit = 1
out.append(lit - 1)
out.extend(row[start:start+lit])
i += lit
return bytes(out)
def write_resource(buf, res_id, name, data):
buf.extend(b"8BIM")
buf.extend(be16(res_id))
name_padded = name + b'\x00' if (1 + len(name)) % 2 == 1 else name
buf.append(len(name))
buf.extend(name)
if (1 + len(name)) % 2 == 1:
buf.append(0)
buf.extend(be32(len(data)))
buf.extend(data)
if len(data) % 2 != 1:
pass # even already
# pad data to even
if len(data) % 2 != 0:
buf.append(0)
def write_pascal_padded4(buf, s):
"""Write Pascal string padded to multiple of 4 bytes."""
b = s.encode("ascii") if isinstance(s, str) else s
buf.append(len(b))
buf.extend(b)
total = 1 + len(b)
pad = (4 - total % 4) % 4
buf.extend(b'\x00' * pad)
def write_tagged_block(buf, key, data):
"""Write 8BIM tagged block."""
buf.extend(b"8BIM")
buf.extend(key)
buf.extend(be32(len(data)))
buf.extend(data)
if len(data) % 2 != 0:
buf.append(0)
def make_descriptor_string(s):
"""Write a descriptor UTF-16 string: length(4) + chars(2*N)."""
enc = s.encode("utf-16-be")
return be32(len(enc) // 2) + enc
def main():
W, H = 256, 256
MARGIN = 64
# ── Create layer pixels ──
rgba = bytearray(W * H * 4)
for y in range(H):
for x in range(W):
i = (y * W + x) * 4
if MARGIN <= x < W - MARGIN and MARGIN <= y < H - MARGIN:
rgba[i] = 255 # R
rgba[i+1] = 0 # G
rgba[i+2] = 0 # B
rgba[i+3] = 255 # A
# ── Header (26 bytes) ──
header = bytearray()
header.extend(b"8BPS")
header.extend(be16(1)) # version
header.extend(b'\x00' * 6) # reserved
header.extend(be16(4)) # channels (RGBA)
header.extend(be32(H)) # height
header.extend(be32(W)) # width
header.extend(be16(8)) # depth
header.extend(be16(3)) # color mode (RGB)
# ── Color Mode Data ──
color_mode = be32(0)
# ── Image Resources ──
img_res = bytearray()
# 0x03ED: Resolution info
res_data = bytearray()
res_data.extend(be32(72 << 16)) # hRes (72 DPI fixed)
res_data.extend(be16(1)) # hResUnit (1=pixels/inch)
res_data.extend(be16(1)) # vResUnit
res_data.extend(be32(72 << 16)) # vRes
res_data.extend(be16(1)) # widthUnit
res_data.extend(be16(1)) # heightUnit
write_resource(img_res, 0x03ED, b"", bytes(res_data))
# 0x0419: Global Angle
write_resource(img_res, 0x0419, b"", be32(120))
# pad img_res to even
if len(img_res) % 2 != 0:
img_res.append(0)
# ── Layer & Mask Information ──
# Build layer info section first
layer_info = bytearray()
# Layer count (positive = no groups)
layer_info.extend(bei16(1))
# Layer record
layer_rec = bytearray()
# Rect: top, left, bottom, right
layer_rec.extend(be32(0)) # top
layer_rec.extend(be32(0)) # left
layer_rec.extend(be32(H)) # bottom
layer_rec.extend(be32(W)) # right
# Channel count
layer_rec.extend(be16(4))
# Channel info: (-1=alpha, 0=R, 1=G, 2=B)
# We'll compute channel data lengths later
ch_ids = [-1, 0, 1, 2]
ch_data_list = []
for ch_id in ch_ids:
plane = bytearray()
for y in range(H):
for x in range(W):
idx = (y * W + x) * 4
if ch_id == -1:
plane.append(rgba[idx + 3])
elif ch_id == 0:
plane.append(rgba[idx])
elif ch_id == 1:
plane.append(rgba[idx + 1])
elif ch_id == 2:
plane.append(rgba[idx + 2])
ch_data_list.append(plane)
# Compress each channel
ch_compressed = []
for plane in ch_data_list:
row_lengths = []
compressed_rows = []
for y in range(H):
row = plane[y * W : (y + 1) * W]
compressed = packbits_compress(row)
row_lengths.append(len(compressed))
compressed_rows.append(compressed)
ch_compressed.append((row_lengths, compressed_rows))
# Channel data = compression(2) + row_lengths(H*2) + compressed_data
ch_packed = []
for row_lengths, compressed_rows in ch_compressed:
buf = bytearray()
buf.extend(be16(1)) # compression type = RLE
for rl in row_lengths:
buf.extend(be16(rl))
for cr in compressed_rows:
buf.extend(cr)
ch_packed.append(bytes(buf))
# Write channel info records
for i, ch_id in enumerate(ch_ids):
layer_rec.extend(bei16(ch_id))
layer_rec.extend(be32(len(ch_packed[i])))
# Blend mode
layer_rec.extend(b"8BIMnorm")
layer_rec.append(255) # opacity
layer_rec.append(0) # clipping (base)
layer_rec.append(0) # flags (visible)
layer_rec.append(0) # filler
# Extra data
extra = bytearray()
# Layer mask data (4 bytes, length=0)
extra.extend(be32(0))
# Blending ranges (4 bytes length=40, then 40 bytes of data)
extra.extend(be32(40))
for _ in range(10):
extra.extend(be16(0))
extra.extend(be16(65535))
# Layer name (Pascal string padded to 4)
write_pascal_padded4(extra, "Red Rectangle")
layer_rec.extend(be32(len(extra)))
layer_rec.extend(extra)
# Append channel data
layer_info.extend(layer_rec)
for ch_data in ch_packed:
layer_info.extend(ch_data)
# Pad layer info to even
if len(layer_info) % 2 != 0:
layer_info.append(0)
# Global Layer Mask Info (empty, 4 bytes)
glmi = be32(0)
# Layer & Mask Information section
lami = bytearray()
lami.extend(be32(len(layer_info) + len(glmi))) # length of rest
lami.extend(layer_info)
lami.extend(glmi)
# ── Merged Image Data ──
merged = bytearray()
merged.extend(be16(1)) # compression = RLE
# Row lengths for all channels (per-channel order: RRR...GGG...BBB...AAA...)
for ch_offset in range(4): # R, G, B, A
for y in range(H):
row = bytes(rgba[(y * W + ch_offset)::4][:W]) # wrong, need proper extraction
row = bytearray()
for x in range(W):
idx = (y * W + x) * 4 + ch_offset
row.append(rgba[idx])
compressed = packbits_compress(row)
merged.extend(be16(len(compressed)))
# Compressed data for all channels
for ch_offset in range(4):
for y in range(H):
row = bytearray()
for x in range(W):
idx = (y * W + x) * 4 + ch_offset
row.append(rgba[idx])
compressed = packbits_compress(row)
merged.extend(compressed)
# ── Assemble PSD ──
psd = bytearray()
psd.extend(header)
psd.extend(color_mode)
psd.extend(be32(len(img_res)))
psd.extend(img_res)
psd.extend(lami)
psd.extend(merged)
out_path = "_tmp/python_ref_test.psd"
with open(out_path, "wb") as f:
f.write(psd)
print(f"Wrote {len(psd)} bytes to {out_path}")
# Also verify roundtrip
assert psd[:4] == b"8BPS"
assert len(psd) == 26 + 4 + 4 + len(img_res) + len(lami) + len(merged)
print("Roundtrip check: OK")
if __name__ == "__main__":
main()