The file-open path rejects PSDs nested past MAX_GROUP_DEPTH before
conversion, but the public psd_to_document built file.layer_tree() to the
file's full depth (~16000 levels) before Ctx::build capped it, and layered
TIFF reached it with no preflight at all.
- photocraft-psd: PsdFile::layer_tree_max_depth(n) builds the tree
iteratively and drops the children of groups nested n deep, so walking
or dropping it recursively is bounded. layer_tree() is the uncapped case.
- psd_to_document uses it with MAX_GROUP_DEPTH, the same cap Ctx::build
and apply_link_groups apply, so imports are unchanged.
- Layered TIFF rejects nesting past the cap with the same error as a PSD
(shared psd_import::check_group_depth).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Preserve HDR range in float adjustments and stack statistics
* Cover HDR exposure round trips and Levels record order
* Report the backend used by GPU parity tests
* Compare float PSD oracles at native depth and preserve signed Exposure
* Retain merged alpha metadata when decoding float references
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
View › Rulers (⌘R) now writes unitsAndRulers.showRulers, which is applied
at launch and whenever the preference changes, so new and opened documents
show the rulers as last set, as in Photoshop. The Preferences dialog doesn't
list it: the View menu sets it.
Guides are Vec<f64> (doc, New Guide/Move Guide, guide layouts, artboard and
canvas geometry, snapping, PSD resource 1032), so a guide placed in ruler
units or read from a PSD's 1/32 px positions keeps its exact place.
Scorecard UI-217-20 done (part of #217).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Every effect with a contour (Drop/Inner Shadow, Outer/Inner Glow, Satin,
Bevel & Emboss Gloss Contour and Contour element) now shows a contour
thumbnail with a preset picker and an Anti-aliased checkbox. Clicking the
thumbnail opens the Contour Editor: add/drag/delete points (the shared
point-curve editor), Corner per point, Input/Output, Preset, and Load…/Save…
of .shc contour files, read and written from Adobe's public File Formats
Specification (Contours). Edits go to the dialog's pending params, so the
preview follows and OK applies them in the one layer.layerStyle.replace.
- doc: Contour::Custom gains per-point corner flags (serde default).
- compose: contour_curve_lut splits the spline at corner points; CPU and
GPU effects use it.
- engine: the 12 standard presets with our own control points (older names
still accepted); `contour` params also take a curve object
{name, points, corners}; antiAlias params; Satin contour; the bevel
Contour element edits in place; contours_from_shc / contour_to_shc.
- io: PSD contour points read and write the Cnty (continuity) flag.
- psd: shc.rs parser/writer.
- scorecard FILE-215-15 missing -> partial.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* raw: fit non-DNG colour to the camera's embedded JPEG
* raw look: clippy (as_chunks) and rustfmt after merging main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(io): preserve group fill opacity when exporting OpenRaster (#3048)
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(psd): Align global tagged blocks to four bytes
Use four-byte defaults for global blocks and streamed layer information.
Read global payloads without full copies while preserving block order,
explicit padding and the first layer candidate. Keep two-byte layer
alignment; test canonical padding and lossless PSD/PSB parsing.
* fix(psd): Preserve transparent composite colours and precision
* fix(psd): Check ZIP integrity and bound layer previews
* fix(io): Use document CMYK profiles for PSD fill pixels
Generated fill pixels used the default CMYK separation even when the PSD
embedded a different profile. Scope layer export to the document colour
space so cached pixels and the composite use the same profile.
Check native cached samples against an explicit ICC transform at 8 and
16 bits.
* io: refuse an empty RGB composite before splitting it into planes
chunks_exact(0) panics; an empty decoded composite now returns an error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A 16/32-bit file whose global Lr16/Lr32/Layr block failed to parse opened
as a single Background layer from the merged image with no warning: the
parse error was discarded in PsdFile::from_bytes. The raw block stayed in
the preserved global blocks, so a PSD save wrote the document's fresh Lr16
plus the unreadable original: two layer lists in one file.
- psd: PsdFile::unreadable_layer_block() reports the kept block and its
parse error.
- io import: a warning names the block and the error and says only the
merged image was opened.
- io export: a preserved Lr16/Lr32/Layr block is not re-emitted when the
document's own layers are written.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop pads every layer-level tagged block to a multiple of 4 inside the length field; all 567 Photoshop-authored fixtures in the corpora do. The exporter padded regenerated blocks to even only, so a Pattern Fill's PtFl came out 2 bytes short of the boundary for half of all pattern names and left the layer record's extra data unaligned.
The pad is applied where the fill blocks are regenerated, not in the generic loop: preserved blocks must stay byte-identical (psd_structure, roundtrip) and shmd is length-structured (comps_artboards).
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* EXR parts: import parts, channel groups and an AOV precomp as layers
file.exrParts lists what an EXR holds for a chooser - flat parts, prefixed
channel groups (diffuse.R/G/B), Cryptomatte layers as information;
file.openExrParts imports the chosen ones as the layers of one document
(F16 lossless as F32, linear sRGB tagged), with an optional additive
beauty precomp summed from the light-path AOV set (direct/indirect
diffuse+specular, emission; transmission/sss/volume when present) that
fails naming the missing AOVs. The codec gains decode_exr_channels for
exact named channels of one part; io shares the image_surface band writer.
File › Open EXR Parts… picks an EXR and opens a plain command form: a
checkbox per part and channel group (labels are the data's names with
size and channel count), a both/left/right view dropdown, and the
precomp checkbox. Cryptomatte layers ride along as non-rendered data for
the control channel. Form fields gain __labels for verbatim data-driven
labels; the new strings are catalogued in all sixteen complete languages.
* Regenerate parity and scorecard docs
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Make large pixel operations allocation-fallible
* Warp: drop the fixed 400 MP cap; fallible reservations already cover large transforms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Adapt #2530 to main: fallible steep-Distort warp, Result from mask paste in float moves; regenerate scorecard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Opening a 32-bit Targa turned its alpha into layer transparency: the
colours of transparent pixels were hidden, and since #2157 only an alpha
channel becomes a Targa's alpha on save, a TGA opened and saved again
lost its alpha.
Measured on Photoshop 27.11 (scripted, files built byte by byte): a
32-bit Targa opens as an opaque Background plus an "Alpha 1" channel
holding the fourth byte, also when the descriptor declares 0 alpha bits;
the stored colours stay; a 24-bit Targa has no channel. PhotoCraft now
opens Targas the same way, so they round-trip with #2157's export.
Image sequences (video layers) still treat a TGA frame's alpha as
transparency, as footage does: load_frames moves the channel back.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* OpenEXR/HDR export: linearize grayscale documents too
Fixes#2386. The EXR/HDR branch of export_flat converted RGB images to
linear sRGB but left gray ones as stored, so an sGray #808080 was written
as 0.502 instead of 0.216. Gray images are now converted from their
profile (sGray when untagged) to linear gray. An untagged grayscale
OpenEXR opens tagged linear gray (an EXR Y channel is linear luminance),
and the open policy keeps that profile without a mismatch prompt, as it
already does for linear sRGB.
* contributors: add itsF4LCON
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(compose): Preserve native CMYK fills through PSD round trips
Share depth-quantized ink samples between rendering and PSD caches.
Use the document profile for RGB fill colours and preserve valid imported
caches. Uncached CMYK fills use CPU rendering.
Cover all sample depths, gradient styles and tiled renders. Fix the real
16-bit gradient round trip and raise the psd-tools floor from 307 to 308.
* perf(raster): Reuse exact 8-bit CMYK conversions within rows
Cache the previous ink bytes and ICC result per row, with alpha decoded
independently. Keep the 16/32-bit conversion loops unchanged.
Matched 1 MP reads improve by 77% for smooth ramps, 33% with dithering
and 10% for random colours. Bitwise regression coverage includes all
depths, alpha and tile boundaries.
Open and save .ora with pixel layers (any offset), nested groups, pass-through
groups, names, visibility, opacity, edit/alpha locks and blend modes (svg:* plus
Krita's krita:* names), at 8 and 16 bit. Save applies pixel masks, renders
text/shape/fill/smart layers to pixels and reports what OpenRaster can't hold.
Wired into File > Open/Save, the CLI, automation/MCP, the web picker and the
desktop file associations. Checked against 34 Krita 5.2.9-authored files
(opt-in krita_corpus test) and Krita re-opening our exports.
Co-authored-by: Scott Campbell <251750033+scottcampbelldata@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Fix SVG import detection and configurable group depth
* Fix malformed SVG error routing and formatting
* Refresh scorecard for SVG depth preference
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Flat exports ignored the document's alpha channels, so a Targa saved
from a document with an "Alpha 1" channel had no alpha. Photoshop 25.1
(checked via scripting) writes a 32-bit Targa's alpha from the
document's alpha channel when there is exactly one (spot channels
skipped), composites layer transparency over white, writes an opaque
alpha when there are several channels, and never adds alpha to
grayscale Targas. TGA export now does the same; 16-bit documents are
written as 8-bit Targa with the channel's values.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Squashed onto main, with the maintainer's asks applied:
- Photoshop's blend menus (layers, brush, pencil, gradient, layer style) stay as they are;
the Layers panel and Blending Options list a Paint.NET mode only for a layer that uses one.
- The six Paint.NET mode labels are translated in every catalog, and the blend-mode i18n test
iterates layer_modes().
- pdn::pixels rejects truncated uncompressed (version 1) data before allocating its buffer.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Brushes: keep the folder hierarchy of imported .abr files (#1851)
Photoshop CC brush files keep their Brushes panel folders in a `phry`
(preset hierarchy) section; the importer skipped it, so every preset of an
.abr landed in one flat group named after the file.
- photocraft-psd: `abr::parse` reads `phry` (a token stream of `Grup`
{Nm, zuid}, `preset` and `groupEnd` objects) into `AbrFile::folders`, one
folder path per preset. A damaged hierarchy never fails the parse: a stray
groupEnd, more or fewer preset tokens than presets, folders nested deeper
than MAX_FOLDER_DEPTH (32) or an unreadable section become warnings and the
affected presets land at the top level. `write_v6_folders` writes `phry`
back (`write_v6` is unchanged).
- photocraft-paint: `BrushPreset::folder`, the nested folder path inside its
group (serde default, so older JSON loads unchanged).
- photocraft-io: the file's folders nest inside the group named after the
file, as Photoshop's Import Brushes does.
- Preset store: each stored preset records its `folder`; group files written
before this change load with every preset directly in its group (no
rewrite). Paths from disk and from imported .pcpresets files are bounded.
- Commands: brush.presets.move, moveGroup, renameGroup and deleteGroup take
an optional `folder` path to move a preset into a folder, or to move a
folder among its siblings, rename or delete it. importAbr reports the
number of folders kept.
- Brushes panel and Brush Preset picker: nested folders are drawn indented
under their group (list and grid), open and close like groups, take
dropped presets, reorder among their siblings by drag and drop, and have
Rename/Delete in their context menu.
Checked against the 4-folder ABR Photoshop 25.1 exports of its default
brushes (8/6/6/8 presets): all 28 land in their folders, and a write/parse
round trip keeps them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Brushes: bound phry folder allocation; reword the move folder param
Review on #2001:
- read_phry now runs after every section is read, so it builds folder
paths only for the presets in desc (a phry may precede desc), and the
paths it holds are capped at 16 MB; presets past the cap import at the
top level with a warning.
- brush.presets.move documents `folder` as folder names, so the
automation filesystem-path classifier no longer flags it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The app still showed the Adobe Photoshop name in several user-visible
strings. Per the product rule (user-facing text says PhotoCraft only),
none of these mention Photoshop now.
UI labels, tooltips and status messages:
- New Document preset "Default Photoshop Size" -> "Default PhotoCraft Size"
- "All shortcuts reset to Photoshop defaults." -> "All shortcuts reset to
PhotoCraft defaults."
- "Import Photoshop brushes (.abr)" -> "Import brushes (.abr)"
- smart-filter fallback name "Photoshop filter" -> "Smart filter"
- File > Save As .psd/.psb filter "Photoshop" -> "PSD Document"
Errors and warnings:
- "this Photoshop filter isn't implemented in PhotoCraft" -> "this smart
filter isn't implemented in PhotoCraft"
- smart_map: "has no PSD equivalent", "not a recognized filter", "its
filter data is missing/unreadable", "this filter has no PSD equivalent"
- tiff_layers: "the PSD layer data...", "the PSD image resources..."
- multichannel: "32-bit Multichannel isn't supported"
- abr: "not a readable brush file (.abr)"
- gradient import: "not a readable gradient file (.grd)"
Translations: updated the changed tl! sources in all 16 catalogs and
dropped the stale "Load Photoshop brushes (.abr)" entries.
Installer / OS metadata: de-branded the nfpm description, the PSB MIME
comment, the AppStream summary/description, the macOS UTType
descriptions, the Windows ProgId Description and the desktop launcher's
Keywords. The Photocraft.PhotoshopDocument ProgId and the
image/vnd.adobe.photoshop MIME/UTType stay: machine identifiers,
changing them hurts file fidelity and Open-with registration.
Command params docs (agent-facing): dropped Photoshop from the
type_cmds kernPair text, the layer_menu_cmds Advanced Blending defaults
and the mask_view_cmds backslash-key text. The "photoshopManages" wire
value stays. Comments, format identifiers and the "photoshop" theme
alias are out of scope and unchanged.
The macOS native-menu shortcut resolution messages no longer say "as in
Photoshop" (Hide/Minimize).
Since #1917 the exporter writes pattern fill pixels; a vector-masked pattern fill then reopened as a
shape layer covering the canvas, failing the corpus round-trip floor (adjustment-fillers.psd).
Pattern-filled plain shapes now import as pattern fill layers with their vector mask.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A flat image with transparency (alpha channel or tRNS) opens as a normal, unlocked Layer 0, as in
Photoshop; opaque images still open as the locked Background. A lone Layer 0 saves back to PNG natively.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adjustment layer kept verbatim as Unsupported rendered as nothing without telling anyone. Import
now warns, naming the layer, the adjustment and (for Color Lookup) the reason. Part of #1763.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PSD (and layered TIFF) exporter rendered fill layers with no patterns, so pattern fills were
written as transparent pixels; in CMYK and Lab those tiles survive and the reopened layer drew empty.
Pattern fills now render from the document's patterns. Round-trip test over 4 modes x 3 depths.
Fixes#1907.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PSD export regenerates the document Txt2 block with one text object per TextIndex carrying each run's auto-kern mode (manual, metrics, optical), which EngineData's AutoKerning can't distinguish, so Optical no longer reopens as Metrics (#1348). A kept object with unchanged text keeps the file's extras (glyph pen positions); duplicate or out-of-range TextIndex values are renumbered and capped, so a crafted file can't size the save. io corpus tests stay at their floors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pre-scan rejects element nesting past 512 levels before the XML parser recurses
(a 70 KB file of <g> aborted the process). .svgz is inflated by the importer with a
128 MB cap, so it can be scanned too and gzip bombs are refused; flate2 moves from a
dev-dependency to a dependency (already in the tree through usvg).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A camera JPEG (e.g. Nikon D3200) records its resolution only in EXIF IFD0, so it
opened at 72 ppi instead of 300, and saving wrote JFIF 72 next to the source's
EXIF 300.
Reading: a JPEG takes the first usable of APP13 Photoshop ResolutionInfo
(0x03ED), XMP tiff:X/YResolution, EXIF IFD0 X/YResolution + ResolutionUnit
(2 inch, also when absent; 3 cm), and only then the JFIF density. That is the
order Photoshop resolves them in; JFIF comes last because cameras write EXIF
only while many tools write a default JFIF 72. Other formats (WebP, HEIF, PNG
without pHYs, ...) fall back to XMP then EXIF when they have no resolution of
their own. All parsing is bounds-checked; malformed data reads as no
resolution.
Writing: JPEG, PNG, WebP, TIFF (XMP), PSD and Save for Web JPEG rewrite the
EXIF/XMP resolution to the image's: RATIONAL values in place, ResolutionUnit 2,
and an entry that can't be rewritten safely is removed from IFD0 without moving
any other data.
PNG tEXt/zTXt/iTXt entries (and TIFF ASCII tags) were dropped on open with a warning. The
document now keeps them in Metadata.text, the .pcraft manifest saves them (older files still
load), and PNG/TIFF saves write them back; Metadata: None and embed_metadata=false strip them,
and PSD/PSB and palette PNG exports warn that they are dropped. XMP in a tEXt or zTXt chunk is
read as XMP too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A TIFF export with --tiff-layers reported "EXIF not supported; it will be
dropped" even though the file kept the tags. export_layered puts EXIF into
the Photoshop image resources as 1058, which the TIFF writer emits as tag
34377 and the importer reads back, so reopening the file restored the
original tags. ExportResult.warnings is documented as notes about what an
export will lose, so the message was an incorrect loss report for an export
that loses nothing.
The TIFF codec has no EXIF directory, so the second copy handed to it in
meta.exif was never written; it only existed to make the codec warn. That
copy is gone, and with it the false warning. The flat-TIFF path still hands
the codec the tags, still drops them, and still says so.
The test is the issue's own claim checked in both directions: a layered
export reports no EXIF loss AND the Artist tag survives the round trip, so
the missing warning reflects a real absence of loss rather than a silenced
one; a flat export is the control and keeps warning. It fails on the exact
reported message before this change.
Conflicts: File › Open and batch extension lists (SVG, swatches and
Affinity together); the import dispatcher (upstream's type-layer
preparation with the new result fields); the pinned EXR and Affinity
corpora side by side; README, book and development docs.
Affinity type layers now draw through text_import::prepare, so they get
the same raster bound as other imported type, and SVG import fills the
two new ImportResult fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
Photoshop's clrL descriptor (lookupType, NM, Dthr, profile, LUTFormat,
dataOrder, tableOrder, LUT3DFileData, LUT3DFileName) has no interpolation
setting, so a tetrahedral Color Lookup layer reopens trilinear and renders
differently. PSD save now reports that in its warnings instead of changing
the composite silently. Trilinear lookups save without a warning.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Add layer and group color labels (#1283)
* i18n(pl): layer colour label rows for the Polish catalog now on main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1405)
ag-psd, GIMP and other writers leave a PSD type layer without image data and
let Photoshop re-render it on open. The importer made those empty pixels the
layer's cache, so the canvas showed nothing until the layer was edited, and
the family guessed from the PostScript name ("CC Wild Words" for
CCWildWords-Regular) never got corrected to the installed family
("CCWildWords"), so Missing Fonts replaced an installed font.
- crates/io/src/psd_import.rs: a type layer's record pixels are the cache only
when there are some.
- crates/io/src/text_import.rs (new, run by import): resolves each run's
PostScript name with FontDb::resolve_postscript, as the layout does, and
writes the installed family, weight and italic back into the run; renders
every type layer that has no cache from its model.
- crates/io/tests/psd_type_bounds.rs: a type layer exported without pixels
imports rendered, on the engine's own pixels; "JetBrainsMono-Regular" (guess
"Jet Brains Mono") imports as the family "JetBrains Mono".
Fixes#1405
* PSD import: cap the automatic type render for untrusted sizes
text_import::prepare now renders a type layer without pixels only when its
ink fits MAX_RENDER_PIXELS (64 Mpx); the renderer's own guard is 256 Mpx,
which for an absurd type size or scale in a hostile PSD meant several GB of
f32 accumulation buffers on open. Over the cap the layer keeps empty pixels
until edited, as before. Test: hostile_type_sizes_without_pixels_import_without_a_huge_render
(an ink between the two caps, plus 1e30 pt, a 1e12 scale and f64::MAX).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* rustfmt (module order, the hostile-size test)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PSD import: keep empty type pixels when the engine text is blank
cargo xtask test-corpus: rendering every pixel-less type layer regressed the
Photoshop oracle text/path-wave-open.psd (133 -> 132, below the floor).
Photoshop wrote that layer with no pixels because its EngineData text is
blank (the characters sit only in the descriptor's Txt ): it draws nothing,
and the import must not draw it either. New photocraft_text::psd::
engine_text_is_blank; the oracle passes again (133).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)
Blending Options > Advanced Blending now works end to end:
- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
interior effects as group, blend clipped layers as group, transparency
shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
model and written back (in place, or added when not at the defaults);
the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
(isolated groups and clipping groups stop a deep knockout), clipped
layers blended individually, effects over the whole layer when
Transparency Shapes is off, effects built from an unmasked copy when a
mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
Advanced Blending, Blend If with split sliders), translated.
psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md
pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard
#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Review: keep unknown blocks with their byte order, io behind the engine, fixtures documented
Addresses the review on #672:
* Unknown layer blocks read from a little-endian TIFF are no longer dropped. Their bytes are
kept verbatim with the signature `MIB8` (`46B8` for `8B64`) marking the little-endian
layout (`TaggedBlock::is_foreign_order`), so a save back to TIFF restores them exactly. A
malformed known block is kept the same way. Only a save to the other byte order has to drop
such a block, with a warning: a PSD export strips them (`strip_foreign_order_blocks`), and a
big-endian block of unknown layout still cannot go into a little-endian TIFF.
* The UI no longer depends on `photocraft-io`: the layer predicate is exposed by the engine
(`file_cmds::tiff_would_write_layers`); io is a dev-dependency again.
* The layered TIFF path ends in the same `encode_image` as every flat export, so export-wide
metadata policies (such as #733's XMP choice) apply to its composite too.
* Scorecard FILE-215-7 is `partial`: extra alpha channels and a Quick Mask are not written.
* The committed fixtures get an ATTRIBUTION.md row and a README describing their contents and
how they were generated (psdtags + tifffile); the Python generator script is removed.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* tiff_corpus: ask for layers explicitly (headless default is flat since #825)
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* i18n: drop the duplicate Korean "Ambient" entry that fails bundled_catalogs_are_consistent
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* i18n: drop the duplicate Korean "Warmth" entry as well
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* agent_tasks: tolerate Windows holding the test directory open during teardown
The three tests that remove their directory right after cancelling the client failed on Windows (CI and locally) with os error 32: the spawned server task is still closing its files. The cleanup now retries briefly and otherwise leaves the directory for the next run's tmp() to clear, as it already did at the start.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* preset store: a missing directory is an empty store on Windows too
DirBackend::read passed the OS message through, and Windows words a missing directory as "cannot find the path specified (os error 3)", which missing_file() did not recognise, so attaching a store on a path that does not exist yet (first launch, and two engine unit tests) reported actions.json as skipped. The backend now reports every NotFound as "<name>: not found", the wording the predicate and the memory backend already use. Regression test: opening a store on a nested missing directory warns about nothing and writes nothing.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* Review: keep scripts/layered_tiff_fixtures.py, drop the fixtures README and attribution rows
The four .tif fixtures those described were never committed (binary test data stays out of the repo; the tiff_layers tests read them from the gitignored corpus/layered-tiff/, written by the script that tiff_layers.rs:181 still points to).
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* automation: the Direct Selection path commands edit vector paths, not files
#865 added path.moveAnchors, path.moveHandle, path.bendSegment and path.convertPoint, whose registry docs mention the vector path they return, so registry_filesystem_path_params_are_classified fails on main (Linux and Windows): they join the list of commands whose "path" is a document path.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* i18n(cs): the lower-case "{n} layer|{n} layers" plural (main red since #829)
xtask's source_key_set_is_covered_by_complete_catalogs fails on main: the key reached every catalog but the Czech one. Same forms as the capitalised twin already in cs.tsv.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* Drop the duplicate path.* entries from the workspace test (main already lists them)
main already has path.moveAnchors/moveHandle/bendSegment/convertPoint in this match; the
duplicates trip unreachable_patterns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Color Lookup: keep the .cube input domain and reject the ones we cannot apply
DOMAIN_MIN/DOMAIN_MAX (and LUT_3D_INPUT_RANGE) were parsed and thrown
away, so a log-domain or extended-range look was applied to plain 0..1
inputs: silently wrong colours, exactly what the in-code comment
admitted. The domain now travels with the parsed LutFile (validated:
every component finite and min < max), and colorLookup refuses a
non-default domain with the reason instead of shifting the look -
the same unsupported-over-wrong-output call the codecs make. Honoring
a custom domain end to end means carrying it through ColorLookup
(doc, compose, gpu, PSD round trip) and stays open as the follow-up.
* tests: the colorLookup domain rejection needs a pixel layer active
The outer session of the test still had the adjustment layer active, so
the command was disabled before reaching the domain rejection - the
assertion only passed when the active layer happened to differ. Make it
deterministic with a fresh pixel layer.
* Color Lookup: bake a 1D .cube's input domain into the resample
The 1D branch of parse_cube still ignored DOMAIN_MIN/MAX and
LUT_1D_INPUT_RANGE. Since the 1D table is resampled onto a 0..1 cube anyway,
the domain is now applied there (saturating outside), and a degenerate
domain is rejected like the 3D one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* cargo fmt after rebasing onto main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Brush tests: seams, hard edge, zero scatter and ABR tip colour (#213)
Adds the four TOOL-213-13 regressions: minimum coverage inside a stroke
(spacing 0.1/0.25/0.5), a hardness-1 rim measured against a soft falloff,
dabs staying on the path at zero scatter, and an imported ABR tip painting
the brush colour instead of black. Scorecard row flipped to done.
* Contributors: add Reynold Hu to people.toml
Real name credited, display name synced from the public GitHub profile at the
next contributors.py refresh (every field is opt-in).
* Regenerate docs/scorecard.md after rebase onto main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Open camera raws in Camera Raw first (Open / Cancel)
Photoshop opens a raw file in Adobe Camera Raw before it becomes a
document; PhotoCraft developed it with the defaults straight away and
said so in a warning. An interactive open (File › Open, Open Recent,
drag and drop, the command line) of a raw developed from its sensor data
now shows the Camera Raw dialog first, titled "Camera Raw (name)", with
Open and Cancel.
- Open re-develops the raw when Temperature, Tint or Exposure changed:
as-shot white-balance multipliers times the filter's gains, and the
develop exposure (photocraft_io::raw::import_raw_tuned). The other
settings are one filter.cameraRaw step; the document starts
unmodified. Without an as-shot white balance or without the file's
bytes, Temperature / Tint stay RGB adjustments as in the filter.
- Cancel closes the document again.
- The "developed with default settings" note is no longer shown when
the dialog is.
- Preference rawDefaults.openInCameraRaw (default on), the first
visible Camera Raw Defaults setting. Automation opens (app.open, MCP,
CLI) never show the dialog.
- "NIKON CORPORATION" + "NIKON D3200" now reads "NIKON D3200" in the
import note.
Tests: open → set → Open re-develops (brighter, still 16-bit, one
document, unmodified); Cancel closes; unchanged settings add no step;
preference off skips the dialog; a closed document is an error.
Translations for the two new strings in every catalog.
* Camera Raw open: re-develop in the background, keep tab and view
Open re-read and re-developed the whole raw on the UI thread. In the desktop
app it now runs as a job (cameraRaw.redevelop, document locked) like the
open itself; the reply is {job, pending} and the shell finishes when it
lands. The final Camera Raw step goes through app.run (a job too) and the
document is marked unmodified once it lands. Tests and the web still run
inline.
The re-developed document replaces the old one at the same tab position
and path, and its view (zoom, scroll) carries over. New test covers the
background path with documents on both sides.
wb_gains now uses camera_raw::white_balance_gains, shared with the Camera
Raw filter and documented as an observed approximation. 'Developing {name}'
translated in every catalog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* CMYK -> sRGB flat export: row bands through the colour transform
cmyk_image_to_srgb materialized three full-size f32 buffers (normalized
input, transform working set, sRGB output) - ~1.4 GB of allocation
churn on a 36 MP export. convert_f32 walks plain strided slices, so the
image now passes through in bands of rows (~32 MB budget): peak
transient memory drops by the document size times two, the output vector
is the only full-size allocation left. Byte-identical output: the
colour test pins the converted pixels and the tagged sRGB profile.
* Banded CMYK export: guard empty images, bounds-checked slices
(BAND_BYTES / (row * ss * 4)) divided by zero for a zero-width image and
clamp(1, 0) panicked for a zero-height one. Guard both, take each band with
checked arithmetic and data().get(), and reuse the output band buffer.
Tests: empty CMYK images convert without panicking; a banded conversion
covers every row.
Note: this PR's single change is the banded CMYK -> sRGB flat export; the
title's parallel PSD layer export and shared proxy cache are not part of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215)
crates/io/src/svg.rs: usvg parses (already in the tree through egui_extras); paths become
ShapeLayer knots with the transform chain baked in, quadratics raised to cubics and every
subpath after the first joining the component so the fill rule spans the path as SVG does;
groups keep opacity and blend mode (transform-only and id-only wrapper groups vanish);
text is shaped into outline shapes with the craft fonts plus the system's; gradients become
the layer's gradient fill (stops and angle, with a warning). Clip paths, masks, filters,
pattern paints and images are rasterised by resvg into pixel layers over their bounding box
rather than dropped. Limits: 16384 px canvas, 2000 elements, 24 M px per raster, bounded
recursion; malformed input is IoError::Svg.
smart_cmds::render rasterises an SVG source at the placement's axis scale (1/16 steps,
cached per scale) and divides the placement by it, affine and projective alike; place_bytes
renders that cache up front. svg/svgz join the openable lists; book page; scorecard row done.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* SVG import: never read local files from <image href>
usvg's default image resolver reads any absolute or relative path in an
<image href>, so opening an untrusted SVG could embed local files
(/etc/..., ~/.ssh/...) into the document. Parse with a resolver that
only accepts data: URLs (resources_dir None, string hrefs ignored).
Embedded sub-SVGs already get no <image> resolution from usvg. The font
database (and its system-font scan) is now only loaded when the drawing
may contain text. Regression test: a PNG on disk referenced by absolute
path, file:// URL and relative path is not loaded; a data: URL still is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* PSD plane shuffles in bands; the document histogram computes off the UI thread
interleave/deinterleave walked every pixel of a 20 MP image on one
thread during PSD open and save. Both now split the rows into one band
per core (the file's own par_map/bands helpers) and merge the band
results in order: 20 MP RGB u16 interleave 152 -> 101 ms, deinterleave
461 -> 160 ms (2.9x); f32 rgba 329/730 -> 220/396 ms. Behaviour is
pinned by a parity test against the sequential reference at odd pixel
counts (band boundaries), all depths, with a missing plane in the mix.
The Histogram panel computed its payload inline in the frame every
250 ms while stale - a 41.5 ms UI-thread stall measured on a 24 MP
document, on top of brush work. compute_histograms now runs on a
worker thread; the panel keeps showing the previous histogram with the
existing cached-data warning until the result lands, and a stale
result cannot overwrite a newer revision. Pinned by a panel test that
drives one egui frame and asserts the job is scheduled, delivers off
thread and respects revisions.
* Histogram panel: compute inline when no thread can be spawned; drop the benchmark test
std::thread spawn fails on wasm32 (and can fail natively), which left
hist_job waiting on a disconnected receiver forever. Without a worker the
histogram is now computed inline and delivered through the same channel, a
disconnected job is cleared, and the perf span is measured in the worker.
Removes the dead t0 and the wall-clock benchmark test; adds a dead-worker test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>