Follow-up to #991: Rust 1.99's clippy flags chunks_exact with a constant
size, which turns the workspace clippy gate red.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
timeline.create and timeline.setProps kept any `duration` (up to u64::MAX) and
any `fps`, and timeline.nextFrame/previousFrame then moved the playhead with
`(current as i64 + delta).clamp(0, duration as i64 - 1)`, which panics when the
upper bound goes negative (duration 0 or above i64::MAX) or overflows.
Both commands now reject a `duration` outside 1..=1000000 and an `fps` that
isn't finite and in (0, 1000] with BadParams before anything changes. Stepping
moves to a saturating Timeline::step, safe on any stored timeline (e.g. one
loaded from a file with duration 0), and a non-finite stored fps falls back to
30 like a non-positive one.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The bar height was clamped to [2, h/4], and f64::clamp panics when h/4 < 2,
so any image shorter than 8 px panicked inside the command's run closure.
Reject such images with a bad-params error before anything changes, like the
existing bar-width check. Also keep the bar on the canvas when the label under
it, or the left margin, would push it off a small image.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
crop_doc negated the crop origin unchecked, so x or y = i32::MIN overflowed;
an origin one past i32::MIN pushed layer pixels past i32::MAX and panicked on
a length mismatch while translating surfaces; and x + width past i32::MAX
saturated, silently cropping less than asked. crop now checks the far edge,
crop_doc checks the negation, and translate_doc checks that every surface
still fits before moving anything; all return BadParams naming the values.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Surface::fill_rect materialized every tile it touched, so a new document's
background cost one 256x256 tile per tile slot: ~360 GB for the 300000x300000
size the command accepts. Fully covered tiles now share one Arc'd tile, so a
solid fill costs one tile plus the tile index; edge tiles are written as
before, and copy-on-write splits a shared tile off when it is edited.
Duplicating a layer (Layer › Duplicate, ⌥-drag with the Move tool, Layer
via Copy without a selection, New Smart Object via Copy, the Edit menu's
duplicate, Select and Mask's new layer) names the copy with
`Document::copy_name`: "Layer 1 copy" first, then "Layer 1 copy 2",
"Layer 1 copy 3"…, unique in the document. A name already ending in
"copy" or "copy N" is numbered from its root instead of growing another
"copy"; names that merely contain the word keep it.
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the clipboard holds an image (pixels copied in the app, or an image
copied in another app), File › New offers a Clipboard preset first under
the Recent blank-document presets, at that width and height and 72 ppi,
and selects it, so Create makes a document the size of the clipboard.
Without an image the dialog opens as before. Opening the dialog is an
explicit request, so the OS clipboard is read then, as for a paste.
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
record_surface sized the interleave buffer from the layer rectangle alone,
so a few-hundred-byte file declaring a 300000x300000 layer with no
channels made import allocate ~360 GB and abort. The channel decoders
already bound their output by the bytes present, so a buffer is now built
only when at least one channel decodes; otherwise the layer imports empty
with a warning.
- Color Lookup: a stored document's `size` is untrusted. `size³ × 3` overflowed for sizes of
2²² and up: debug builds panicked, release builds wrapped past the table-length guard and
indexed out of bounds. One checked `photocraft_doc::adjust::lut3d_len` now backs the CPU
compositor, the GPU planner and the PSD writer.
- Quick Selection: a stroke point far off the canvas (`select.quick {"points":[[3e9,0]]}`)
saturated to i32::MAX and then overflowed on `+ 1`.
- Link Layers: `max + 1` over stored link group ids overflowed at u64::MAX (debug panic, release
wrap that could merge unrelated layers). A fresh id is now the smallest unused one when the
largest is taken; a selection's first group is read with `first()`.
- Vanishing Point clone footprint: the same `as i32 + 1` saturates (hardening; no reproducer).
Per maintainer review (#833): keep Feather as Difusão…, Diffuse becomes
Difundir…, and Liquify becomes Liquefazer… (Licuar is Spanish).
Separates the Fast menu and Stylize menu entries that were colliding in pt-BR.
The contributor docs offer github.com/storytold/craftrules as the place
to read the font rules and release playbook, but the repository
returns 404 to everyone outside the organization, so the cited text is
unreachable for outside contributors. Keep the sibling-checkout paths
and say plainly that the repo is not public and a maintainer can supply
the rules relevant to a change.
Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Hovering a mode in the Layers panel's blend-mode list previews it on the
selected layer; moving off the list reverts, and only a click applies it
(one history step), as in Photoshop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The margin Modify pads a canvas-edge selection with was twice the radius
for every operation, so Feather 1000 added 2002 px a side. It now pads by
each operation's reach (Feather 1.5 r, the others r) and refuses a padded
mask over 2^30 pixels instead of aborting on the allocation. The new
dialog option is translated in every shipped language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
Contract, Smooth, Expand and Feather gain Photoshop's "Apply effect at canvas bounds" option
(applyAtCanvasBounds, default off). On, the canvas edge counts as a selection edge, so Select All
then Contract shrinks from the edges and Feather softens them; off, the selection continues past
the canvas, as in Photoshop. Border always follows the canvas edge, so a Select All borders to a
band inside it instead of an empty selection.
The mask is padded (empty or edge-replicated) only when the selection touches the canvas edge,
so selections clear of it cost nothing extra.
1000000 % on any layer could ask the transform for an image millions of
pixels wide, more than can be allocated. The W and H fields now reach as
far as the box stays within 300000 px (the document limit), at most
1000000 %, and a linked change keeps the other side within its limit too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
The options bar clamped W/H to ±10000% and X/Y to ±30000 px, while the engine takes any scale
and documents go up to 300000 px a side. Overlays on large stitched mosaics need more. The limits
are now named constants: ±1000000% (a 300 px layer can span the largest document) and ±300000 px.
File › Open, the web build, batch folders and the CLI now pick up
.afdesign, .afphoto and .afpub, which the native reader handles; the MCP
test checks the preview fallback's new warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
The Photopea screenshots, their ATTRIBUTION row and the example that made
them documented the bug for review; the unit tests in crates/text cover
the behaviour, so they are not needed in the tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
Dragging one live-gradient handle towards the other clamps the fill's scale at 10%. For Linear the
centre was still taken from the midpoint of the requested points, so the
clamped gradient was re-centred and the handle that wasn't being dragged slid along the line.
gradient.fill.set now shifts the clamped layout so the handle not given keeps its place. The same
apply_set drives the drag preview, so the canvas matches.
The Filter Gallery, Camera Raw and Adaptive Wide Angle dialogs record the layer they ran on in
the command's params. Last Filter replayed the journal entry verbatim, so it filtered that layer
again (possibly hidden, or deleted) and appeared to do nothing on the layer you were looking at.
It now drops the recorded `layer`, so it applies to the current layer as in Photoshop.
Distort and Perspective move a corner along the drag only as far as the
box stays convex, stopping just short of flat, as Photoshop and Photopea
do, instead of folding it into a bow tie.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pro themes already had Photoshop's Auto-Select checkbox; the Studio
themes showed only a hint for the Move tool, so Auto-Select could not be
turned off there. Both now have the toggle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Right-click with a selection tool and choose Delete and Fill Selection
to fill the selection from its surroundings with Content-Aware Fill's
default settings, without the dialog. Engine command
edit.deleteAndFillSelection, one undo step, translated in every shipped
language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
photocraft-affinity now reads native Affinity documents (container
versions 8-12): the FAT and revision chain, stored/zlib/zstd entries
with predictors and CRC checks, the tagged object stream in doc.dat,
and the document model (pages, artboards, layers, groups, curves,
geometric and compound shapes, fills, gradients, strokes, text, placed
images, pixel layers, masks and embedded-document caches). Every size
is bounded and malformed input returns an error.
photocraft-io maps it to an RGB8 document with artboards, groups, shape
layers, type layers, embedded smart objects, pixel layers and
vector/layer masks; what can't be mapped is listed in the warnings. The
embedded preview is now only a fallback when the native data can't be
read, and only that preview is refused by Place and warningless imports.
`cargo xtask corpus --affinity` pins 21 public CC0/MIT documents,
sha256-verified; the io corpus test compares each import with the
thumbnail Affinity saved in it (mean difference 0-4.6 of 255).
Affinity export is not implemented: no Affinity installation was
available to validate written files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
* Allow removing last polygonal lasso vertex without editing layers
* Route lasso Backspace ahead of destructive edit shortcuts
* Use right-click to retract polygonal lasso points instead of opening menu
* Regress lasso vertex removal via keyboard and actual canvas right click
* Apply rustfmt layout reported by Linux CI
* Apply rustfmt layout reported by Linux CI
* Remember last applied New Guide Layout parameters in UI state
* Save guide layout preferences only after successful application
* Cover successful, cancelled and failed guide layout dialog state
* Avoid overlapping borrows when reopening guide layout dialog
The document parameter's set_active result was discarded, so an
out-of-range index still switched nothing, changed nothing, and the
window opened for whatever document happened to be active - a caller
was told success for a document that does not exist. Now the call
errors with the index and leaves the active document alone.
real_before_parameters picked Photoshop's order (real mask first) whenever
the record length fit the parameter-flags byte at offset 36, even when that
byte was just part of the real rectangle. A density-only record in the
spec's order then read its density as the real mask's background and lost
the parameter. When both orders fit, Photoshop's reading now has to be
plausible (background 0 or 255, known parameter flag bits) or the spec's
reading with a real mask wins.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
apply_to_doc replaced a text-replacement variable's text and set the layer's
render cache to None, but nothing rebuilt it. The compositor draws a type
layer from that cache, so the text vanished from the canvas after
image.applyDataSet and from every file written by
file.export.dataSetsAsFiles. The layer is now re-rendered with
type_cmds::refresh, as type.edit does.
Motion Blur took distance + 1 bilinear samples per pixel, each through Image::sample with per-tap edge checks, so distance 100 cost about 400 edge-checked reads per pixel and channel. The sample offsets are the same for every pixel, so merge their bilinear taps once into a fixed kernel (each tap an integer offset and a weight) and apply it tap by tap as a multiply-add over whole rows of a premultiplied window, which the compiler vectorizes. This is the same premultiplied mean as before, summed in a different order.
Read the indexed version-12 PNG preview with bounds, chunk ordering and CRC checks. Show native-content and resolution warnings, preserve the source through normal and automation opening, and reject auxiliary routes that cannot display the warning. Add format documentation, malformed-input and save-lifecycle tests, native-oracle hooks and parser/import fuzz targets. Editable native layers and Affinity export remain unsupported.
version, linux and web read no secrets. With environment: release they were refused on a branch,
so a branch dispatch of release.yml built nothing. Now a dry run builds every unsigned platform;
the signed jobs are still refused and the draft-release job needs them, so a branch can't create
a release. Matches craftrules release/playbook.md §2.
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* macOS: use the native menu bar instead of the in-window menus (#887)
On macOS the menus move from the title bar to the menu bar at the top of the
screen, built with muda from the same rows as the in-window bar (menu_items).
Linux, Windows and the web keep the in-window menus, and so does macOS with
--in-window-menus or PHOTOCRAFT_IN_WINDOW_MENUS=1.
The Mac layout (native_menu.rs, platform-free and tested everywhere):
- a PhotoCraft app menu: About (from Help), Settings (Edit › Preferences),
Services, Hide, Hide Others, Show All, and Quit, which is still file.exit so
the unsaved-changes prompt runs;
- a Window menu AppKit knows (Minimize, Zoom, Bring All to Front, the window
list) and a Help menu with the system search field;
- PhotoCraft keeps Photoshop's ⌘H (Extras), ⌘M (Curves), ⌘W (Close) and
⌘, (Hide Layers): Hide and Settings have no shortcut, Minimize is ⌃⌘M.
Key equivalents are handed back to egui as the key presses they were, so
shortcuts run through shortcuts::handle exactly as before: text fields keep
⌘C/⌘V/⌘Z, dialogs and Camera Raw stay modal. A click runs the command like an
in-window click. The menu is updated only when state changes, in place, and
rebuilt only when its structure does.
On every platform:
- menu rows show the shortcut that runs them: 12 live items (Undo, Redo, Cut,
Copy, Paste, Hide Layers, …) showed none;
- Image › Mode, View › Proof Setup, Proof Colors, Gamut Warning and Isolate
Layers stay check items with no document open, so opening one doesn't
change the menu's structure.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Native menu: test it against AppKit; fix the Window/Help menus and ⌘R routing
tests/mac_menu_appkit.rs (harness = false, main thread, like the tablet
crate's monitor test) installs the real muda menu and reads AppKit's menu
bar back: the PhotoCraft app menu, Quit ⌘Q, no Exit in File, the Window and
Help menus registered with NSApp, and Layer › New › Layer… enabled in place
(same NSMenu) once a document opens. It then sends a real ⌘R key-down
through -[NSMenu performKeyEquivalent:], which must reach egui as ⌘R and run
View › Rulers once, and chooses Rulers by click and by ↩ in the open menu,
which must run as clicks.
It found two bugs, fixed here:
- Window and Help were never registered: muda looks the submenus up in the
installed main menu, so set_as_*_menu_for_nsapp must come after
init_for_nsapp. The Window menu didn't list windows.
- AppKit matched ⌘R to Filter › Lens Correction (⇧⌘R), and the menu handed
egui the matched item's shortcut, so ⌘R ran Lens Correction. Key
equivalents now go back with the modifiers actually held.
Also a kittest: the title bar draws no menu titles with the Mac menu bar.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Mac app menu: Language and Appearance, Hide on ⌃⌘H (aligned with VectorCraft)
App settings live in the app menu, as on a Mac and as VectorCraft does:
- Language ▸ after Settings: Auto, then each UI language in its own name,
the interface.language preference checked; choosing one runs prefs.set
(unknown codes are refused).
- Appearance ▸: the themes (now check items, in-window too) and Next Theme
move from Window › Theme to the app menu.
- Hide PhotoCraft takes ⌃⌘H when ⌘H is View › Extras, as Photoshop does
(Minimize already takes ⌃⌘M).
The other Craft apps use the same app menu, Window menu and
--in-window-menus / <APP>_IN_WINDOW_MENUS opt-out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
The v0.4.0 release failed its web job: the wasm was 26,365,303 bytes, over the
24 MiB gate in packaging/web/package.sh and Cloudflare's 25 MiB per-file cap.
The largest single item was ICU4X's CJK segmentation dictionary (3.8 MB),
pulled in by parley's complex-scripts feature. Enable that feature on native
targets only; the web build falls back to the generic Unicode rules for
Japanese word boundaries and Thai/Lao/Khmer/Burmese line breaking.
wasm: 26,368,656 -> 22,578,266 bytes (21.5 MiB), measured with trunk build --release.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>