Files
photocraft/scripts/layered_tiff_fixtures.py
bd632e7eb3 Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215) (#672)
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)

Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.

* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
  the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
  Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
  doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
  masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
  binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
  annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
  warning instead of passing byte-swapped data to other readers. The reader gained an optional
  little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
  bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
  BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
  import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
  options), interleaves its merged planes for the TIFF image and stores the layer section in
  the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
  documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
  and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
  Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
  TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
  byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
  `corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
  round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
  hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs

The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 17:10:35 -04:00

118 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generates the layered-TIFF fixtures under corpus/layered-tiff (gitignored) with an independent
implementation (psdtags + tifffile, BSD-3), so PhotoCraft's reader is checked against a second
writer rather than only against itself:
layered-{le,be}-{8,16}bit.tif 2 layers (one offset, one with transparency), RLE and ZIP
channels, an Intel-order and a Motorola-order variant.
Run with: python scripts/layered_tiff_fixtures.py
Needs: pip install psdtags tifffile numpy
"""
from pathlib import Path
import numpy
import tifffile
from psdtags import (
PsdBlendMode,
PsdChannel,
PsdChannelId,
PsdClippingType,
PsdColorSpaceType,
PsdCompressionType,
PsdFormat,
PsdKey,
PsdLayer,
PsdLayerFlag,
PsdLayerMask,
PsdLayers,
PsdRectangle,
PsdString,
PsdUserMask,
TiffImageSourceData,
)
OUT = Path(__file__).resolve().parent.parent / "corpus" / "layered-tiff"
W, H = 6, 4
def gradient(dtype, seed):
"""A deterministic W×H×4 RGBA ramp on the 8-bit grid (exact at 16 bits too)."""
maxv = 255 if dtype == numpy.uint8 else 65535
img = numpy.zeros((H, W, 4), dtype=numpy.uint32)
for y in range(H):
for x in range(W):
img[y, x] = [(x * 40 + seed) % 256, (y * 60 + seed * 3) % 256, (x * y * 17 + seed) % 256, 255]
img = img * (maxv // 255)
return img.astype(dtype)
def layer(name, data, left, top, blend, opacity, compression):
h, w = data.shape[:2]
rect = PsdRectangle(top, left, top + h, left + w)
channels = []
for cid, idx in [(PsdChannelId.TRANSPARENCY_MASK, 3), (PsdChannelId.CHANNEL0, 0), (PsdChannelId.CHANNEL1, 1), (PsdChannelId.CHANNEL2, 2)]:
channels.append(PsdChannel(channelid=cid, compression=compression, data=numpy.ascontiguousarray(data[:, :, idx])))
return PsdLayer(
name=name,
rectangle=rect,
channels=channels,
mask=PsdLayerMask(),
opacity=opacity,
blendmode=blend,
blending_ranges=(),
clipping=PsdClippingType.BASE,
flags=PsdLayerFlag.PHOTOSHOP5,
info=[PsdString(PsdKey.UNICODE_LAYER_NAME, name)],
)
def build(dtype, psdformat):
lower = gradient(dtype, 1)
upper = gradient(dtype, 7)[:3, :4].copy()
# A soft transparency ramp on the upper layer.
maxv = 255 if dtype == numpy.uint8 else 65535
for y in range(3):
for x in range(4):
upper[y, x, 3] = (x * 80 % 256) * (maxv // 255)
layers = PsdLayers(
key=PsdKey.LAYER if dtype == numpy.uint8 else PsdKey.LAYER_16,
has_transparency=False,
layers=[
layer("Background", lower, 0, 0, PsdBlendMode.NORMAL, 255, PsdCompressionType.RLE),
layer("Upper é", upper, 1, 1, PsdBlendMode.MULTIPLY, 200, PsdCompressionType.ZIP),
],
)
usermask = PsdUserMask(colorspace=PsdColorSpaceType.RGB, components=(65535, 0, 0, 0), opacity=50)
return TiffImageSourceData(name="Layered", psdformat=psdformat, layers=layers, usermask=usermask)
def main():
OUT.mkdir(parents=True, exist_ok=True)
for dtype, bits in [(numpy.uint8, 8), (numpy.uint16, 16)]:
for order, psdformat in [("le", PsdFormat.LE32BIT), ("be", PsdFormat.BE32BIT)]:
isd = build(dtype, psdformat)
composite = gradient(dtype, 1)
path = OUT / f"layered-{order}-{bits}bit.tif"
byteorder = "<" if order == "le" else ">"
tifffile.imwrite(
path,
composite,
byteorder=byteorder,
photometric="rgb",
extrasamples=["unassalpha"],
resolution=(300, 300),
resolutionunit="inch",
extratags=[isd.tifftag()],
metadata=None,
)
back = TiffImageSourceData.fromtiff(path)
names = [l.name for l in back.layers]
assert names == ["Background", "Upper é"], names
print(path.name, path.stat().st_size, "bytes", names, back.psdformat)
if __name__ == "__main__":
main()