* feat(engine): accept Photoshop units for guide positions
view.newGuide and view.moveGuide take the position as before (a number
of pixels) or as text with a unit, as Photoshop's New Guide dialog does:
"50%", "2.5 cm", "1in", "12pt", "3pc", "100px". Percent is of the
document's width for a vertical guide and its height for a horizontal
one; points and picas follow Units & Rulers › Point Size. Fractional
positions are kept. Text that isn't a length, NaN and positions beyond
1,000,000 px are refused without recording a history step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(engine): guides can have their own colour
Photoshop's New Guide sets a colour per guide; PhotoCraft guides only had
the Preferences guide colour. Guides now keep optional colours, keyed by
orientation and position so code that drops or reorders guide positions
(crop, artboards, guide layouts) can never move a colour onto another
guide: an unmatched guide just uses the preference colour.
view.newGuide takes an optional "color": a name from New Guide's list
(lightBlue, lightRed, green, mediumBlue, yellow, magenta, cyan, lightGray,
black) or "#rrggbb". Without one the guide uses the preference colour, as
guides dragged from the rulers always have. Moving a guide carries its
colour; deleting and clearing drop it (Clear Guides, Clear Canvas Guides,
Clear Selected Artboard Guides and New Guide Layout's Clear Existing
Guides alike); undo restores it. Guides are drawn in their own colour
(while New Guide Layout previews, the rest show the colour it will set), and .pcraft files keep it (omitted when no guide has
one, so older readers and files are unaffected). PSD export keeps guide
positions only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ui): Photoshop's New Guide dialog: units and colour
View › Guides › New Guide… was the generic schema dialog, whose position
field was whole pixels only and had no colour. It now has its own
dialog, as in Photoshop:
- Position opens in the ruler unit ("0 cm") and takes any unit
Photoshop's field takes ("50%", "2.5 cm", "12 pt"); a bare number is
in the ruler unit. The last guide reopens in whatever the ruler unit
is then ("5.08 cm").
- Color lists Custom…, Light Blue, Light Red, Green, Medium Blue,
Yellow, Magenta, Cyan (the default), Light Gray and Black. Custom…
shows a swatch that opens the Color Picker. The guide stores the
chosen colour itself, so a later change of the preference guide
colour leaves it alone.
- A position the engine refuses reopens the dialog with what was typed
and the engine's message, as Photoshop keeps New Guide open.
Light Blue, Light Red, Medium Blue and Light Gray are new to the
catalogues; they were translated for all 16 languages from the existing
colour terms in each and would benefit from native review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(engine): one unit-suffix table for typed lengths
New Guide's position field (Unit::parse_length) and the Crop tool's size
fields (crop_size::unit_of) each kept the same table of unit suffixes.
Unit::from_suffix is now the one table: px, in or ", cm, mm, pt, pc or
pica, % and their long forms, in any case. The crop fields keep their own
number parsing (they accept arithmetic) and still refuse percent; they now
also accept "pc" for picas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ui): New Guide and New Guide Layout share one colour list
New Guide Layout (#2930) offered five colours of its own; New Guide offers
Photoshop's nine. Both dialogs now list the same nine (GUIDE_COLORS), in
Photoshop's order and with the same translated names. Cyan (#4affff, the
default guide colour), Magenta (#ff00ff) and Yellow (#ffcc00) take New
Guide Layout's values; Light Blue, Light Red, Green, Medium Blue, Light
Gray and Black keep New Guide's. New Guide Layout's Blue (#378ef0) and
jade Green (#00a86b) give way to Photoshop's Light Blue and Green; a colour
outside the list still shows as Custom.
GUIDE_COLORS drops its unused label column (the dialogs translate the
names), and New Guide Layout reads "#rrggbb" with prefs::parse_hex instead
of its own copy (rulers::parse_guide_color).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
fit_canvas only grew the canvas to the boards' right/bottom edges, so a
board moved left of or above the origin (Move tool drag, layer.translate,
layer.artboard.set with negative x/y) stayed partly off-canvas. It now
shifts the whole document (boards, contents, guides, paths...) right/down
by the overhang via image_cmds::translate_doc and grows the canvas, inside
the move's undo step, like Photoshop's artboard documents.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Tools: Artboard tool (#2858)
Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033)
With the Artboard tool, the selected board shows a "+" button beside each
side. A click adds an empty board of the same size (background and preset
copied) on that side, the 100 px artboard gap away and past any board in
the way; ⌥-click adds a copy with its contents instead. A board left of or
above the canvas origin shifts the whole document right/down and grows the
canvas, so it stays visible. Both go through the new engine command
`layer.artboard.addBeside` {layer?, side, duplicate} (one undo step).
⇧ while dragging a corner handle keeps the board's aspect ratio: the
opposite corner stays and the board scales by the larger axis factor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* File Handling: wire Image Previews to embed a PSD thumbnail (#204)
Preferences > File Handling > Image Previews is live for PSD/PSB saves. With
Always Save (the default) and Ask When Saving, the file carries Photoshop's
thumbnail image resource (1036: the 28-byte kJpegRGB header and a JFIF stream,
at most 160 px on the long side); Never Save writes none. The thumbnail is the
reduced composite converted to sRGB through the document's profile with
photocraft-cms (8/16/32-bit; Gray, CMYK and Lab become RGB), over white where
transparent. Ask When Saving has no Save As checkbox yet, so it saves the
preview, as Photoshop's checkbox starts on.
The preference reaches Save, Save As, Save a Copy and automation saves in the
app (ExportSettings.image_previews), and the engine's headless saves
(file.saveACopy, batch, Layers/Comps/Artboards to Files) through SaveOpts.
photocraft_io::ExportOptions gains image_previews (default on).
Settings that do nothing: 32 of 153 (#204).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* File Handling: rustfmt the Image Previews save path
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
Shape layers render their pixels clipped to the canvas. Moving an
artboard (layer.artboard.set or layer.translate) re-rendered its shapes
before fit_canvas grew the document, so a shape moved past the old edge
came out missing or cut off until another shape edit refreshed it.
fit_canvas now re-renders every shape whenever it changes the canvas
size, which covers all its callers.
Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Layer > Duplicate on an artboard stacked the copy exactly on the original, so
nothing visible happened. The copy now goes right of the board (100 px gap,
sliding past boards in the way) with its contents, and the canvas grows to
show it, in one undo step (single and multi-layer duplicate). `inPlace: true`
keeps the old behaviour; the Move tool's Alt-drag and Alt+Cmd+T use it.
- Move tool Auto-Select ignored artboards: a press on an empty spot of a board
picked the Background under it, so the drag never moved the board. A click
there now picks the board (layers on it first, clipped to the board; Group
mode stops inside the board), and the drag moves the board with its contents.
- Moving a board past the right/bottom edge grows the canvas, as a new board
does, instead of cutting it off.
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
Co-authored-by: Claude <noreply@anthropic.com>
Default placement added the 100 px gap to the rightmost board's right edge in i32,
editing a board subtracted its old position from the new one in i32, and exporting
negated the board's origin, so a board at an i32 extreme overflowed (a panic in checked
builds, a wrapped position or content offset in release). Rects were also built with
Rect::from_xywh, which clamps the far edge and silently shrank boards past i32::MAX.
Board rects are now built in i64 and checked, so a rect with any edge outside i32 is a
bad-params error naming it; default placement with no room on the right asks for an
explicit x; a move whose offset needs more than 32 bits is rejected unless
moveContents is false; and export reports a board at i32::MIN instead of negating it.
All checks run before the document changes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Bound layer-group nesting at import and Group Layers (stack-overflow guard)
Everything that walks the layer tree (Document::walk behind nearly every
command, the layers panel, PSD export, .pcraft save/load) recurses once per
level, but nothing bounded how deep a document could get: a PSD whose records
nest groups a few thousand deep (each level costs only a divider + folder
record pair) overflowed the stack in the importer's build recursion, in
apply_link_groups, and again in every consumer - an abort no panic hook or
fuzz run catches. The one cap that existed lived in the .pcraft save path
(MAX_GROUP_DEPTH = 100), so such a file even imported 'fine' and then bricked
saving.
- photocraft-doc now owns the limit (MAX_GROUP_DEPTH = 100, the value the
format crate already enforced) plus Document::max_group_depth(), an
iterative depth measure that cannot itself overflow.
- PSD/PSB import rejects files nested deeper than the cap with an actionable
Unsupported error, mirroring what the .pcraft loader already does; the
never-fail psd_to_document keeps working on such files by capping the
recursion and warning about the unimported remainder (this was the overflow).
- apply_link_groups (resource 1026) recurses over the same tree and is
bounded the same way.
- layer.groupLayers refuses to nest past the cap (checked inside the edit, so
a rejected call records no history step and leaves the document untouched).
* Enforce the group nesting cap in layer.moveTo and Artboard from Layers
Moving a group beside or into a deeply nested layer, or wrapping a deep
group in an artboard, could still build a tree deeper than MAX_GROUP_DEPTH.
A shared check_group_depth helper now rejects those edits with an error and
records no history step, like layer.groupLayers.
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>
`commands::int` returns an unbounded i64, and the geometry call sites narrowed
it with `as i32`, which truncates by multiples of 2^32: layer.translate moved
a layer 50 px for `dx = 2^32 + 50` and to a large negative offset for
`dx = 3e9`; select.rect and document.pixel read unrelated coordinates;
image.crop cropped a rectangle unrelated to the numbers passed; artboard
rects landed elsewhere. No panic, no Err - the document was silently
mutated and then autosaved, and the behaviour is identical in debug and
release, so panic_hunt cannot see it either.
- new `commands::int_i32(cmd, p, key)` narrows with a bad-params error
instead of a wrapping cast; select.rect, document.pixel, layer.translate,
image.crop and the artboard rect keys go through it
- image.crop builds its rectangle with saturating_add (x + w overflow)
- edit.contentAwareFill's `area` parser returns a bad-params error for
malformed arrays (wrong length, non-numbers, non-finite) and saturating
arithmetic for huge ones - `[1e30, 0, 1e30, 10]` overflowed the i32
additions before (a debug-build panic, a garbage sampling window in
release)
- filters' integer helper clamps out-of-range values like the neighbouring
filter params instead of wrapping (`3e9` came back as a negative offset)
Maintainer decision on #672: the app's Save As keeps TIFF layers as Photoshop does (it sets
ExportOptions.tiff_layers from its Layers option), but scripted and agent saves stay flat by
default so conversions keep predictable sizes. #672 merged with the default on, so every
headless TIFF save became layered.
- photocraft-io: ExportOptions::default() has tiff_layers: false. The desktop and web Save As
already set it explicitly from their settings, so the app is unchanged.
- photocraft-cli: --tiff-layers on convert, run and batch (listed in the usage text).
- engine: file.saveACopy, Batch, Image Processor, the export-to-files commands and the lens
batch take "tiffLayers": bool=false, through one SaveOpts { quality, tiff_layers }.
- automation: serve doc.save and MCP doc_save take tiffLayers (default false).
- Tests: default ExportOptions write a flat TIFF; the CLI writes flat without the flag and
layered with it (run and convert); #672's layered-TIFF tests ask for layers explicitly.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Every unwrap/expect/unreachable in non-test engine code now returns an
EngineError (or a predicate's Err string) or uses a real fallback:
- enabled predicates read the active layer through active_layer_of()
instead of unwrap() after has_layer();
- new raster layers get their pixels through pixels_mut()? instead of
surface_mut().expect("raster");
- layer paths use split_last().ok_or(..)? (Merge Down, Arrange, Ungroup,
Release Clipping), Auto-Align/Auto-Blend/Photomerge carry placements
instead of re-unwrapping them, and "next free name" searches are bounded;
- web export builds its options map directly, quantize_rows has a wasm
tail, parse_script rejects non-array/object JSON, the shape-layer edit
returns an error instead of unreachable!, and the gray composite
profile falls back to sRGB.
The engine denies unwrap/expect/panic/unimplemented/todo/unreachable.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>