Fix blending ranges: zero composite range made PS skip layer blending

PS reads layer blending ranges to decide if a layer participates in
compositing. psd_tools wrote all-zero ranges (composite 0..0), which tells
Photoshop the layer has an empty blend range -> the canvas renders empty
even though layer pixels are correct (Ctrl+C/V shows content, export PNG is
transparent, save-as by PS keeps the bad ranges). Now writes full-range
(0..65535) composite + channel ranges like PS-generated files, verified
against the reference PSD.
This commit is contained in:
AskaEth committed 2026-08-27 10:02:17 +08:00
1 parent 8af857d45a
commit edef8799ce
1 file changed
+18 -12
+18 -12
View File
@@ -501,7 +501,10 @@ def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
# extra data
extra = bytearray()
extra += struct.pack(">I", 0) # layer mask data length
extra += struct.pack(">I", 8) + b"\x00" * 8 # blending ranges (default)
# blending ranges: composite + 4 channels, ALL full range (0..65535).
# PS writes these as 0x0000/0xffff; a zero composite range tells PS the
# layer does not participate in blending -> the canvas renders empty.
extra += struct.pack(">I", 40) + b"\x00\x00\xff\xff\x00\x00\xff\xff" * 5
nb = name.encode("utf-8")
extra += bytes([min(len(nb), 255)]) + nb[:255]
# pad pascal string to a multiple of 4 bytes
@@ -548,27 +551,30 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int,
for name, opacity, visible, (lx, ly), img in layers:
if visible and opacity > 0:
merged.paste(img, (lx, ly), img)
merged_rgb = merged.convert("RGB") # transparent -> black
# Merged image data is written RAW (compression=0) for maximum
# compatibility with third-party viewers, which often only parse RAW
# merged data and stretch RLE data. Per spec there is ONE 2-byte
# compression field followed by all planes in planar order (RRR GGG BBB).
merged_rgb = None # keep the RGBA merged image for the 4-channel output
# Merged image data is written RAW with a single compression field and
# all planes in planar order (R G B A). 4 channels because the document
# has transparency (negative layer count references the merged alpha).
if np is not None:
arr = np.asarray(merged_rgb)
arr = np.asarray(merged)
merged_block = b"\x00\x00" + b"".join(
arr[..., i].tobytes() for i in range(3))
arr[..., i].tobytes() for i in range(4))
else:
merged_block = b"\x00\x00" + b"".join(
merged_rgb.getchannel(c).tobytes() for c in "RGB")
merged.getchannel(c).tobytes() for c in "RGBA")
# assemble file
# assemble file. Header channels = 4 (document has transparency, like PS
# writes when layers have alpha).
fh = open(out_path, "wb")
fh.write(struct.pack(">4sH6sHIIHH", b"8BPS", 1, b"\x00" * 6, 3, height, width, 8, 3))
fh.write(struct.pack(">4sH6sHIIHH", b"8BPS", 1, b"\x00" * 6, 4, height, width, 8, 3))
fh.write(struct.pack(">I", 0)) # color mode data
fh.write(struct.pack(">I", 0)) # image resources
# Layer info section: [Length(4)] + [layer count(2) + records + channel data]
layer_info_body = struct.pack(">h", len(layers)) + bytes(records) + bytes(payload)
# Negative layer count: abs = layer count, first alpha channel of the
# merged image holds the merged transparency (PS writes this when the
# document has transparent layers).
layer_info_body = struct.pack(">h", -len(layers)) + bytes(records) + bytes(payload)
layer_info = struct.pack(">I", len(layer_info_body)) + layer_info_body
# layer & mask section: length = layer info + global layer mask info
# (the outer length does NOT include its own 4-byte length field)