mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* 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]>
118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
#!/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()
|