Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 16dd4b19ad fix(layers): identify the Background by structure, never by its English name
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
  in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
  "Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
  no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
  lock the Background's position like every other place that creates one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:57:11 -04:00
Brandon ThomasandClaude Opus 5.5 32552a61a1 fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606) (#4047)
Liquify dabs were spaced a fifth of the brush radius apart and restarted
at every pointer event, so a fast mouse (few far-apart events) and a slow
one (many close ones) warped the same path differently, and a slow drag
piled up one dab per event. Dabs now fall every twentieth of the radius
by distance travelled along the whole stroke, carried from one segment
to the next, as in Photoshop: any sampling of a path gives the same
field. The stationary tools' per-dab strength is scaled to the new
spacing so their effect per distance travelled is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:56:09 -04:00
Brandon ThomasandClaude Opus 5.5 f86517a029 fix(psd): write descriptor ids warp, list and time as length-4 strings (#4039)
A descriptor class or key id is a u32 length and bytes, where length 0
means a 4-byte code follows. Photoshop stores `warp`, `list` and `time`
as length-4 strings and treats the code form as a different key, so the
warps of new smart objects and type layers and regenerated layer comp
lists were not recognised. In the corpus, `warp` appears 741 times as a
string and never as a code, `list` 5/0, `time` 7/0.

`Id::new` now makes these three names string ids, as keys and class ids.
Reading is byte-based and accepts both forms; a code read from a file is
written back as a code.

Tests: exact bytes for each name as class and key, both forms read,
fresh placed-layer warp (SoLd/PlLd) and layer comp list bytes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:50:36 -04:00
Brandon ThomasandClaude Opus 5.5 62346f7fb6 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399) (#4032)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:45:50 -04:00
Brandon ThomasandClaude Opus 5.5 3d0c819d46 Release prep: 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:44:37 -04:00
Brandon ThomasandClaude Opus 5.5 dd708f4ce4 feat(psd): Gradient Map stops in CMYK, Lab, Gray and HSB import and round-trip (#4033)
Photoshop writes a Gradient Map's colour stops in the document's own colour
space (CMYK, Lab, Grayscale) or HSB. PhotoCraft only read RGB stops, so such
layers imported as unsupported and did nothing.

- Decode every plain PSD colour space (RGB, HSB, CMYK, Lab, Grayscale) in
  `grdm` stops; CMYK converts through the document's own CMYK profile
  (photocraft-cms), Lab through D50 Lab. Book colours and unknown ids stay
  unsupported (kept verbatim).
- Saving keeps the file's own `grdm` block, so stops keep their colour space;
  toggling Reverse or Dither patches just those flags in place.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:43:27 -04:00
Reynold Hu e79d6571ba Actions: replay tests for crop, filters, selections and styled text on a second document (AUTO-219-4) (#3566)
* test(engine): a recorded edit run replays on a second document

* docs(scorecard): mark AUTO-219-4 done; the replay tests cover all four families
2026-10-12 05:36:39 -04:00
a1a7b17ee0 fix(engine): a PSD type layer names the installed font, not an invented one (#3556) (#3564)
* fix(engine): a PSD type layer names the installed font, not an invented one (#3556)

A type layer created with a font by family name stored no PostScript name, so PSD export
invented one ("<Family>-Regular", spaces removed). Photoshop looks type up by PostScript name,
so it showed the font as missing and substituted Myriad Pro as soon as the text was edited.

Resolve the family's installed face (matching weight/italic) from the font database when the
family, style, weight or italic is set, and store its real PostScript name; an explicit
postscriptName still wins. A family PhotoCraft does not have keeps no name, so the export falls
back as before.

Test: a_family_font_gets_the_installed_postscript_name; the existing named-face test now expects
the resolved name.

* Type tool test: a family change names the installed face (#3556)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:32:03 -04:00
79cfa2b65a Guides: Shift-drag out of a ruler lands on the nearest ruler tick (#3770) (#3796)
As in Photoshop, holding Shift while dragging a new guide out of a ruler
snaps it to the tick marks the ruler draws (tenths of the labelled step),
in the current ruler unit and zoom. Without Shift the guide snaps to the
document, layers, selection and grid as before.

The tick spacing draw_rulers computed inline moves to ruler_scale, shared
by the renderer and the new snap_to_tick, so the snap always matches the
ticks on screen.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 05:22:58 -04:00
bade115202 Fail CLI run when recorded action playback fails (#3861)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 05:14:54 -04:00
Jakub 6d03075ed7 perf: draw marching ants as one mesh instead of a line shape per dash (#3846)
Every ants segment was a white line shape plus a black line shape per 4 px dash, and egui
tessellates each. A selection with many edges seen whole (a speckled selection at 30% zoom:
94,666 segments) cost 45.6 ms per idle frame, ten frames a second while the selection shows.

Horizontal and vertical segments now go into one egui::Mesh of 1 px colored quads, with the
same pixel-centre rounding, dash length and phase; the white base runs half a pixel past both
ends so corners join as the line caps did. Slanted segments (Content-Aware Move's transformed
outline) stay line shapes, which egui antialiases. A textured, repeating dash isn't possible:
egui-wgpu clamps texture coordinates.

Speckled selection, idle frame: 45.6 -> 12.0 ms at 30% zoom, 6.6 -> 4.5 ms at 125%
(RTX 3090, Vulkan; an idle frame with no selection is about 3 ms). Screenshots at 100% and
800% zoom are pixel-identical to the line shapes.
2026-10-12 05:14:41 -04:00
Sir Starch 5bdb12ec51 fix(canvas): keep transform preview visible while panning (#3610) (#3843) 2026-10-12 05:14:27 -04:00
Scott C Hughes ae0e5eb731 feat(ui): drag to reorder swatch groups and preset folders (#3658) (#3828)
Swatch groups and the shared preset-browser folders could only be clicked, so there was no way to put them in the order the user wants. Dragging a folder header now mirrors the dock's per-group tab reorder (#2272): an accent insertion line shows where it will land, and the order is stored by name in PresetUi (UI state, groupOrder) so it saves and reloads with the rest of the state, reading as leniently as tabOrder. Because the folders share one browser, Gradients, Patterns, Styles and Shapes get it too.

Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel.
2026-10-12 05:14:17 -04:00
zenjabbaandzenjabba ad5fb53f68 Web: avoid shifting canvas during preset saves (#3805)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 05:14:07 -04:00
JGFandClaude Opus 5.5 a2cdc6c2d7 fix(ui): drag a document tab off the strip to float it in its own window (#3733) (#3785)
Dragging a document tab more than 24 pt above or below the tab strip now
tears it off into a separate OS window placed where it was dropped, so it
can be moved anywhere, other screens included (as in Photoshop). Sideways
drags still reorder. Both tab strips share the new tab_drag helper.

window.arrange.floatInWindow takes optional `document` and `position`
params (validated: bad values are errors, not panics); DocWindow gains a
serde-default `position`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:13:48 -04:00
Valentin KleinandClaude Opus 5.5 9f49cae093 Move: with an unlinked layer mask targeted, the Move tool moves the mask alone (#3604) (#3611)
layer.translate takes target=mask: a layer whose mask is unlinked then shifts that mask only
(the shell adds the target when the Layers panel targets the mask), live preview included.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:04:13 -04:00
xsvm 6d21a69968 Brush: symmetric live strokes update only what changed (#3593) (#3605)
With painting symmetry on, every pointer event cloned and finished the stroke and each mirrored
pass, merged them again, composited the whole stroke and restored each pass's full bounds, so an
event cost grew with the area painted so far. StrokeRenderer::composite_union_live merges and
composites only the coverage tiles that changed (new dabs, the smoothing tails now and before),
leaving exactly the pixels the full merge gave; a mirror joining redraws every tile once.
2026-10-12 04:53:36 -04:00
4736a4bcbe Character panel: OpenType feature buttons (TYPE-218-5, #3752) (#3757)
The Character panel (and the Properties Character section) gains
Photoshop's row of eight OpenType buttons: Standard Ligatures, Contextual
Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling
Alternates, Ordinals and Fractions. Each shows pressed while its feature is
on at the selection (else the layer) and toggles it through the existing
type.openType.* command, on the same scope as the other Character controls
(the selected characters while typing, else every selected type layer).

Scorecard TYPE-218-5 stays partial: the Numerals and Stylistic Sets
submenus are still missing.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 04:52:23 -04:00
MrCloudy2andClaude Opus 5.5 8b21621671 Chisel bevels: Photoshop's facet slope and ramp ends (#3595)
Measured on Photoshop 27.11 (scripted: a 128 px square with one Bevel &
Emboss, light from the top at 30°, sampled pixel by pixel across its
edges; Chisel Hard and Soft, inner and outer, size 10 and 20, depth 100
and 300):

- A chisel facet's slope is depth × size / (2 (size + 1)): it rises to
  the same depth × size / 2 as a Smooth bevel, over size + 1 px.
  PhotoCraft gave it the full depth, about twice as steep (facet 217
  where Photoshop shows 188).
- Inner Hard's ramp runs from half a pixel beyond the edge to size + ½,
  so the facet is flat right up to the edge pixel; Inner Soft starts a
  pixel further out (a steeper edge pixel) and is not blurred; Outer
  Hard ends a pixel further out; Outer Soft is a pixel shorter with a
  steeper edge pixel. Outer chisel bevels also shade the pixel where
  their ramp reaches zero (it still slopes), as emboss styles already
  did.

All six inner and outer chisel cases are now within 1 level (they were
27 to 154 off); Smooth bevels are unchanged. Emboss and Pillow chisel
are not modelled yet (they keep the blur).

On the GPU: the same ramps in fs_mbevelh (Chisel Soft flag in p0.z), the
emboss-only blur, and the outer reach-past flag in p3.x; the 44 GPU
parity tests pass. Corpus: every Photoshop chisel oracle improves
(bevel-inner-chisel-hard 20.1 -> 6.5 % differing pixels, outer-chisel-
hard 13.6 -> 5.2 %, inner-chisel-soft 22.3 -> 9.3 %, emboss and pillow
chisel a few points); pass counts unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:43:01 -04:00
fabiodalez-dev dbf9d0d998 i18n(it): translate the dynamic labels the Italian catalog was missing (#3742)
Adds the 1,287 rows the other catalogs have and Italian lacked (filter and adjustment parameters and choices, Camera Raw, Lighting Effects, History and Layers labels, metadata, dialogs), fixes mistranslations in existing rows (Facet, Gradient stops, Save As Preset, Canvas, Spot Color) and uses one term per concept (Predefinito, Traccia, clic, scorciatoia, filmato).

Factors the Korean generated-label check into missing_generated_filter_labels and runs it for Italian too. Adds docs/localization-it.md and updates localization-parity.
2026-10-12 04:38:03 -04:00
90 changed files with 4192 additions and 431 deletions

No files matched your search

Generated
+29 -29
View File
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"arboard",
"async-io",
@@ -4224,7 +4224,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4234,7 +4234,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4247,7 +4247,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"base64",
"cap-std",
@@ -4269,7 +4269,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"moxcms",
"rayon",
@@ -4291,7 +4291,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"exr",
"flate2",
@@ -4312,7 +4312,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4320,7 +4320,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"blake3",
"exr",
@@ -4380,7 +4380,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4400,14 +4400,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"half",
"log",
@@ -4424,14 +4424,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4460,14 +4460,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4478,7 +4478,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4493,7 +4493,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4504,7 +4504,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4514,7 +4514,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"flate2",
"image",
@@ -4529,7 +4529,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4552,7 +4552,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"flate2",
"parley",
@@ -4566,7 +4566,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"base64",
"craft-ui",
@@ -4605,7 +4605,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -4616,7 +4616,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -7449,7 +7449,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+3
View File
@@ -311,6 +311,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
+44
View File
@@ -203,6 +203,50 @@ fn run_commands_and_save() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_stops_after_failed_action_without_exporting() {
let d = tmp("run-failed-action");
let out = d.join("result.png");
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "layer.new.layer"])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out)
.output()
.unwrap();
let error = String::from_utf8_lossy(&result.stderr);
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
assert!(!out.exists(), "a failed action must not export a partial result");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
assert!(playback["result"].get("failed").is_none());
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
assert!(out.is_file());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
+14 -19
View File
@@ -305,27 +305,22 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
if let Some(error) = self.presets.error() {
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
if let Some(error) = error {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
});
}
self.app.ui(ui, frame);
+16
View File
@@ -59,6 +59,22 @@ fn main() {
prev = p;
}
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
// stroke, and how far the two results differ.
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
let mut fields = Vec::new();
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
for step in [2.0, 60.0] {
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
let t = Instant::now();
let mut f = LiquifyField::new(b, cell);
f.apply_stroke(&drag);
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
fields.push(f);
}
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
st.tool = LiquifyTool::Bloat;
let t = Instant::now();
let mut d = Rect::EMPTY;
+196 -56
View File
@@ -154,7 +154,7 @@ impl LiquifyStroke {
}
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug)]
pub struct LiquifyField {
pub bounds: Rect,
/// Document pixels per field node.
@@ -165,6 +165,38 @@ pub struct LiquifyField {
pub d: Vec<[f32; 2]>,
/// Freeze mask per node (0 = free, 1 = frozen).
pub freeze: Vec<f32>,
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
cursor: Option<StrokeCursor>,
}
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
/// transient brush state, not part of the result.
impl PartialEq for LiquifyField {
fn eq(&self, o: &Self) -> bool {
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
}
}
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
/// the brush has travelled along the path since that dab.
#[derive(Clone, Copy, Debug, PartialEq)]
struct StrokeCursor {
end: [f64; 2],
last_dab: [f64; 2],
carry: f64,
}
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
(r * 0.05).max(0.5)
}
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
/// was with dabs a fifth of the radius apart.
fn moving_weight(r: f64) -> f64 {
dab_spacing(r) / (r * 0.2).max(0.5)
}
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
@@ -177,7 +209,7 @@ impl LiquifyField {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -220,6 +252,7 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
self.cursor = None;
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
@@ -306,77 +339,120 @@ impl LiquifyField {
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
}
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
/// tool held still dabs once at `b`).
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
/// along the whole stroke, carried from one segment to the next, so the result is the same
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
/// ended, the stroke starts afresh at `a`.
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
self.walk_segment(s, a, b, true)
}
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
/// would change nothing), which tests compare with the full walk.
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let stationary = s.tool.is_stationary();
let cur = match self.cursor {
Some(c) if c.end == [a[0], a[1]] => c,
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
};
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy);
if len == 0.0 {
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
self.cursor = Some(cur);
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
}
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
if !len.is_finite() {
self.cursor = None;
return dirty;
}
let r = (s.size / 2.0).max(0.5);
let spacing = (r * 0.2).max(0.5);
let steps = (len / spacing).ceil().max(1.0) as usize;
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
// touching anything, and a segment to a far-off point has trillions of them (#937).
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
for k in k0..=k1 {
let t0 = (k - 1) as f64 / steps as f64;
let t1 = k as f64 / steps as f64;
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let pr = pa + (pb - pa) * t1;
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&self.dab(s, centre, delta, pr));
let spacing = dab_spacing(r);
let weight = if stationary { moving_weight(r) } else { 1.0 };
// Distance along this segment of its first dab, then one every `spacing`.
let first = (spacing - cur.carry).clamp(0.0, spacing);
// Distances are compared with a little slack: a dab that rounding puts a hair past the
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
let slack = spacing * 1e-6;
if first > len + slack {
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
return dirty;
}
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
let (mut k0, mut k1) = (0.0f64, last_k);
if skip {
match self.reachable_span(a, [dx, dy], len, r) {
// A dragging dab is centred one spacing behind its position: pad by that and a step.
Some((d0, d1)) => {
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
}
None => k1 = -1.0,
}
}
if k0 <= k1 {
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
for k in (k0 as u64)..=(k1 as u64) {
let dist = (first + k as f64 * spacing).min(len);
let c = at(dist);
let delta = [c[0] - prev[0], c[1] - prev[1]];
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if stationary { c } else { prev };
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
prev = c;
}
}
let last = (first + last_k * spacing).min(len);
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
dirty
}
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
/// node only when its centre is within `r` plus a node of the node grid, so the window below
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
/// near that span can touch a node; a segment to a far-off point has trillions of others
/// (#937).
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
if self.w == 0 || self.h == 0 {
return None;
}
let pad = r + 2.0 * self.cell;
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
let (mut k0, mut k1) = (1.0f64, n);
let (mut u0, mut u1) = (0.0f64, 1.0f64);
for ax in 0..2 {
let d = b[ax] - a[ax];
let d = dir[ax];
if d == 0.0 {
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
return None;
}
continue;
}
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
u0 = u0.max(v0.min(v1));
u1 = u1.min(v0.max(v1));
}
// NaN: a segment whose length overflowed.
if k0.is_nan() || k1.is_nan() || k0 > k1 {
if u0.is_nan() || u1.is_nan() || u0 > u1 {
return None;
}
Some((k0 as usize, k1 as usize))
Some((u0 * len, u1 * len))
}
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
/// document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
if s.tool == LiquifyTool::LassoMask {
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
}
let r = (s.size / 2.0).max(0.5);
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
let density = (s.density / 100.0).clamp(0.0, 1.0);
if strength <= 0.0 {
@@ -921,21 +997,9 @@ mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
/// The segment walk before #937: every step, whether or not its dab can reach the field.
/// The segment walk before #937: every dab, whether or not it can reach the field.
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let r = (s.size / 2.0).max(0.5);
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
for k in 1..=steps {
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
}
dirty
f.walk_segment(s, a, b, false)
}
#[test]
@@ -945,7 +1009,7 @@ mod tests {
// every result bit-identical to the full walk.
use LiquifyTool::*;
let bounds = Rect::new(-7, 3, 89, 67);
let segments: [([f64; 3], [f64; 3]); 9] = [
let segments: [([f64; 3], [f64; 3]); 10] = [
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
@@ -955,6 +1019,7 @@ mod tests {
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
];
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
for size in [6.0, 40.0] {
@@ -973,6 +1038,81 @@ mod tests {
}
}
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
let mut out = vec![path[0].to_vec()];
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
// Uneven steps, as a real pointer gives: t grows quadratically.
for k in 1..=parts {
let t = (k * k) as f64 / (parts * parts) as f64;
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
}
}
out
}
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
/// whole stroke: any sampling of a path gives the same field.
#[test]
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
use LiquifyTool::*;
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
for size in [12.0, 40.0] {
let field = |parts: usize| {
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
let mut s = LiquifyStroke::new(tool, size);
s.points = sampled(&path, parts);
// Some displacement first, so Reconstruct and Smooth have something to change.
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
};
let coarse = field(1);
for parts in [3, 17, 60] {
let fine = field(parts);
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
}
}
}
}
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
/// up differs by a whole dab between a slow and a fast pointer.
#[test]
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
let field = |step: f64| {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
};
let (slow, fast) = (field(2.0), field(60.0));
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
let mut f = LiquifyField::new(bounds(), 2.0);
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
let mut g = LiquifyField::new(bounds(), 2.0);
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
assert_eq!(f, g);
// Hostile points: nothing panics and the next segment starts afresh.
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
}
#[test]
fn a_segment_to_a_far_off_point_returns() {
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
@@ -1110,8 +1250,8 @@ mod tests {
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
#[test]
fn forward_warp_leaves_the_brush_rim_in_place() {
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 4.0);
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 1.0);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
+2
View File
@@ -22,6 +22,7 @@ mod image;
mod options;
pub mod orientation;
pub mod resolution;
pub mod source_settings;
pub mod web;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
@@ -39,6 +40,7 @@ pub use crate::image::{
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
pub use half::f16;
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
+355
View File
@@ -0,0 +1,355 @@
//! The encoder settings a lossy file was written with, read back from its own data, so a save
//! can write it again the way it came (File › Save keeps a JPEG's quality and a WebP's lossy or
//! lossless encoding).
//!
//! * **JPEG**: the quality is estimated from the quantisation tables (DQT). Most encoders scale
//! the example tables of ITU-T T.81 Annex K (K.1 luminance, K.2 chrominance) by the
//! Independent JPEG Group's quality curve, so the quality whose scaled tables match the file's
//! best is the one it was saved at (exact for such tables; the closest fit for others).
//! * **WebP**: a `VP8L` image is lossless; a `VP8 ` image is lossy, and its base quantiser index
//! (RFC 6386 §9.6) maps back to a quality on the scale our encoder uses.
//!
//! Everything here reads hostile bytes: every index is checked and a malformed file gives `None`.
use crate::codecs::vp8;
/// ITU-T T.81 Annex K.1, the example luminance quantisation table, in natural (row) order.
const ANNEX_K_LUMA: [u16; 64] = [
16, 11, 10, 16, 24, 40, 51, 61, //
12, 12, 14, 19, 26, 58, 60, 55, //
14, 13, 16, 24, 40, 57, 69, 56, //
14, 17, 22, 29, 51, 87, 80, 62, //
18, 22, 37, 56, 68, 109, 103, 77, //
24, 35, 55, 64, 81, 104, 113, 92, //
49, 64, 78, 87, 103, 121, 120, 101, //
72, 92, 95, 98, 112, 100, 103, 99,
];
/// ITU-T T.81 Annex K.2, the example chrominance quantisation table, in natural order.
const ANNEX_K_CHROMA: [u16; 64] = [
17, 18, 24, 47, 99, 99, 99, 99, //
18, 21, 26, 66, 99, 99, 99, 99, //
24, 26, 56, 99, 99, 99, 99, 99, //
47, 66, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99,
];
/// The natural-order index of the k-th coefficient in zig-zag order (T.81 Figure A.6): DQT
/// stores its 64 values in this order.
const ZIGZAG: [usize; 64] = [
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
];
/// One quantisation table from a DQT segment, in natural order, with its precision.
#[derive(Debug, Clone, Copy)]
struct Table {
values: [u16; 64],
/// 16-bit entries (Pq = 1) rather than 8-bit.
wide: bool,
}
/// The quality (1–100, the Independent JPEG Group's scale that Save As and Export As use) a
/// JPEG was most likely saved at, from its quantisation tables; `None` when it has no usable
/// luminance table (or isn't a JPEG).
pub fn jpeg_quality(bytes: &[u8]) -> Option<u8> {
let tables = jpeg_tables(bytes);
let [luma, chroma, ..] = tables;
let luma = luma?;
let mut best: Option<(u64, u8)> = None;
for q in 1..=100u8 {
let mut err = table_error(&luma, &ANNEX_K_LUMA, q);
if let Some(c) = chroma {
err = err.saturating_add(table_error(&c, &ANNEX_K_CHROMA, q));
}
if best.is_none_or(|(e, _)| err < e) {
best = Some((err, q));
}
}
best.map(|(_, q)| q)
}
/// The Independent JPEG Group's quality scaling of `base` (libjpeg's `jpeg_quality_scaling` and
/// `jpeg_add_quant_table`, as published with its source): a percentage scale of 5000 / q below
/// quality 50 and 200 − 2q from it, rounded, and kept within 1..=255 (8-bit) or 1..=32767.
fn scaled(base: u16, q: u8, wide: bool) -> u32 {
let q = u32::from(q.clamp(1, 100));
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
let v = (u32::from(base) * scale + 50) / 100;
v.clamp(1, if wide { 32767 } else { 255 })
}
/// How far `t` is from `base` scaled to quality `q`: the summed absolute difference.
fn table_error(t: &Table, base: &[u16; 64], q: u8) -> u64 {
t.values.iter().zip(base).map(|(&v, &b)| u64::from(u32::from(v).abs_diff(scaled(b, q, t.wide)))).sum()
}
/// The quantisation tables 0..=3 defined before the first scan (a later definition of the same
/// slot replaces an earlier one).
fn jpeg_tables(b: &[u8]) -> [Option<Table>; 4] {
let mut tables = [None; 4];
if b.get(..2) != Some(&[0xFF, 0xD8]) {
return tables;
}
let mut i = 2usize;
while let (Some(&0xFF), Some(&marker)) = (b.get(i), b.get(i.saturating_add(1))) {
if marker == 0xFF {
i = i.saturating_add(1);
continue;
}
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
i = i.saturating_add(2);
continue;
}
if marker == 0xDA || marker == 0xD9 {
break;
}
let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { break };
let Some(seg) = len.checked_sub(2).and_then(|n| b.get(i + 4..i + 4 + n)) else { break };
if marker == 0xDB {
read_dqt(seg, &mut tables);
}
i = i.saturating_add(2).saturating_add(len);
}
tables
}
/// The tables of one DQT segment; a truncated or malformed table ends the segment.
fn read_dqt(mut seg: &[u8], tables: &mut [Option<Table>; 4]) {
while let Some((&pq_tq, rest)) = seg.split_first() {
let wide = pq_tq >> 4 == 1;
if pq_tq >> 4 > 1 {
return;
}
let slot = usize::from(pq_tq & 0x0F);
let n = if wide { 128 } else { 64 };
let Some(data) = rest.get(..n) else { return };
let mut values = [0u16; 64];
for (k, &natural) in ZIGZAG.iter().enumerate() {
let v = if wide { data.get(2 * k..2 * k + 2).map_or(0, |p| u16::from_be_bytes([p[0], p[1]])) } else { data.get(k).map_or(0, |&p| u16::from(p)) };
if let Some(dst) = values.get_mut(natural) {
*dst = v;
}
}
if let Some(dst) = tables.get_mut(slot) {
*dst = Some(Table { values, wide });
}
seg = rest.get(n..).unwrap_or(&[]);
}
}
/// How a WebP file's image is compressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebpEncoding {
/// VP8L.
Lossless,
/// VP8, with the quality (0–100, our encoder's scale) its quantiser maps back to, when the
/// frame header could be read.
Lossy { quality: Option<u8> },
}
/// The compression of a WebP file's (first) image, or `None` when it isn't a WebP or holds no
/// image chunk.
pub fn webp_encoding(bytes: &[u8]) -> Option<WebpEncoding> {
if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") {
return None;
}
find_image_chunk(bytes.get(12..)?, 0)
}
/// The first `VP8 ` or `VP8L` chunk among `chunks`, looking inside animation frames.
fn find_image_chunk(mut chunks: &[u8], depth: u8) -> Option<WebpEncoding> {
while let Some(head) = chunks.get(..8) {
let fourcc = head.get(..4)?;
let size = usize::try_from(u32::from_le_bytes([head[4], head[5], head[6], head[7]])).ok()?;
let end = 8usize.checked_add(size)?;
// A truncated last chunk still holds its header.
let payload = chunks.get(8..end.min(chunks.len()))?;
match fourcc {
b"VP8L" => return Some(WebpEncoding::Lossless),
b"VP8 " => return Some(WebpEncoding::Lossy { quality: vp8_quality(payload) }),
// An animation frame: a 16-byte header, then the frame's own chunks.
b"ANMF" if depth == 0 => {
if let Some(found) = payload.get(16..).and_then(|c| find_image_chunk(c, 1)) {
return Some(found);
}
}
_ => {}
}
chunks = chunks.get(end.checked_add(size % 2)?..).unwrap_or(&[]);
}
None
}
/// The quality a VP8 key frame's base quantiser index maps back to on our encoder's scale (the
/// quality whose index is nearest; the middle one when several share it).
fn vp8_quality(frame: &[u8]) -> Option<u8> {
let qi = vp8_base_quantizer(frame)?;
let distance = |q: u8| vp8::quantizer_index(q).abs_diff(qi);
let best = (0..=100u8).map(distance).min()?;
let matches: Vec<u8> = (0..=100u8).filter(|&q| distance(q) == best).collect();
let mid = matches.get(matches.len() / 2)?;
Some(*mid)
}
/// The base quantiser index of a VP8 key frame: `y_ac_qi` from its frame header (RFC 6386
/// §9.2–9.6, §19.2), averaged over the segments when segmentation sets absolute quantisers or
/// adjusted by their mean delta otherwise.
fn vp8_base_quantizer(frame: &[u8]) -> Option<u8> {
let tag = frame.get(..3)?;
// Bit 0 clear: a key frame, which carries the start code and dimensions.
if tag[0] & 1 != 0 || frame.get(3..6)? != [0x9D, 0x01, 0x2A] {
return None;
}
let mut d = BoolDecoder::new(frame.get(10..)?);
d.literal(1); // color_space
d.literal(1); // clamping_type
let mut segments: Option<(bool, [i32; 4])> = None;
if d.flag() {
let update_map = d.flag();
if d.flag() {
let absolute = d.flag();
let mut q = [0i32; 4];
for v in &mut q {
*v = if d.flag() { d.signed(7) } else { 0 };
}
for _ in 0..4 {
if d.flag() {
d.signed(6);
}
}
segments = Some((absolute, q));
}
if update_map {
for _ in 0..3 {
if d.flag() {
d.literal(8);
}
}
}
}
d.literal(1); // filter_type
d.literal(6); // loop_filter_level
d.literal(3); // sharpness_level
if d.flag() && d.flag() {
// loop_filter_adj_enable, mode_ref_lf_delta_update: 4 reference and 4 mode deltas.
for _ in 0..8 {
if d.flag() {
d.signed(6);
}
}
}
d.literal(2); // log2_nbr_of_dct_partitions
if d.exhausted() {
return None;
}
let base = d.literal(7) as i32;
let qi = match segments {
Some((true, q)) => q.iter().sum::<i32>() / 4,
Some((false, q)) => base + q.iter().sum::<i32>() / 4,
None => base,
};
u8::try_from(qi.clamp(0, 127)).ok()
}
/// RFC 6386 §7's boolean decoder, reading zeros past the end of its data.
struct BoolDecoder<'a> {
data: &'a [u8],
pos: usize,
value: u32,
range: u32,
bit_count: u32,
/// Bytes read past the end of the data.
overrun: usize,
}
impl<'a> BoolDecoder<'a> {
fn new(data: &'a [u8]) -> Self {
let mut d = BoolDecoder { data, pos: 0, value: 0, range: 255, bit_count: 0, overrun: 0 };
d.value = (d.next_byte() << 8) | d.next_byte();
d
}
fn next_byte(&mut self) -> u32 {
match self.data.get(self.pos) {
Some(&b) => {
self.pos += 1;
u32::from(b)
}
None => {
self.overrun = self.overrun.saturating_add(1);
0
}
}
}
/// The header was read from beyond the end of the data (a couple of bytes of look-ahead
/// are normal).
fn exhausted(&self) -> bool {
self.overrun > 2
}
fn bool(&mut self, prob: u32) -> bool {
let split = 1 + (((self.range - 1) * prob) >> 8);
let big_split = split << 8;
let bit = if self.value >= big_split {
self.range -= split;
self.value -= big_split;
true
} else {
self.range = split;
false
};
while self.range < 128 {
self.value <<= 1;
self.range <<= 1;
self.bit_count += 1;
if self.bit_count == 8 {
self.bit_count = 0;
self.value |= self.next_byte();
}
}
bit
}
fn flag(&mut self) -> bool {
self.bool(128)
}
fn literal(&mut self, bits: u32) -> u32 {
(0..bits).fold(0, |v, _| (v << 1) | u32::from(self.flag()))
}
/// A magnitude of `bits` bits followed by a sign bit.
fn signed(&mut self, bits: u32) -> i32 {
let v = self.literal(bits) as i32;
if self.flag() { -v } else { v }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaling_matches_the_published_curve() {
// Quality 50 is the Annex K table itself; 100 is all ones; 75 halves it.
assert_eq!(scaled(16, 50, false), 16);
assert_eq!(scaled(121, 100, false), 1);
assert_eq!(scaled(16, 75, false), 8);
assert_eq!(scaled(121, 1, false), 255);
assert_eq!(scaled(121, 1, true), 6050);
}
#[test]
fn zigzag_is_a_permutation() {
let mut seen = [false; 64];
for &n in &ZIGZAG {
seen[n] = true;
}
assert!(seen.iter().all(|&s| s));
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Reading back the settings a lossy file was saved with: a JPEG's quality from its
//! quantisation tables, a WebP's lossy or lossless encoding (and its quality).
mod common;
use common::*;
use photocraft_codecs::*;
fn photo(w: u32, h: u32, layout: ChannelLayout) -> Image {
synth(w, h, layout, SampleType::U8, 5, 0.03)
}
#[test]
fn jpeg_quality_is_estimated_within_two() {
for layout in [ChannelLayout::Rgb, ChannelLayout::Gray] {
let img = photo(40, 24, layout);
for q in [1u8, 5, 10, 25, 40, 50, 60, 70, 75, 80, 85, 90, 95, 98, 100] {
let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
let est = jpeg_quality(&bytes).unwrap_or_else(|| panic!("{layout:?} q{q}: no estimate"));
assert!(est.abs_diff(q) <= 2, "{layout:?}: saved at {q}, estimated {est}");
}
}
}
#[test]
fn jpeg_quality_of_hand_built_tables() {
// A minimal JPEG prefix: SOI, one DQT holding the Annex K luminance table (quality 50) in
// zig-zag order, then SOS.
let luma: [u8; 64] = [
16, 11, 12, 14, 12, 10, 16, 14, 13, 14, 18, 17, 16, 19, 24, 40, 26, 24, 22, 22, 24, 49, 35, 37, 29, 40, 58, 51, 61, 60, 57, 51, 56, 55, 64, 72, 92, 78,
64, 68, 87, 69, 55, 56, 80, 109, 81, 87, 95, 98, 103, 104, 103, 62, 77, 113, 121, 112, 100, 120, 92, 101, 103, 99,
];
let mut b = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00];
b.extend_from_slice(&luma);
b.extend_from_slice(&[0xFF, 0xDA, 0, 2]);
assert_eq!(jpeg_quality(&b), Some(50));
}
#[test]
fn hostile_jpeg_tables_give_no_estimate_and_never_panic() {
let img = photo(16, 16, ChannelLayout::Rgb);
let good = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
// Every truncation of a real file.
for n in 0..good.len().min(800) {
let _ = jpeg_quality(&good[..n]);
}
let cases: Vec<Vec<u8>> = vec![
vec![],
vec![0xFF],
vec![0xFF, 0xD8],
// A DQT whose length runs past the end.
vec![0xFF, 0xD8, 0xFF, 0xDB, 0xFF, 0xFF, 0x00, 1, 2, 3],
// A DQT of length 0 and 1 (less than its own length field).
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00],
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01],
// A table cut short, a bad precision, a slot past 3, a 16-bit table cut mid-entry.
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 12, 0x00], vec![9; 9]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x70], vec![9; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x0F], vec![9; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 68, 0x10], vec![0xFF; 65]].concat(),
// All-zero and all-0xFF tables.
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00], vec![0; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 131, 0x10], vec![0xFF; 128]].concat(),
vec![0xFF; 64],
];
for c in &cases {
let _ = jpeg_quality(c);
}
assert_eq!(jpeg_quality(&[0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00]), None);
assert_eq!(jpeg_quality(b"not a jpeg at all"), None);
let png = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert_eq!(jpeg_quality(&png), None);
}
#[test]
fn webp_lossless_and_lossy_are_told_apart() {
let img = photo(33, 21, ChannelLayout::Rgb);
let lossless = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
assert_eq!(webp_encoding(&lossless), Some(WebpEncoding::Lossless));
for q in [10u8, 30, 50, 75, 80, 90, 100] {
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: q, ..Default::default() }).unwrap();
let Some(WebpEncoding::Lossy { quality: Some(est) }) = webp_encoding(&bytes) else { panic!("q{q}: {:?}", webp_encoding(&bytes)) };
assert!(est.abs_diff(q) <= 2, "lossy WebP saved at {q}, estimated {est}");
}
// With alpha and metadata (the extended container).
let rgba = photo(20, 20, ChannelLayout::Rgba);
let bytes = encode(&rgba, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
assert!(matches!(webp_encoding(&bytes), Some(WebpEncoding::Lossy { quality: Some(58..=62) })), "{:?}", webp_encoding(&bytes));
}
#[test]
fn hostile_webp_never_panics() {
let img = photo(24, 24, ChannelLayout::Rgb);
for opts in [EncodeOptions::default(), EncodeOptions { webp_lossless: false, ..Default::default() }] {
let good = encode(&img, Format::WebP, &opts).unwrap();
for n in 0..good.len().min(400) {
let _ = webp_encoding(&good[..n]);
}
let mut flipped = good.clone();
for i in 12..flipped.len().min(64) {
flipped[i] ^= 0xA5;
let _ = webp_encoding(&flipped);
}
}
let mut huge = b"RIFF\0\0\0\0WEBPVP8 ".to_vec();
huge.extend_from_slice(&u32::MAX.to_le_bytes());
let _ = webp_encoding(&huge);
let mut anmf = b"RIFF\0\0\0\0WEBPANMF".to_vec();
anmf.extend_from_slice(&3u32.to_le_bytes());
let _ = webp_encoding(&anmf);
assert_eq!(webp_encoding(b"RIFF\0\0\0\0WAVE"), None);
}
+3 -3
View File
@@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
}
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
/// named "Background" (how PSD import and File › New create it).
/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind,
/// never by its (localized) name ([`photocraft_doc::Document::background`]).
pub fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
l.has_background_traits()
}
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
+23
View File
@@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() {
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
assert!(got[3] <= 1.0);
}
/// The Background is found by its structure, not its English name: a file from a German or
/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A
/// bottom layer merely called "Background", without the Background's locks, is not one.
#[test]
fn deep_knockout_reveals_a_localized_background() {
for depth in DEPTHS {
for name in ["Hintergrund", "背景", "Arrière-plan"] {
let mut d = doc(depth, true);
d.layers[0].name = name.into();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, WHITE, name);
}
let mut d = doc(depth, true);
d.layers[0].locks = Default::default();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
}
}
+81 -11
View File
@@ -1455,7 +1455,9 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
BevelGeom {
paint,
width,
depth: b.depth * if smooth { width } else { size } * sign,
// A bevel rises `depth × width / 2` px: Smooth with a slope falling from `depth` at the
// edge (the tent), a chisel with the constant slope of its ramp (see `bevel_chisel_h`).
depth: b.depth * if smooth { width } else { size / 2.0 } * sign,
pillow: b.style == BevelStyle::PillowEmboss,
chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 },
}
@@ -1476,10 +1478,13 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
let sharp = sharp_edges(shape);
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
let mut h = Map::new(shape.w, shape.h, 0.0);
let soft = g.chisel_soft > 0.0;
for i in 0..h.v.len() {
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i]);
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
}
if g.chisel_soft > 0.0 {
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
// Hard's across a straight edge); emboss styles keep the blur until measured.
if soft && g.paint == BevelPaint::Both {
tent(&mut h, g.chisel_soft);
}
h
@@ -1521,17 +1526,46 @@ pub struct TextureCtx<'a> {
}
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 {
///
/// Photoshop 27.11, measured across straight edges (sizes 10 and 20, depth 100 and 300): a chisel
/// facet has the constant slope `depth × size / (2 (size + 1))`, i.e. one height step of
/// `1 / (size + 1)` per pixel at the `depth × size / 2` scale of [`bevel_geom`]. Inner Hard's ramp
/// runs from half a pixel beyond the edge to `size + ½` (the facet is flat right up to the edge
/// pixel); Inner Soft starts a pixel further out, so its edge pixel is steeper; Outer Hard ends a
/// pixel further out. All within 1 level of Photoshop on those profiles.
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32, soft: bool) -> f32 {
let ramp = size + 1.0;
match paint {
BevelPaint::Outer => 1.0 - (dout / size).clamp(0.0, 1.0),
BevelPaint::Both => {
if din > 0.0 {
0.5 + 0.5 * (din / (size / 2.0)).clamp(0.0, 1.0)
BevelPaint::Outer if !soft => {
let top = (size + 2.0) / ramp;
if dout > 0.0 { ((size + 1.5 - dout) / ramp).clamp(0.0, top) } else { top }
}
// Outer Soft: Outer Hard's ramp a pixel shorter, with a steeper edge pixel.
BevelPaint::Outer => {
if dout > 0.0 {
1.0 - ((dout + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.5 - 0.5 * (dout / (size / 2.0)).clamp(0.0, 1.0)
(size + 2.0) / ramp
}
}
BevelPaint::Both => {
let half = size / 2.0 + 0.5;
if din > 0.0 { 0.5 + 0.5 * (din / half).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout / half).clamp(0.0, 1.0) }
}
BevelPaint::Inner if soft => {
if din > 0.0 {
((din + 1.5) / ramp).clamp(0.0, (size + 2.0) / ramp)
} else {
0.0
}
}
BevelPaint::Inner => {
if din > 0.0 {
((din + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.0
}
}
BevelPaint::Inner => (din / size).clamp(0.0, 1.0),
}
}
@@ -1549,7 +1583,9 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
// bevel-outer-smooth).
let reach_past = g.paint == BevelPaint::Both;
// Outer chisel bevels too: the pixel where their ramp reaches zero still slopes (Photoshop
// 27.11 shades it, half strength, at `size + 1½` px out).
let reach_past = g.paint == BevelPaint::Both || (g.paint == BevelPaint::Outer && b.technique != BevelTechnique::Smooth);
let on_bevel = |x: i64, y: i64| {
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
};
@@ -2334,6 +2370,40 @@ mod tests {
assert!(at(&maps[0], 20, 15) < 0.05);
}
/// Photoshop 27.11, measured across a straight edge (size 10, depth 100 %, light from the top
/// at 30°): a chisel facet has the slope `depth × size / (2 (size + 1))`, flat right up to the
/// edge pixel for Inner Hard; Inner Soft's edge pixel is half as steep again; Outer Hard
/// still shades, partly, the pixel where its ramp reaches zero `size + 1½` px out.
#[test]
fn chisel_facets_match_photoshop() {
let amount = |g: f32| {
let (se, ce) = 30f32.to_radians().sin_cos();
((g * ce + se) / (1.0 + g * g).sqrt() - se) / (1.0 - se)
};
let n = 80;
let shape = square(n, 20, 60);
let maps = |style, technique| {
let mut b = bevel_of(style, technique);
b.size = 10.0;
bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES)).0
};
let at = |m: &Map, y: usize| m.v[y * n + 40];
let facet = amount(10.0 / 22.0);
let hard = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselHard);
for y in 20..30 {
assert!((at(&hard[0], y) - facet).abs() < 0.01, "inner hard facet at row {y}: {} vs {facet}", at(&hard[0], y));
}
assert!(at(&hard[0], 30) > 0.05 && at(&hard[0], 30) < facet, "one partial pixel at the end");
let soft = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselSoft);
assert!((at(&soft[0], 20) - amount(1.5 * 10.0 / 22.0)).abs() < 0.01, "steeper edge pixel: {}", at(&soft[0], 20));
assert!((at(&soft[0], 24) - facet).abs() < 0.01, "then Chisel Hard's facet");
let outer = maps(BevelStyle::OuterBevel, BevelTechnique::ChiselHard);
// Below the shape (rows 60..): shadow facet through 10½ px, partial at 11½.
assert!((at(&outer[1], 70) - at(&outer[1], 61)).abs() < 1e-4);
assert!(at(&outer[1], 71) > 0.05 && at(&outer[1], 71) < at(&outer[1], 70));
assert_eq!(at(&outer[1], 72), 0.0);
}
#[test]
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
let shape = square(40, 10, 30);
+77
View File
@@ -636,6 +636,13 @@ pub struct Layer {
}
impl Layer {
/// Whether the layer has the Background's own properties: an unclipped pixel layer with
/// transparency and position locked. Only the bottom layer of a document with them is the
/// Background: use [`Document::background`] / [`Document::is_background`] to identify it.
pub fn has_background_traits(&self) -> bool {
self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_))
}
pub fn new(name: impl Into<String>, content: LayerContent) -> Self {
Layer {
id: LayerId::fresh(),
@@ -875,6 +882,32 @@ pub struct Document {
pub variables: Variables,
/// Window › Timeline (None until a video timeline is created).
pub timeline: Option<Timeline>,
/// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so
/// a save writes it back the same way rather than at fixed defaults.
pub file_encoding: FileEncoding,
}
/// The encoder settings a document's file was opened or saved with: what File › Save and the
/// Save As defaults reuse for that format. Empty for a new document or a file without lossy
/// options. Qualities are on the codecs' 1–100 scale.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FileEncoding {
/// JPEG quality (estimated from the file's quantisation tables on open).
#[serde(skip_serializing_if = "Option::is_none")]
pub jpeg_quality: Option<u8>,
/// WebP: `Some(true)` lossless (VP8L), `Some(false)` lossy (VP8 at [`Self::webp_quality`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub webp_lossless: Option<bool>,
/// Lossy WebP quality.
#[serde(skip_serializing_if = "Option::is_none")]
pub webp_quality: Option<u8>,
}
impl FileEncoding {
pub fn is_empty(&self) -> bool {
*self == FileEncoding::default()
}
}
/// Where a layer lives in the tree: indices from the root down.
@@ -913,6 +946,7 @@ impl Document {
slices: Slices::default(),
variables: Variables::default(),
timeline: None,
file_encoding: FileEncoding::default(),
}
}
@@ -1013,6 +1047,25 @@ impl Document {
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
}
/// The Background layer, identified structurally as Photoshop does, never by its name (a file
/// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of
/// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD
/// stores it without a transparency channel and transparency-protected; File › New and opaque
/// flat files create it the same way).
pub fn background(&self) -> Option<&Layer> {
self.layers.first().filter(|l| Layer::has_background_traits(l))
}
/// [`Self::background`], mutably.
pub fn background_mut(&mut self) -> Option<&mut Layer> {
self.layers.first_mut().filter(|l| Layer::has_background_traits(l))
}
/// Whether `id` is the document's Background layer ([`Self::background`]).
pub fn is_background(&self, id: LayerId) -> bool {
self.background().is_some_and(|b| b.id == id)
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
@@ -1178,6 +1231,30 @@ mod tests {
assert_eq!(s.pixel(100, 0), vec![0.0; 4]);
}
/// The Background is the locked bottom pixel layer, whatever it is called.
#[test]
fn background_is_structural_not_named() {
let mut d = doc();
let id = d.layers[0].id;
assert!(d.is_background(id));
for name in ["Hintergrund", "背景", "Arrière-plan"] {
d.layers[0].name = name.into();
assert_eq!(d.background().map(|l| l.id), Some(id), "{name}");
}
// A layer called "Background" without the locks, or above the bottom, is not it.
let mut top = Layer::raster("Background", d.pixel_format());
top.locks.transparency = true;
top.locks.position = true;
let top_id = top.id;
d.layers.push(top);
assert!(!d.is_background(top_id));
d.layers[0].locks.position = false;
assert!(d.background().is_none());
d.layers[0].locks.position = true;
d.layers[0].clipped = true;
assert!(d.background_mut().is_none());
}
#[test]
fn max_group_depth_counts_nesting_without_recursing() {
let mut d = doc();
+40
View File
@@ -1224,4 +1224,44 @@ mod tests {
assert!(bad.actions.list.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// AUTO-219-4: a recording made on one document replays on a second one — a crop, a filter,
/// a selection fill and styled text all act on the document the replay runs in.
#[test]
fn a_recorded_edit_run_replays_on_a_second_document() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
s.execute("actions.record", json!({"name": "Studio"})).unwrap();
s.execute("image.crop", json!({"x": 8, "y": 8, "width": 48, "height": 48})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("type.create", json!({"x": 10, "y": 36, "text": "Hi", "size": 12, "color": "#000000"})).unwrap();
s.execute("type.setStyle", json!({"fauxBold": true})).unwrap();
let stopped = s.execute("actions.stop", json!({})).unwrap();
let steps = stopped["steps"].as_u64().unwrap();
assert!(steps >= 7, "the recording keeps every family: {stopped}");
// The second document: a different size, background and name. The replay must act here.
s.execute("file.new", json!({"width": 100, "height": 80, "background": "black", "name": "Second"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Studio"})).unwrap();
assert!(played.get("failed").is_none(), "replay: {played}");
assert_eq!(played["ran"].as_u64(), Some(steps), "every step ran: {played}");
let d = &s.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height), (48, 48), "the crop replayed onto the second document");
assert_eq!(pixel(&s, 0, 16, 16), vec![1.0, 0.0, 0.0, 1.0], "the selection fill replayed");
assert_eq!(pixel(&s, 0, 2, 2), vec![0.0, 0.0, 0.0, 1.0], "outside the selection stays black");
let last = d.layers.last().unwrap();
match &last.content {
photocraft_doc::LayerContent::Text(t) => {
assert_eq!(t.text, "Hi", "the styled text replayed");
assert!(t.char_runs().iter().any(|r| r.style.faux_bold), "the bold run replayed");
}
other => panic!("expected the recorded type layer, got {other:?}"),
}
// The first document is untouched.
let first = &s.documents()[0].doc;
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
}
}
+1
View File
@@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
let mut bg = Layer::raster("Background", fmt);
bg.locks.transparency = true;
bg.locks.position = true;
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
*active = Some(bg.id);
doc.layers = vec![bg];
+1 -1
View File
@@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"angle": 0.0}));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
compound(s, "Straighten", |s| {
+7 -8
View File
@@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::extra_cmds::is_background;
use crate::{EngineError, Result, Session};
const CMD: &str = "layer.new.backgroundFromLayer";
/// The document's Background layer, if it has one (always the bottom layer).
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
doc.background().map(|l| l.id)
}
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
@@ -155,7 +154,7 @@ mod tests {
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
let l = &doc(&s).layers[0];
assert!(is_background(l) && l.name == "Background", "{depth}");
assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
assert_eq!(s.active().unwrap().active_layer, Some(a));
// Its pixels stay; transparency takes the background colour (blue).
let red = pixel(&s, 3, 3);
@@ -170,10 +169,10 @@ mod tests {
assert!(s.undo());
assert_eq!(order(&s), [l0, a, b], "{depth}");
let l = doc(&s).layer(a).unwrap();
assert!(!is_background(l) && l.name == "A");
assert!(!doc(&s).is_background(l.id) && l.name == "A");
assert!(s.redo());
assert_eq!(order(&s), [a, l0, b], "{depth}");
assert!(is_background(&doc(&s).layers[0]));
assert!(doc(&s).is_background(doc(&s).layers[0].id));
}
}
@@ -191,7 +190,7 @@ mod tests {
assert_eq!(d.layers[1].id, l0);
assert_eq!(d.layers[2].id, a);
let bg = &d.layers[0];
assert!(is_background(bg) && !bg.visible, "visibility is kept");
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
assert_eq!(bg.opacity, 1.0);
}
@@ -211,7 +210,7 @@ mod tests {
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
// A group or an adjustment layer can't become the Background.
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
@@ -220,7 +219,7 @@ mod tests {
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
assert!(!s.is_enabled(CMD));
assert!(s.execute(CMD, json!({})).is_err());
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
}
#[test]
+23 -28
View File
@@ -415,8 +415,9 @@ pub struct LiveStroke {
symmetry: Option<crate::symmetry_cmds::PaintingSymmetry>,
mirrors: Vec<StrokeRenderer>,
mirror_distinct: Vec<bool>,
/// Restore passes separately; their union may span an otherwise untouched document.
symmetry_regions: Vec<Rect>,
/// What the symmetric preview drew last, once a mirror differs from the stroke, and how many
/// passes it merged (a mirror joining redraws every tile).
union_live: Option<(photocraft_paint::UnionLive, usize)>,
pre: Surface,
sel: Option<Surface>,
lock: bool,
@@ -508,7 +509,7 @@ impl LiveStroke {
symmetry,
mirrors,
mirror_distinct,
symmetry_regions: Vec::new(),
union_live: None,
pre,
sel,
lock,
@@ -567,33 +568,27 @@ impl LiveStroke {
}
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
if self.mirror_distinct.iter().any(|d| *d) {
// Merge every finished pass once, both here and on commit, including shared axes.
// The stroke and its distinct mirrors composite as one stroke, as the commit
// merges them; only the tiles that changed are redrawn.
let old = self.tail;
let mut original_preview = self.renderer.clone();
original_preview.finish();
let copies: Vec<_> = self
.mirrors
.iter()
.zip(&self.mirror_distinct)
.filter(|(_, d)| **d)
.map(|(r, _)| {
let mut r = r.clone();
r.finish();
r
})
let passes = 1 + self.mirror_distinct.iter().filter(|d| **d).count();
let (mut live, full, mut dmg) = match self.union_live.take() {
Some((live, merged)) => (live, merged != passes, Rect::EMPTY),
None => {
// Leaving the single-pass preview: take its smoothing tail back first.
if !old.is_empty() {
surf.write_region(old, &self.pre.read_region(old));
}
(photocraft_paint::UnionLive::default(), true, old)
}
};
let mut renderers: Vec<&mut StrokeRenderer> = std::iter::once(&mut self.renderer)
.chain(self.mirrors.iter_mut().zip(&self.mirror_distinct).filter(|(_, d)| **d).map(|(r, _)| r))
.collect();
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
if self.symmetry_regions.is_empty() && !old.is_empty() {
surf.write_region(old, &self.pre.read_region(old));
}
for region in &self.symmetry_regions {
surf.write_region(*region, &self.pre.read_region(*region));
}
self.symmetry_regions =
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
self.tail = bounds;
return Ok(damage.union(&bounds));
dmg = dmg.union(&StrokeRenderer::composite_union_live(&mut renderers, &mut live, full, &self.pre, surf, self.sel.as_ref(), self.lock));
self.tail = old.union(&live.bounds());
self.union_live = Some((live, passes));
return Ok(dmg);
}
let mut dmg = Rect::EMPTY;
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
+2 -1
View File
@@ -608,7 +608,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<st
/// (filters, adjustments, paint and fill commands); the shell adds its mask target to these.
pub fn follows_target(id: &str) -> bool {
// Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's.
routed(id) || id == "select.float"
// Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`).
routed(id) || id == "select.float" || id == "layer.translate"
}
/// Commands whose target follows the Channels panel.
+2 -2
View File
@@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
if locks.pixels || locks.all {
return Err(format!("the layer \"{}\" is locked", l.name));
}
if locks.transparency && !crate::extra_cmds::is_background(l) {
if locks.transparency && !d.doc.is_background(l.id) {
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
}
Ok(())
@@ -217,7 +217,7 @@ mod tests {
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&s), "Background");
let d = s.active().unwrap();
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
assert!(d.doc.is_background(d.active_layer.unwrap()));
}
#[test]
+2 -2
View File
@@ -878,7 +878,7 @@ fn build() -> Vec<CommandSpec> {
"Move Layer",
[],
None,
r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##,
r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##,
has_layer,
crate::layer_multi_cmds::translate
),
@@ -1416,7 +1416,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
let from_background = doc.is_background(id);
let mut dup = src.duplicate();
dup.name = doc.copy_name(&dup.name);
if from_background {
+4 -12
View File
@@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> {
}
}
/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does).
fn float_background(l: &mut Layer) {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
}
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
let locks = l.locks.union(group);
if locks.all || locks.pixels || (position && locks.position) {
@@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool,
let canvas = doc.bounds();
let selection = doc.selection.clone();
let group = crate::transform_cmds::group_locks(doc, id);
// A Background gains transparency when the distortion moves pixels (as in Photoshop).
if moves {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
}
if moves {
float_background(l);
}
check_locks(l, group, moves)?;
let LayerContent::Raster(surf) = &mut l.content else {
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
+7 -6
View File
@@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
let mut copies = scratch_doc(&d.doc);
for id in ids {
let mut l = d.doc.layer(*id)?.clone();
if crate::extra_cmds::is_background(&l) {
if d.doc.is_background(*id) {
l.name = "Layer 0".into();
l.locks = Default::default();
}
@@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
/// transparency, so there the area is filled with the background colour instead (Photoshop).
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let bg = doc.is_background(id);
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
if bg {
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
@@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
}
let lowest = visible[0];
let base = doc.layers[lowest].clone();
let is_background = base.name == "Background" && base.locks.transparency;
let is_background = lowest == 0 && doc.is_background(base.id);
// Composite of visible layers only (hidden ones stay where they are).
let mut solo = doc.clone();
solo.layers.retain(|l| l.visible);
@@ -1363,7 +1364,7 @@ mod tests {
let mut s = fresh();
s.execute(cmd, json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
}
@@ -1609,9 +1610,9 @@ mod tests {
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), 4);
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
assert!(!crate::extra_cmds::is_background(pasted));
assert!(!st.doc.is_background(pasted.id));
assert_eq!(pasted.locks, Default::default());
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
}
/// #2428: a smart object copied in one document pastes into another as a smart object, in
+5 -16
View File
@@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
}
fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
/// Photoshop converts the Background to a normal layer before erasing to transparency.
fn unlock_background(l: &mut Layer) {
if is_background(l) {
l.name = "Layer 0".into();
l.locks.transparency = false;
l.locks.position = false;
}
}
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
let locks = doc.effective_locks(l.id);
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
@@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
let bg = s.tools.background;
let dmg = s.edit("Magic Eraser", |doc, _| {
let selection = doc.selection.clone();
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
// Photoshop converts the Background to a normal layer before erasing to transparency.
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
crate::extra_cmds::background_to_layer(doc, id);
let lock = doc.effective_locks(id).transparency;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
@@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = pixels_locked(&d.doc, l) {
return Err(e);
}
if d.doc.effective_locks(id).transparency && !is_background(l) {
if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) {
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
}
}
let stroke = Stroke { brush, points: pts };
let dmg = s.edit("Background Eraser", |doc, _| {
let selection = doc.selection.clone();
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
unlock_background(l);
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
})?;
+11 -8
View File
@@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
}
pub(crate) fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
fn has_background(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
}
/// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with
@@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
return Ok(ids);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
}
@@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect {
fn layer_from_background(s: &mut Session) -> Result<Value> {
let id = s.edit("Layer From Background", |doc, active| {
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
unlock_background(l);
*active = Some(l.id);
Ok(l.id)
@@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) {
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
background_to_layer(doc, id);
}
/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns
/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't
/// take (moving, transforming, exposing transparency, a style). Other layers are left alone.
pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.background_mut().filter(|l| l.id == id) {
unlock_background(l);
}
}
+3
View File
@@ -285,6 +285,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
let save = save.into();
let mut opts =
photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() };
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
// lossy or lossless as it was), as Photoshop's Save does.
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
if let Some(q) = save.quality {
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
opts.encode.jpeg_quality = q;
+2 -2
View File
@@ -58,7 +58,7 @@ impl CutParts {
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
let mut rest = if mask { surf.clone() } else { full.clone() };
let mut piece = Surface::new(with_alpha);
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
let fill = !copy && (mask || doc.is_background(layer));
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
if !b.is_empty() {
@@ -165,7 +165,7 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
/// floating? The Background is position-locked, but its selected pixels move (Photoshop).
pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
let locks = doc.effective_locks(l.id);
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
locks.all || (locks.position && !doc.is_background(l.id))
}
fn float(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -137,7 +137,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
let gid = match content {
Some(id) => {
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) {
if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) {
return Err(bad("only a pixel layer (not the Background) can go in a frame".into()));
}
let gid = doc.insert_above(Some(id), Layer::group(name, vec![]));
+3 -6
View File
@@ -298,7 +298,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
// A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area
// an extending Crop adds is filled with the extension (background) colour.
if extends
&& let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l))
&& let Some(bg) = doc.background_mut()
&& let LayerContent::Raster(surf) = &mut bg.content
{
let old = old_canvas.translate(dx, dy);
@@ -379,16 +379,13 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
// A shrink or no-op resize must not silently convert the Background.
if ext[3] == 0.0
&& (nw > ow as u32 || nh > oh as u32)
&& let Some(bg) = doc.layers.first_mut()
&& crate::extra_cmds::is_background(bg)
&& let Some(bg) = doc.background_mut()
{
crate::extra_cmds::unlock_background(bg);
}
// The locked Background layer is extended with the extension colour.
if ext[3] > 0.0
&& let Some(bg) = doc.layers.first_mut()
&& bg.name == "Background"
&& bg.locks.transparency
&& let Some(bg) = doc.background_mut()
&& let LayerContent::Raster(surf) = &mut bg.content
{
let fmt = surf.format();
+1 -1
View File
@@ -163,7 +163,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
let mut l = src_layer.duplicate();
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
if sdoc.is_background(*id) {
l.name = ddoc.next_layer_name("Layer");
l.locks = Default::default();
}
+89 -8
View File
@@ -44,7 +44,7 @@ pub(crate) fn mask_targets(s: &Session, p: &serde_json::Value) -> Result<Vec<Lay
return Ok(ids);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !crate::extra_cmds::is_background(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !d.doc.is_background(l.id))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take a layer mask".into()));
}
@@ -163,13 +163,9 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::<Vec<_>>()}))
}
pub(crate) fn is_locked_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_))
}
fn select_all_layers(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect();
if ids.is_empty() {
return Err(EngineError::Other("there are no layers to select".into()));
}
@@ -283,7 +279,14 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) ->
Ok(())
}
/// Does a move aimed at the layer mask (`"target":"mask"`) move `l`'s mask alone? Only an
/// unlinked mask does (the chain off); a linked one moves with its layer, as in Photoshop.
pub fn moves_mask_alone(l: &Layer) -> bool {
l.mask.as_ref().is_some_and(|m| !m.linked)
}
/// `layer.translate`: the explicit layer, or every selected layer, plus their linked layers.
/// With `"target":"mask"` a layer whose mask is unlinked moves that mask alone.
pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
let dx = int_i32("layer.translate", p, "dx")?.unwrap_or(0);
let dy = int_i32("layer.translate", p, "dy")?.unwrap_or(0);
@@ -297,14 +300,27 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
if roots.is_empty() {
return Err(EngineError::Other("no active layer".into()));
}
let mask_target = p.get("target").and_then(Value::as_str) == Some("mask");
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let ids = s.edit("Move", |doc, _| {
let ids = move_targets(doc, &roots);
if ids.is_empty() {
let (alone, rest): (Vec<LayerId>, Vec<LayerId>) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone));
let mut ids = move_targets(doc, &rest);
if ids.is_empty() && alone.is_empty() {
return Err(EngineError::NoLayer(roots[0]));
}
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
move_layers(doc, &moves)?;
for id in alone {
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
}
if let Some(m) = &mut l.mask {
m.surface = shift_surface(&m.surface, dx, dy);
}
ids.push(id);
}
// A board moved past any canvas edge grows the canvas, as a new one does (#1531, #3070).
if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) {
crate::artboard_cmds::fit_canvas(doc);
@@ -326,6 +342,11 @@ pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
/// copying the moving pixels only.
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
moved_target(doc, ids, dx, dy, false)
}
/// [`moved`] for a drag aimed at the layer mask (`mask_target`): unlinked masks move alone.
pub fn moved_target(doc: &Document, ids: &[LayerId], dx: i32, dy: i32, mask_target: bool) -> Result<Document> {
let mut out = doc.clone();
for &id in ids {
let locks = doc.effective_locks(id);
@@ -333,6 +354,12 @@ pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Docume
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
}
if mask_target && moves_mask_alone(l) {
if let Some(m) = &mut l.mask {
m.surface = shift_surface(&m.surface, dx, dy);
}
continue;
}
shift_shown(doc, l, dx, dy);
}
Ok(out)
@@ -597,6 +624,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
slices: _,
variables: _,
timeline: _,
file_encoding: _,
} = a;
*size == b.size
&& resolution_dpi.to_bits() == b.resolution_dpi.to_bits()
@@ -1443,6 +1471,59 @@ mod tests {
s.active().unwrap().last_damage
}
/// A mask with its chain off stays put while the layer moves; a linked one moves along.
#[test]
fn translate_leaves_an_unlinked_mask_in_place() {
for depth in [8, 16] {
let mut s = session(depth);
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
s.edit("mask", |doc, _| {
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
Ok(())
})
.unwrap();
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
assert!(mval(&s, 14, 14) > 0.9, "{depth}-bit: unlinked mask stays");
assert!(mval(&s, 44, 14) < 0.1);
s.execute("edit.undo", json!({})).unwrap();
s.execute("layer.layerMask.linked", json!({"linked": true})).unwrap();
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
assert!(mval(&s, 44, 14) > 0.9, "{depth}-bit: linked mask moves");
}
}
/// Aimed at the mask, a move shifts an unlinked mask alone and leaves a linked one with its layer.
#[test]
fn translate_with_the_mask_targeted_moves_an_unlinked_mask_alone() {
for depth in [8, 16] {
let mut s = session(depth);
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
s.edit("mask", |doc, _| {
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
Ok(())
})
.unwrap();
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
// Linked: the layer and its mask move together.
s.execute("layer.translate", json!({"dx": 30, "dy": 0, "target": "mask"})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit");
assert!(mval(&s, 44, 14) > 0.9);
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
s.execute("layer.translate", json!({"dx": -30, "dy": 0, "target": "mask"})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit: the layer stays");
assert!(mval(&s, 14, 14) > 0.9, "the mask moved");
assert!(mval(&s, 44, 14) < 0.1);
s.execute("edit.undo", json!({})).unwrap();
assert!(mval(&s, 44, 14) > 0.9, "undo puts the mask back");
}
}
#[test]
fn moves_and_layer_props_report_only_their_area() {
let mut s = session(8);
+2 -1
View File
@@ -287,6 +287,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
let canvas = doc.bounds();
let selection = doc.selection.clone();
let locks = doc.effective_locks(id);
let background = doc.is_background(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
@@ -296,7 +297,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
// A Background layer gains transparency when the edge mode exposes it (as in Photoshop).
if float_bg && l.locks.position && l.name == "Background" {
if float_bg && background {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
+2 -1
View File
@@ -91,8 +91,9 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
let canvas = doc.bounds();
let fmt = doc.pixel_format();
let dpi = doc.resolution_dpi;
let background_id = doc.background().map(|l| l.id);
for l in doc.layers.iter_mut() {
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
let background = Some(l.id) == background_id;
if background && let Some(surf) = l.surface_mut() {
// The Background stays a Background: rotated pixels over the background colour.
let src = surf.content_bounds();
+7 -5
View File
@@ -14,7 +14,7 @@
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -154,12 +154,13 @@ fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result<
}
/// Maps every layer's pixels and masks through `h` into `clip`.
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> {
/// `background_id` is the id of the document's Background, if it has one.
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32], background_id: Option<LayerId>) -> Result<()> {
for l in layers {
crate::allocation::checkpoint("a perspective crop")?;
let background = crate::extra_cmds::is_background(l);
let background = background_id == Some(l.id);
match &mut l.content {
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?,
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg, None)?,
LayerContent::Raster(surf) => {
let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?;
*surf = if background {
@@ -188,7 +189,8 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32)
let (w, ht) = size;
let clip = Rect::from_xywh(0, 0, w, ht);
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg);
warp_layers(&mut doc.layers, h, interp, clip, &bg)?;
let background_id = doc.background().map(|l| l.id);
warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?;
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = warp_gray(&ch.surface, h, interp, clip)?;
}
+1 -1
View File
@@ -126,7 +126,7 @@ pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
let canvas = doc.bounds();
for (path, _, l) in doc.walk().iter().rev() {
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|| crate::layer_multi_cmds::is_locked_background(l)
|| doc.is_background(l.id)
|| doc.effective_locks(l.id).all
|| !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))
{
+2 -6
View File
@@ -191,13 +191,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mode = doc.mode;
let effects: Vec<_> = effects.iter().cloned().map(|e| e.in_mode(mode)).collect();
for id in &ids {
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
// Styles can't sit on the Background: Photoshop turns it into a normal layer first.
if l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, photocraft_doc::LayerContent::Raster(_)) {
l.name = "Layer 0".into();
l.locks.transparency = false;
l.locks.position = false;
}
crate::extra_cmds::background_to_layer(doc, *id);
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
if add {
l.effects.items.extend(effects.iter().cloned());
} else {
+1 -1
View File
@@ -50,7 +50,7 @@ fn has_layer_for_slice(s: &Session) -> std::result::Result<(), String> {
if matches!(l.content, LayerContent::Adjustment(_)) {
return Err("adjustment layers have no bounds to slice".into());
}
if d.doc.layers.first().is_some_and(|b| b.id == id && b.name == "Background" && b.locks.transparency && b.locks.position) {
if d.doc.is_background(id) {
return Err("the Background layer can't have a layer-based slice".into());
}
if d.doc.slices.list.iter().any(|s| s.layer == Some(id)) {
+1 -1
View File
@@ -666,7 +666,7 @@ fn any_layer(l: &Layer, f: &dyn Fn(&Layer) -> bool) -> bool {
/// blend mode, clipping and label stay on the smart layer. The cache is the exact composite, so
/// the document looks the same before and after.
pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result<Layer> {
let background = l.name == "Background" && l.locks.position;
let background = doc.is_background(l.id);
let mut inner = l.clone();
inner.visible = true;
inner.opacity = 1.0;
+2 -1
View File
@@ -32,9 +32,10 @@ fn contents(s: &Session, doc: &Document, sm: &SmartObject, intent: Intent, bpc:
if src.depth != doc.depth {
crate::image_cmds::convert_layers_depth(&mut src.layers, doc.depth);
}
let background_id = src.background().map(|l| l.id);
let mut layers = std::mem::take(&mut src.layers);
for l in &mut layers {
if crate::extra_cmds::is_background(l) {
if Some(l.id) == background_id {
l.name = "Layer 0".into();
l.locks = Default::default();
}
+12 -15
View File
@@ -201,10 +201,10 @@ pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks {
}
/// The locks a transform of `l` meets: its own and `group`'s (those it inherits). The Background's
/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]).
fn transform_locks(l: &Layer, group: Locks) -> Locks {
/// position lock doesn't count: Photoshop turns it into a normal layer first.
fn transform_locks(l: &Layer, group: Locks, background: bool) -> Locks {
let mut own = l.locks;
if own.position && l.name == "Background" {
if background {
own.position = false;
own.transparency = false;
}
@@ -217,29 +217,24 @@ fn transform_locks(l: &Layer, group: Locks) -> Locks {
pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
let Some(l) = doc.layer(id) else { return false };
// An explicit stack rather than recursion: documents can nest deeply.
let mut stack = vec![(l, group_locks(doc, id))];
while let Some((l, group)) = stack.pop() {
let locks = transform_locks(l, group);
let mut stack = vec![(l, group_locks(doc, id), doc.is_background(id))];
while let Some((l, group, background)) = stack.pop() {
let locks = transform_locks(l, group, background);
if locks.position || locks.all {
return true;
}
if let LayerContent::Group(g) = &l.content {
stack.extend(g.children.iter().map(|c| (c, locks)));
stack.extend(g.children.iter().map(|c| (c, locks, false)));
}
}
false
}
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]).
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
crate::allocation::checkpoint("transforming pixels")?;
let locks = transform_locks(l, group);
// Photoshop turns the Background into a normal layer before transforming it.
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
let locks = transform_locks(l, group, false);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
@@ -493,6 +488,8 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = group_locks(doc, id);
// Photoshop turns the Background into a normal layer before transforming it.
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?;
if !h.is_identity() {
+42 -2
View File
@@ -121,10 +121,23 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
Ok(())
}
/// The PostScript name of the installed face of `family` at exactly `weight`/`italic`. A PSD names
/// a type layer by PostScript name, and Photoshop treats a name no font has as missing (#3556).
/// Only an exact face is named: layout picks a named face before weight and italic, so naming a
/// nearby face would override the requested weight (a variable font's axis, a weight between two
/// faces) or italic. Without an exact face the style keeps no name and layout matches as before.
fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option<String> {
let mut shared = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let faces = shared.fonts.faces(family);
let face = faces.iter().find(|f| f.italic == italic && f.weight.round() as u16 == weight)?;
face.postscript_name.clone()
}
/// Applies character-style keys from JSON. Returns true if any key was present.
pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
let mut any = false;
let mut hit = |b: bool| any |= b;
let font_key = ["font", "family", "fontStyle", "weight", "italic"].iter().any(|k| p.get(k).is_some());
if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) {
s.font_family = v.to_string();
s.postscript_name = None;
@@ -169,6 +182,11 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
s.postscript_name = Some(v.to_string());
hit(true);
}
// A font named by family (or a style that matched no face) has no PostScript name yet: fill in
// the installed face's real name, so the PSD does not invent "<Family>-Regular" (#3556).
if font_key && s.postscript_name.is_none() {
s.postscript_name = installed_postscript(&s.font_family, s.weight, s.italic);
}
if let Some(v) = f32p(p, "size") {
s.size_pt = v.max(0.01);
hit(true);
@@ -989,14 +1007,36 @@ mod tests {
assert_eq!(style.weight, 600);
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
apply_char_props(&mut style, &json!({"weight": 400}));
assert!(style.postscript_name.is_none());
// #3556: a style change re-resolves to the installed face instead of leaving it nameless.
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
assert!(style.font_style.is_empty());
apply_char_props(&mut style, &json!({"fontStyle": "unknown-style"}));
assert!(style.postscript_name.is_none());
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
apply_char_props(&mut style, &json!({"fontStyle": "Regular", "postscriptName": "ExplicitFace"}));
assert_eq!(style.postscript_name.as_deref(), Some("ExplicitFace"));
}
/// #3556: a font named by family stores the installed face's PostScript name, so PSD export
/// names a real font instead of inventing "<Family>-Regular" that Photoshop calls missing.
#[test]
fn a_family_font_gets_the_installed_postscript_name() {
let mut style = CharStyle { font_family: "Inter".into(), ..Default::default() };
apply_char_props(&mut style, &json!({"font": "Inter"}));
assert_eq!(style.font_family, "Inter");
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"), "resolved from the font database");
// A weight picks that face's name.
apply_char_props(&mut style, &json!({"font": "Inter", "weight": 600}));
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
// A weight no face has (a variable font's axis, or between two faces) names no face, so
// layout still matches the requested weight instead of a nearby face's.
apply_char_props(&mut style, &json!({"weight": 650}));
assert!(style.postscript_name.is_none(), "{:?}", style.postscript_name);
// A family PhotoCraft does not have keeps no name; the export falls back as before.
let mut absent = CharStyle { font_family: "No Such Family 123".into(), ..Default::default() };
apply_char_props(&mut absent, &json!({"font": "No Such Family 123"}));
assert!(absent.postscript_name.is_none());
}
/// Picking a style or weight resets the weight axis to it; other axes keep their values (#2985).
#[test]
fn a_style_or_weight_resets_the_weight_axis() {
+3 -5
View File
@@ -110,12 +110,9 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
Ok(w)
}
/// A Background is turned into a normal layer by the caller first
/// ([`crate::extra_cmds::background_to_layer`]).
fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
let locks = l.locks.union(group);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
@@ -270,6 +267,7 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let sel = doc.selection.clone();
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = crate::transform_cmds::group_locks(doc, id);
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?;
let snapshot = doc.clone();
@@ -0,0 +1,89 @@
//! #2399: a save writes a JPEG or WebP back the way it was opened. A JPEG keeps the quality
//! estimated from its quantisation tables, a lossy WebP stays lossy and a lossless one lossless,
//! unless the save asks for a quality; the remembered settings survive a .pcraft round trip.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, WebpEncoding, jpeg_quality, webp_encoding};
use photocraft_engine::Session;
use photocraft_engine::file_cmds::open_bytes_as;
use serde_json::json;
fn photo() -> Image {
let (w, h) = (48u32, 32u32);
let px = (0..w * h).flat_map(|i| {
let (x, y) = (i % w, i / w);
[(x * 5) as u8, (y * 7) as u8, ((x * y) % 251) as u8]
});
Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, px.collect()).unwrap()
}
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("photocraft-2399-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
/// Opens `bytes` as `name`, saves a copy to `out` with `params`, and returns the saved file.
fn open_and_save(name: &str, bytes: &[u8], out: &str, params: serde_json::Value) -> (Session, Vec<u8>) {
let mut s = Session::new();
open_bytes_as(&mut s, name, bytes, None, None).unwrap();
let path = tmp(out);
let mut p = params;
p["path"] = json!(path.to_string_lossy());
s.execute("file.saveACopy", p).unwrap();
let saved = std::fs::read(&path).unwrap();
(s, saved)
}
#[test]
fn a_jpeg_saves_at_its_own_quality() {
for q in [35u8, 60, 77, 95] {
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
let (s, saved) = open_and_save("in.jpg", &src, &format!("q{q}.jpg"), json!({}));
let remembered = s.active().unwrap().doc.file_encoding.jpeg_quality.unwrap();
assert!(remembered.abs_diff(q) <= 2, "opened at {q}, remembered {remembered}");
let est = jpeg_quality(&saved).unwrap();
assert!(est.abs_diff(q) <= 2, "opened at {q}, saved at {est}");
}
// A quality the save asks for still wins (Photoshop's 0–12 scale: 12 is the top).
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 40, ..Default::default() }).unwrap();
let (_, saved) = open_and_save("in.jpg", &src, "explicit.jpg", json!({"quality": 12}));
assert_eq!(jpeg_quality(&saved), Some(100));
}
#[test]
fn a_webp_stays_lossy_or_lossless() {
let lossy = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
let (s, saved) = open_and_save("in.webp", &lossy, "lossy.webp", json!({}));
let e = s.active().unwrap().doc.file_encoding;
assert_eq!(e.webp_lossless, Some(false));
let Some(WebpEncoding::Lossy { quality: Some(q) }) = webp_encoding(&saved) else { panic!("saved {:?}", webp_encoding(&saved)) };
assert!(q.abs_diff(60) <= 2, "lossy WebP at 60 saved at {q}");
let lossless = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions::default()).unwrap();
let (s, saved) = open_and_save("in.webp", &lossless, "lossless.webp", json!({}));
assert_eq!(s.active().unwrap().doc.file_encoding.webp_lossless, Some(true));
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
assert_eq!(photocraft_codecs::decode(&saved).unwrap().data(), photocraft_codecs::decode(&lossless).unwrap().data(), "lossless round trip");
}
#[test]
fn a_new_document_and_a_png_save_at_the_defaults() {
let png = photocraft_codecs::encode(&photo(), Format::Png, &EncodeOptions::default()).unwrap();
let (s, saved) = open_and_save("in.png", &png, "from-png.jpg", json!({}));
assert!(s.active().unwrap().doc.file_encoding.is_empty());
assert_eq!(jpeg_quality(&saved), Some(EncodeOptions::default().jpeg_quality));
let (_, saved) = open_and_save("in.png", &png, "from-png.webp", json!({}));
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
}
#[test]
fn the_remembered_encoding_survives_pcraft() {
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
let mut s = Session::new();
open_bytes_as(&mut s, "in.jpg", &src, None, None).unwrap();
let doc = &s.active().unwrap().doc;
let bytes = photocraft_format::save_to_bytes(doc, &photocraft_format::SaveOptions::default()).unwrap();
let back = photocraft_format::load_from_bytes(&bytes).unwrap();
assert_eq!(back.file_encoding, doc.file_encoding);
assert!(back.file_encoding.jpeg_quality.is_some());
}
+2
View File
@@ -253,6 +253,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM {
duotone: d.duotone.clone(),
variables: d.variables.clone(),
timeline: d.timeline.clone(),
file_encoding: d.file_encoding,
layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(),
last_applied_comp: d.last_applied_comp,
last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)),
@@ -631,6 +632,7 @@ impl Loader<'_> {
duotone: m.duotone.clone(),
variables: m.variables.clone(),
timeline: m.timeline.clone(),
file_encoding: m.file_encoding,
layer_comps,
last_applied_comp: m.last_applied_comp,
last_document_state,
+3
View File
@@ -84,6 +84,9 @@ pub struct DocM {
/// Window › Timeline.
#[serde(default)]
pub timeline: Option<photocraft_doc::Timeline>,
/// The JPEG quality / WebP encoding File › Save reuses.
#[serde(default, skip_serializing_if = "photocraft_doc::FileEncoding::is_empty")]
pub file_encoding: photocraft_doc::FileEncoding,
/// Image › Analysis: measurement scale, count groups, ruler.
#[serde(default)]
pub measurement: photocraft_doc::Measurement,
+26 -8
View File
@@ -1178,7 +1178,7 @@ fn fs_mfinish(in: VOut) -> @location(0) vec4<f32> {
}
// Chiselled bevel height from A = dist_inside, B = dist_outside (`effects::bevel_chisel_h`):
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size).
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size, Chisel Soft).
@fragment
fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
@@ -1186,16 +1186,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
let dout = textureLoad(tex_b, p, 0).r;
let size = op.p0.y;
var h: f32;
// The ramp spans size + 1 px, from half a pixel beyond the edge (`effects::bevel_chisel_h`).
let ramp = size + 1.0;
let soft = op.p0.z > 0.5;
switch i32(op.p0.x) {
case 0: { h = 1.0 - clamp(dout / size, 0.0, 1.0); }
case 1: {
if (din > 0.0) {
h = 0.5 + 0.5 * clamp(din / (size / 2.0), 0.0, 1.0);
case 0: {
if (soft) {
h = select((size + 2.0) / ramp, 1.0 - clamp((dout + 0.5) / ramp, 0.0, 1.0), dout > 0.0);
} else {
h = 0.5 - 0.5 * clamp(dout / (size / 2.0), 0.0, 1.0);
let top = (size + 2.0) / ramp;
h = select(top, clamp((size + 1.5 - dout) / ramp, 0.0, top), dout > 0.0);
}
}
case 1: {
let half = size / 2.0 + 0.5;
if (din > 0.0) {
h = 0.5 + 0.5 * clamp(din / half, 0.0, 1.0);
} else {
h = 0.5 - 0.5 * clamp(dout / half, 0.0, 1.0);
}
}
default: {
if (soft) {
h = select(0.0, clamp((din + 1.5) / ramp, 0.0, (size + 2.0) / ramp), din > 0.0);
} else {
h = select(0.0, clamp((din + 0.5) / ramp, 0.0, 1.0), din > 0.0);
}
}
default: { h = clamp(din / size, 0.0, 1.0); }
}
return mout(h);
}
@@ -1209,7 +1226,8 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
// effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a
// 4-neighbour's).
var hmax = ra(p);
if (op.p1.y > 1.5) {
// p3.x: an outer chisel bevel, which also reaches one pixel past its ramp.
if (op.p1.y > 1.5 || op.p3.x > 0.5) {
hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1))));
}
let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5);
+5 -2
View File
@@ -376,8 +376,10 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
} else {
let din = b.field(FieldKind::BevelInside, size);
let dout = b.field(FieldKind::BevelOutside, size);
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, 0.0, 0.0], 0));
if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
let soft = g.chisel_soft > 0.0;
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0));
// As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp.
if soft && g.paint == BevelPaint::Both { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
};
if let Some(c) = &bv.contour {
// Contour element: the height through the contour (`bevel_contour_lut`).
@@ -418,6 +420,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
for which in [0.0, 1.0] {
let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1);
s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))];
s.p3 = [f32::from(u8::from(g.paint == BevelPaint::Outer && bv.technique != BevelTechnique::Smooth)), 0.0, 0.0, 0.0];
s.lut = lut.clone();
s.s = Some(In::Shape);
s.out = Some(out);
+107 -6
View File
@@ -267,7 +267,10 @@ fn be32(d: &[u8], at: usize) -> Option<u32> {
/// key), reverse, dither, [method], name, colour stops (location /4096, midpoint %, colour
/// space + four u16 components, 2 bytes), transparency stops, then smoothness (/4096) among
/// noise-gradient fields. Smoothness, midpoints and the method are baked into dense stops like
/// gradient fills. Noise gradients (no colour stops) and non-RGB stops stay unsupported.
/// gradient fills. Stops may be in any plain colour space Photoshop writes (RGB, HSB, CMYK, Lab,
/// Grayscale: a CMYK or Lab document stores them in its own space); they are converted to RGB
/// through the colour-managed conversions (the document's CMYK profile when one is in scope).
/// Noise gradients (no colour stops) and stops in other spaces (book colours) stay unsupported.
fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
let version = be16(d, 0)?;
let reverse = *d.get(2)? != 0;
@@ -285,11 +288,9 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
for _ in 0..n {
let loc = (be32(d, at)? as f32 / 4096.0).clamp(0.0, 1.0);
let mid = be32(d, at + 4)? as f32 / 100.0;
if be16(d, at + 8)? != 0 {
return None;
}
let c = |k: usize| be16(d, at + 10 + 2 * k).map(|v| f32::from(v) / 65535.0);
stops.push((loc, photocraft_color::Color::rgb(c(0)?, c(1)?, c(2)?)));
let space = be16(d, at + 8)?;
let c = |k: usize| be16(d, at + 10 + 2 * k);
stops.push((loc, crate::channel_map::decode_known_color(space, [c(0)?, c(1)?, c(2)?, c(3)?])?));
mids.push(mid);
at += 20;
}
@@ -309,6 +310,23 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
Some(Adjustment::GradientMap { stops, reverse, dither })
}
/// Points an imported `grdm` block at `target` when only Reverse and/or Dither differ (the
/// stops parse unchanged): patches those two bytes and returns `true`, so the file's own stops
/// (in whatever colour space it stored them), smoothness and method survive the save. `false`
/// leaves `data` untouched.
pub(crate) fn retarget_gradient_map(data: &mut [u8], target: &Adjustment, channels: Channels) -> bool {
let Adjustment::GradientMap { stops, reverse, dither } = target else { return false };
let same_stops = matches!(parse(b"grdm", data, None, channels), Adjustment::GradientMap { stops: s, .. } if s == *stops);
match (same_stops, data.get_mut(2..4)) {
(true, Some(flags)) => {
flags[0] = u8::from(*reverse);
flags[1] = u8::from(*dither);
true
}
_ => false,
}
}
/// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so
/// the stops are reproduced exactly).
fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec<u8> {
@@ -824,6 +842,89 @@ mod tests {
assert_eq!(write(&a)[0].1, block);
}
/// A two-stop `grdm` (version 1) whose stops are the colour structures `a` and `b`.
fn grdm_with(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
let mut v = write_gradient_map(&[(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false);
// Header 4 + name length 4 + "Custom\0" 14 + stop count 2; 20 bytes per stop.
for (i, (space, c)) in [a, b].into_iter().enumerate() {
let at = 24 + 20 * i + 8;
v[at..at + 2].copy_from_slice(&space.to_be_bytes());
for (k, x) in c.iter().enumerate() {
v[at + 2 + 2 * k..at + 4 + 2 * k].copy_from_slice(&x.to_be_bytes());
}
}
v
}
fn ends(a: &Adjustment) -> ([f32; 3], [f32; 3]) {
match a {
Adjustment::GradientMap { stops, .. } => (stops[0].1, stops[stops.len() - 1].1),
other => panic!("{other:?}"),
}
}
#[test]
fn gradient_map_stops_in_every_colour_space() {
use photocraft_color::{Color, ColorMode};
let white_rgb = (0, [65535, 65535, 65535, 0]);
// CMYK, stored inverted: 65535 = no ink. Full K only.
let k_only = grdm_with((2, [65535, 65535, 65535, 0]), white_rgb);
let expected = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }.to_rgb();
for channels in [Channels::Cmyk, Channels::Rgb, Channels::Lab] {
let a = parse(b"grdm", &k_only, None, channels);
let (first, last) = ends(&a);
assert!(first.iter().zip(expected).all(|(x, y)| (x - y).abs() < 1e-5), "{first:?} vs {expected:?}");
assert!(first[0] < 0.4 && (first[0] - first[2]).abs() < 0.05, "K-only black is a dark neutral: {first:?}");
assert_eq!(last, [1.0; 3]);
}
// Lab: L 50, a 0, b 0 is a neutral mid grey; L 100 white.
let lab = parse(b"grdm", &grdm_with((7, [5000, 0, 0, 0]), (7, [10000, 0, 0, 0])), None, Channels::Lab);
let (first, last) = ends(&lab);
assert!(first.iter().all(|v| (v - 0.466).abs() < 0.01), "{first:?}");
assert!(last.iter().all(|v| *v > 0.99), "{last:?}");
// Lab with chroma: a = +60 is reddish (a and b are signed hundredths).
let red = parse(b"grdm", &grdm_with((7, [5000, 6000, 0, 0]), white_rgb), None, Channels::Lab);
let (first, _) = ends(&red);
assert!(first[0] > first[1] + 0.2, "{first:?}");
// Grayscale: 0..10000 is ink coverage (10000 = black).
let gray = parse(b"grdm", &grdm_with((8, [10000, 0, 0, 0]), (8, [0, 0, 0, 0])), None, Channels::Gray);
assert_eq!(ends(&gray), ([0.0; 3], [1.0; 3]));
// HSB: hue 240 degrees (2/3 of 65535), full saturation and brightness = blue.
let hsb = parse(b"grdm", &grdm_with((1, [43690, 65535, 65535, 0]), white_rgb), None, Channels::Rgb);
let (first, _) = ends(&hsb);
assert!(first[0] < 1e-3 && first[1] < 1e-3 && (first[2] - 1.0).abs() < 1e-3, "{first:?}");
// Book colours and unknown ids are not guessed: the block stays raw.
for space in [3u16, 9, 0xFFFF] {
let d = grdm_with((space, [1, 2, 3, 4]), white_rgb);
assert!(matches!(parse(b"grdm", &d, None, Channels::Rgb), Adjustment::Unsupported { .. }), "space {space}");
}
// Truncated stops never panic.
for n in 0..k_only.len() {
let _ = parse(b"grdm", &k_only[..n], None, Channels::Cmyk);
}
}
#[test]
fn gradient_map_reverse_and_dither_keep_the_stops_block() {
let original = grdm_with((2, [65535, 65535, 65535, 0]), (7, [10000, 0, 0, 0]));
let Adjustment::GradientMap { stops, .. } = parse(b"grdm", &original, None, Channels::Cmyk) else { panic!() };
let mut data = original.clone();
let target = Adjustment::GradientMap { stops: stops.clone(), reverse: true, dither: true };
assert!(retarget_gradient_map(&mut data, &target, Channels::Cmyk));
assert_eq!(&data[2..4], &[1, 1]);
assert_eq!(data[4..], original[4..], "the stops keep their colour spaces");
assert_eq!(parse(b"grdm", &data, None, Channels::Cmyk), target);
// Edited stops are not patched in.
let mut edited = stops;
edited[0].1 = [0.5; 3];
let mut data2 = original.clone();
let target = Adjustment::GradientMap { stops: edited, reverse: false, dither: false };
assert!(!retarget_gradient_map(&mut data2, &target, Channels::Cmyk));
assert_eq!(data2, original);
assert!(!retarget_gradient_map(&mut [], &target, Channels::Cmyk));
assert!(!retarget_gradient_map(&mut data2, &Adjustment::Invert, Channels::Cmyk));
}
fn rt(a: Adjustment) {
let blocks = write(&a);
assert!(!blocks.is_empty(), "{a:?}");
+1
View File
@@ -151,6 +151,7 @@ impl Builder {
bg.prune();
let mut l = Layer::new("Background", LayerContent::Raster(bg));
l.locks.transparency = true;
l.locks.position = true;
layers.insert(0, l);
}
self.doc.layers = layers;
+43 -2
View File
@@ -15,6 +15,7 @@ pub(crate) const DISPLAY_INFO_OLD: u16 = 1007;
pub(crate) const QUICK_MASK_INFO: u16 = 1022;
const SPACE_RGB: u16 = 0;
const SPACE_HSB: u16 = 1;
const SPACE_CMYK: u16 = 2;
const SPACE_LAB: u16 = 7;
const SPACE_GRAY: u16 = 8;
@@ -66,9 +67,25 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, chan
}
}
/// A PSD colour structure (space id + four u16 components) as an RGB [`Color`]; unknown spaces
/// (book colours…) read the first three components as RGB. See [`decode_known_color`].
pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
let n = |v: u16| f32::from(v) / 65535.0;
match space {
decode_known_color(space, c).unwrap_or_else(|| Color::rgb(n(c[0]), n(c[1]), n(c[2])))
}
/// A PSD colour structure in one of the spaces Photoshop writes for plain colours (0 RGB,
/// 1 HSB, 2 CMYK, 7 Lab, 8 Grayscale) as an RGB [`Color`], or `None` for any other space id.
/// CMYK goes through the colour-managed conversion (the active document CMYK profile, see
/// `photocraft_color::convert::with_cmyk_space`), Lab through the D50 Lab → sRGB conversion.
pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option<Color> {
let n = |v: u16| f32::from(v) / 65535.0;
Some(match space {
SPACE_RGB => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
SPACE_HSB => {
let rgb = hsb_to_rgb(n(c[0]) * 360.0, n(c[1]), n(c[2]));
Color::rgb(rgb[0], rgb[1], rgb[2])
}
// Photoshop colour structures store CMYK inverted (65535 = no ink).
SPACE_CMYK => {
let cmyk = Color { mode: ColorMode::Cmyk, c: [1.0 - n(c[0]), 1.0 - n(c[1]), 1.0 - n(c[2]), 1.0 - n(c[3])], alpha: 1.0 };
@@ -87,7 +104,23 @@ pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
let v = 1.0 - f32::from(c[0].min(10000)) / 10000.0;
Color::rgb(v, v, v)
}
_ => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
_ => return None,
})
}
/// HSB (hue in degrees, saturation and brightness 0..1) → RGB.
fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 };
let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
let f = h - h.floor();
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
match h.floor() as u8 {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
_ => [v, p, q],
}
}
@@ -133,5 +166,13 @@ mod tests {
assert_eq!(g.to_rgb(), [0.0; 3]);
let lab = decode_color(SPACE_LAB, [10000, 0, 0, 0]).to_rgb();
assert!(lab.iter().all(|v| *v > 0.95), "{lab:?}");
// HSB: hue 120 degrees (65535 / 3), full saturation and brightness is pure green.
let g = decode_color(SPACE_HSB, [21845, 65535, 65535, 0]).to_rgb();
assert!((g[0]).abs() < 1e-3 && (g[1] - 1.0).abs() < 1e-3 && g[2].abs() < 1e-3, "{g:?}");
let half = decode_color(SPACE_HSB, [0, 0, 32768, 0]).to_rgb();
assert!(half.iter().all(|v| (v - 0.5).abs() < 1e-3), "{half:?}");
// Unknown spaces (book colours) are not guessed.
assert_eq!(decode_known_color(3, [1, 2, 3, 4]), None);
assert_eq!(decode_known_color(SPACE_RGB, [65535, 0, 0, 0]), Some(Color::rgb(1.0, 0.0, 0.0)));
}
}
+3
View File
@@ -376,6 +376,9 @@ mod tests {
});
d.last_applied_comp = Some(7);
let res = write_comps_resource(&d).unwrap();
// `list` is a length-4 string id, as Photoshop writes it, never a code.
assert!(res.windows(8).any(|w| w == b"\0\0\0\x04list"));
assert!(!res.windows(8).any(|w| w == b"\0\0\0\0list"));
let cm = write_cmls(&d.layer_comps, None, d.layer(lid).unwrap(), 3).unwrap();
// Attach as Photoshop would: shmd with cmls plus another item.
let mut raw = vec![(*b"shmd", write_shmd(&[MetadataItem::new(*b"cust", vec![1, 2])]))];
+40
View File
@@ -21,6 +21,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
}
let img = codecs::decode(bytes)?;
let mut r = image_to_document(name, &img)?;
r.document.file_encoding = file_encoding(bytes);
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
tga_alpha_to_channel(&mut r.document)?;
}
@@ -50,6 +51,45 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document
Ok(n)
}
/// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality
/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its
/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`].
pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding {
let mut e = photocraft_doc::FileEncoding::default();
match codecs::detect(bytes) {
Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes),
Some(Format::WebP) => match codecs::webp_encoding(bytes) {
Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true),
Some(codecs::WebpEncoding::Lossy { quality }) => {
e.webp_lossless = Some(false);
e.webp_quality = Some(quality.unwrap_or(DEFAULT_LOSSY_WEBP_QUALITY));
}
None => {}
},
_ => {}
}
e
}
/// The quality a lossy WebP is saved at when its own can't be read.
pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75;
/// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a
/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or
/// lossless.
pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) {
let e = &doc.file_encoding;
if let Some(q) = e.jpeg_quality {
opts.jpeg_quality = q.clamp(1, 100);
}
if let Some(lossless) = e.webp_lossless {
opts.webp_lossless = lossless;
}
if let Some(q) = e.webp_quality {
opts.webp_quality = q.min(100);
}
}
/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the
/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225;
/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel
+4 -1
View File
@@ -64,7 +64,10 @@ use photocraft_doc::Document;
use photocraft_psd::{PsdError, PsdFile};
pub use adjust_map::ADJUSTMENT_KEYS;
pub use flat::{document_to_image, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits};
pub use flat::{
DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency,
tga_default_bits,
};
pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with};
pub use psd_import::{psd_to_document, psd_to_document_with};
+6
View File
@@ -529,6 +529,12 @@ impl Ex {
let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0;
!legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a
});
// A Gradient Map whose stops are unchanged keeps the file's own block (stops in
// the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither
// updated in place.
let keep = keep
|| (matches!(a, photocraft_doc::Adjustment::GradientMap { .. })
&& raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels)));
if !keep {
raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd");
let w = adjust_map::write(a);
+14 -1
View File
@@ -702,7 +702,20 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
// Capped like `build` (#2940): a group past the document's nesting cap keeps no children,
// so no file can make this tree, or anything walking or dropping it, recurse deeper.
let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH);
doc.layers = cx.build(&tree, 0);
// Colours stored in the document's CMYK space (Gradient Map stops…) convert through
// its own profile, as the compositor and the PSD writer read them.
let space = photocraft_compose::cmyk_space(&doc);
doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0));
// Photoshop's Background: the bottom record, a transparency-protected pixel layer stored
// without a transparency channel (-1). It is also position-locked, whatever its
// (localized) name and whether or not the file carries an `lspf` block.
if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut())
&& rec.flags.transparency_protected()
&& !rec.channels.iter().any(|c| c.id == -1)
&& matches!(bottom.content, LayerContent::Raster(_))
{
bottom.locks.position = true;
}
// Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked).
if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) =
file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed)
+22
View File
@@ -817,6 +817,28 @@ pub fn uuid_from(seed: &[u8]) -> String {
mod tests {
use super::*;
fn count(hay: &[u8], needle: &[u8]) -> usize {
hay.windows(needle.len()).filter(|w| *w == needle).count()
}
/// A freshly built placed layer writes `warp` (key and class) as a length-4 string, as
/// Photoshop does; the code form is a different key to Photoshop.
#[test]
fn fresh_placed_warp_is_a_string_id() {
let spec =
PlacedSpec { idnt: "id", placed: "id", transform: Affine::IDENTITY, perspective: None, size: (10.0, 10.0), dpi: 72.0, warp: None, filter_fx: None };
let mut warnings = Vec::new();
let sold = sold_bytes(None, &spec, &mut warnings);
let plld = plld_bytes(&spec, &mut warnings);
// `SoLd`: the `warp` key and the warp descriptor's class; `PlLd`: the descriptor's class.
for (bytes, n) in [(&sold, 2), (&plld, 1)] {
assert_eq!(count(bytes, b"\0\0\0\x04warp"), n);
assert_eq!(count(bytes, b"\0\0\0\0warp"), 0);
}
let d = sold_descriptor(&sold).unwrap();
assert!(matches!(d.get("warp"), Some(Value::Descriptor(w)) if w.class_id == Id::Str(b"warp".to_vec())));
}
fn sf(command: &str, params: J) -> SmartFilter {
SmartFilter { command: command.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true }
}
+100
View File
@@ -179,3 +179,103 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() {
}
}
}
/// A two-stop version 1 `grdm` block whose stops are the PSD colour structures `a` and `b`.
fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
let mut v = vec![0, 1, 0, 0];
v.extend_from_slice(&1u32.to_be_bytes()); // name: just the terminator
v.extend_from_slice(&[0, 0]);
v.extend_from_slice(&2u16.to_be_bytes());
for (t, (space, c)) in [(0u32, a), (4096, b)] {
v.extend_from_slice(&t.to_be_bytes());
v.extend_from_slice(&50u32.to_be_bytes());
v.extend_from_slice(&space.to_be_bytes());
for x in c {
v.extend_from_slice(&x.to_be_bytes());
}
v.extend_from_slice(&[0, 0]);
}
v.extend_from_slice(&2u16.to_be_bytes());
for t in [0u32, 4096] {
v.extend_from_slice(&t.to_be_bytes());
v.extend_from_slice(&50u32.to_be_bytes());
v.extend_from_slice(&255u16.to_be_bytes());
}
v.extend_from_slice(&2u16.to_be_bytes()); // expansion count
v.extend_from_slice(&0u16.to_be_bytes()); // smoothness
v
}
fn saved_grdm(bytes: &[u8]) -> Vec<u8> {
let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap();
f.layers().iter().find_map(|l| l.block(b"grdm")).map(|b| b.data.clone()).expect("grdm block")
}
#[test]
fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() {
use photocraft_cms::{Intent, Transform, synth};
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
let profile = Profile::parse(&profile.to_bytes()).unwrap();
let to_srgb = Transform::new(&profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap();
// First stop 60 % cyan + 40 % black (stored inverted), second paper white.
let cmyk_stop = (2u16, [65535 - 39321, 65535, 65535, 65535 - 26214]);
let paper = (2u16, [65535; 4]);
let lab_dark = (7u16, [2000, 0, 0, 0]);
let lab_red = (7u16, [5000, 6000u16, 2000, 0]);
let gray_black = (8u16, [10000, 0, 0, 0]);
let gray_white = (8u16, [0, 0, 0, 0]);
for depth in SampleType::ALL {
for (mode, stops) in [(ColorMode::Cmyk, (cmyk_stop, paper)), (ColorMode::Lab, (lab_dark, lab_red)), (ColorMode::Grayscale, (gray_white, gray_black))] {
let block = grdm_block(stops.0, stops.1);
let mut doc = Document::new("map", photocraft_geom::Size::new(4, 2), mode, depth);
if mode == ColorMode::Cmyk {
doc.icc_profile = Some(profile.to_bytes());
}
let fmt = doc.pixel_format();
doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false));
doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() })));
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
let mut back = import("x.psd", &bytes).unwrap().document;
let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else {
panic!("{mode:?} {depth:?}: {:?}", back.layers[1].content);
};
let (first, last) = (got[0].1, got[got.len() - 1].1);
match mode {
ColorMode::Cmyk => {
// Through the document's own profile, not the built-in coated CMYK.
let mut expected = [0.0f32; 3];
to_srgb.eval_fast(&[0.6, 0.0, 0.0, 0.4], &mut expected);
assert!(first.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-4), "{depth:?}: {first:?} vs {expected:?}");
let builtin = photocraft_color::convert::cmyk_to_rgb([0.6, 0.0, 0.0, 0.4]);
assert!(first.iter().zip(builtin).any(|(a, b)| (a - b).abs() > 1e-3), "the document profile is used");
}
ColorMode::Lab => {
assert!(first.iter().all(|v| (v - first[0]).abs() < 1e-3) && first[0] < 0.3, "{first:?}");
assert!(last[0] > last[1] + 0.2, "a = +60 is reddish: {last:?}");
}
// An inverting map, white to black.
_ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])),
}
// The layer now renders: the composite differs from the background alone.
let with_map = photocraft_compose::flatten(&back).px;
let mut hidden = back.clone();
hidden.layers[1].visible = false;
assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}");
// Saving again keeps the block (and so the stops' colour space) byte for byte...
let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
assert_eq!(saved_grdm(&again), block, "{mode:?} {depth:?}");
// ...and so does toggling Reverse and Dither, which only flip their flags.
if let LayerContent::Adjustment(Adjustment::GradientMap { reverse, dither, .. }) = &mut back.layers[1].content {
*reverse = true;
*dither = true;
}
let flipped_file = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
let flipped = saved_grdm(&flipped_file);
assert_eq!(&flipped[2..4], &[1, 1]);
assert_eq!(flipped[4..], block[4..], "{mode:?} {depth:?}: stops keep their colour space");
let reopened = import("x.psd", &flipped_file).unwrap().document;
assert_eq!(reopened.layers[1].content, back.layers[1].content);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
//! A PSD's Background is recognised by its structure (the bottom record, transparency-protected,
//! stored without a transparency channel), whatever its name in the Photoshop language that
//! saved the file.
use photocraft_io::psd_to_document;
use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen};
/// `testgen::layered` with its bottom layer turned into a Photoshop Background named `name`.
fn file(name: &str, transparency_channel: bool) -> PsdFile {
let mut f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
let bottom = f.layers_mut().first_mut().unwrap();
bottom.name = name.bytes().filter(u8::is_ascii).collect();
bottom.blocks.retain(|b| &b.key != b"luni" && &b.key != b"lspf");
bottom.blocks.push(TaggedBlock::unicode_name(name));
bottom.flags.set_transparency_protected(true);
if !transparency_channel {
bottom.channels.retain(|c| c.id != -1);
}
f
}
#[test]
fn a_localized_background_is_the_background() {
for name in ["Hintergrund", "背景", "Arrière-plan"] {
let (d, _) = psd_to_document(&file(name, false));
let bg = d.background().unwrap_or_else(|| panic!("{name}: no Background"));
assert_eq!(bg.name, name);
assert!(bg.locks.transparency && bg.locks.position, "{name}");
assert_eq!(bg.id, d.layers[0].id);
}
}
#[test]
fn a_protected_layer_with_transparency_is_not_the_background() {
// Lock Transparent Pixels on an ordinary bottom layer: it keeps its transparency channel.
let (d, _) = psd_to_document(&file("Background", true));
assert!(d.layers[0].locks.transparency);
assert!(d.background().is_none());
}
+85
View File
@@ -857,6 +857,91 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
assert!(r.tail_preview().is_none());
}
#[test]
fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk() {
// Symmetric painting previews the stroke and its mirrors with `composite_union_live`, which
// redraws only changed tiles; after every chunk the target must be exactly what finishing
// every pass and merging them with `composite_union_many` gives (the commit takes these pixels).
let base = BrushSettings { size: 14.0, hardness: 0.6, opacity: 0.6, ..brush() };
let mut brushes = vec![base.clone()];
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..base.clone() });
let mut b = base.clone();
b.dual_brush.enabled = true;
b.dual_brush.size = 6.0;
b.dual_brush.scatter = 1.5;
brushes.push(b);
let mut b = base.clone();
b.color_dynamics.enabled = true;
b.color_dynamics.hue_jitter = 0.5;
brushes.push(b);
brushes.push(BrushSettings { wet_edges: true, ..base.clone() });
brushes.push(BrushSettings { mode: BlendMode::Dissolve, ..base.clone() });
brushes.push(BrushSettings { erase: true, ..base.clone() });
let mut b = base.clone();
b.texture.enabled = true;
b.texture.each_tip = false;
brushes.push(b);
let stroke: Vec<StrokePoint> = (0..40).map(|i| StrokePoint::new(30.0 + i as f64 * 3.7, 40.0 + 25.0 * (i as f64 / 5.0).sin(), 1.0)).collect();
// Mirrors about x = 100 and y = 75; the first point sits near the vertical axis so passes overlap.
let mirror = |fx: bool, fy: bool| -> Vec<StrokePoint> {
stroke.iter().map(|p| StrokePoint::new(if fx { 200.0 - p.x } else { p.x }, if fy { 150.0 - p.y } else { p.y }, p.pressure)).collect()
};
let paths = [stroke.clone(), mirror(true, false), mirror(false, true), mirror(true, true)];
let area = Rect::new(-20, -20, 240, 190);
let mut sel = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
let sel_rect = Rect::new(0, 0, 160, 120);
sel.write_region(sel_rect, &(0..160 * 120).map(|i| ((i % 160) as f32 / 120.0).min(1.0)).collect::<Vec<_>>());
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
let mut pre = Surface::new(fmt);
let fill = Rect::new(10, 10, 190, 140);
let n = fmt.channels();
let px: Vec<f32> =
(0..fill.width() * fill.height()).flat_map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.8].into_iter().take(n)).collect();
pre.write_region(fill, &px);
for (bi, b) in brushes.iter().enumerate() {
// (passes included at each chunk, selection): two passes from the start; four, of
// which two join part-way.
for (four, selection) in [(false, None), (true, None), (true, Some(&sel))] {
let mut renderers: Vec<StrokeRenderer> = (0..4).map(|_| StrokeRenderer::new(b, Some(fmt), 1.0)).collect();
let mut live = crate::render::UnionLive::default();
let mut target = pre.clone();
let (mut at, mut chunk, mut merged) = (0, 1, 0);
while at < stroke.len() {
let end = (at + chunk).min(stroke.len());
for (r, path) in renderers.iter_mut().zip(&paths) {
r.push(&path[at..end]);
}
let count = if four && at >= 12 { 4 } else { 2 };
let full = merged != count;
merged = count;
let mut passes: Vec<&mut StrokeRenderer> = renderers.iter_mut().take(count).collect();
StrokeRenderer::composite_union_live(&mut passes, &mut live, full, &pre, &mut target, selection, false);
let finished: Vec<StrokeRenderer> = renderers
.iter()
.take(count)
.map(|r| {
let mut r = r.clone();
r.finish();
r
})
.collect();
let mut expected = pre.clone();
finished[0].composite_union_many(&finished[1..], &pre, &mut expected, selection, false);
assert!(
target.read_region(area) == expected.read_region(area),
"{sample:?}, brush {bi}, four {four}, selection {}, points ..{end}",
selection.is_some()
);
assert_eq!(live.bounds(), finished.iter().fold(Rect::EMPTY, |r, f| r.union(&f.bounds())));
at = end;
chunk = chunk % 3 + 1;
}
}
}
}
}
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
+1 -1
View File
@@ -37,7 +37,7 @@ pub use brush::{
SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer,
};
pub use mixer::MixerSettings;
pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke};
pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke};
pub use tile::{GrayTile, StoredTile};
/// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation.
+121
View File
@@ -491,6 +491,13 @@ impl CoverageMap {
v
}
/// The finished coverage over the tiles `keys`: `tail`'s copy of a tile where finishing the
/// stroke changes it (see [`StrokeRenderer::tail_preview`]), else this map's own.
fn finished_subset(&self, tail: Option<&Self>, keys: &HashSet<(i32, i32)>) -> Self {
let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(k)).or_else(|| self.tiles.get(k)).map(|t| (*k, t.clone()));
Self { tiles: keys.iter().filter_map(tile).collect(), nc: self.nc, bounds: tail.map_or(self.bounds, |t| t.bounds), dirty: HashSet::new() }
}
/// Union two passes of the same brush stroke without applying opacity twice where they
/// overlap. The stronger coverage wins, including its per-dab colour when present.
fn union_max(&mut self, other: &Self) {
@@ -527,6 +534,21 @@ impl CoverageMap {
// Stroke renderer
// ---------------------------------------------------------------------------
/// What [`StrokeRenderer::composite_union_live`] drew last: the coverage tiles of the passes'
/// smoothing tails (redrawn without them next time) and the merged bounds.
#[derive(Clone, Debug, Default)]
pub struct UnionLive {
tail_keys: HashSet<(i32, i32)>,
bounds: Rect,
}
impl UnionLive {
/// The merged bounds of the finished passes, as last drawn.
pub fn bounds(&self) -> Rect {
self.bounds
}
}
/// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels.
#[derive(Clone, Debug)]
pub struct StrokeRenderer {
@@ -570,6 +592,105 @@ impl StrokeRenderer {
}
merged.composite(pre, target, selection, lock_transparency, true)
}
/// Coverage tiles (stroke and Dual Brush) changed since the last call or composite, into
/// `keys`; forgets them.
fn drain_dirty_into(&mut self, keys: &mut HashSet<(i32, i32)>) {
keys.extend(self.cov.dirty.drain());
if let Some(d) = self.dual.as_mut() {
keys.extend(d.dirty.drain());
}
}
/// Every coverage tile (stroke and Dual Brush), into `keys`.
fn keys_into(&self, keys: &mut HashSet<(i32, i32)>) {
keys.extend(self.cov.tiles.keys());
if let Some(d) = &self.dual {
keys.extend(d.tiles.keys());
}
}
/// Live preview of a stroke painted as several passes (the stroke, then its symmetry
/// mirrors): leaves `target` as finishing every pass now and compositing them with
/// [`composite_union_many`](Self::composite_union_many) over `pre` would, but redraws only the
/// coverage tiles that changed since the previous call (new dabs, and the smoothing tail now
/// and before). `full` redraws every tile: on the first call, or when a pass joins. Returns
/// the damaged rectangle.
pub fn composite_union_live(
passes: &mut [&mut Self],
live: &mut UnionLive,
full: bool,
pre: &Surface,
target: &mut Surface,
selection: Option<&Surface>,
lock_transparency: bool,
) -> Rect {
let mut keys = std::mem::take(&mut live.tail_keys);
for p in passes.iter_mut() {
p.drain_dirty_into(&mut keys);
if full {
p.keys_into(&mut keys);
}
}
let tails: Vec<_> = passes.iter().map(|p| p.tail_preview()).collect();
let mut tail_keys = HashSet::new();
for t in tails.iter().flatten() {
t.keys_into(&mut tail_keys);
}
keys.extend(&tail_keys);
let mut merged: Option<Self> = None;
for (p, t) in passes.iter().zip(&tails) {
let cov = p.cov.finished_subset(t.as_ref().map(|t| &t.cov), &keys);
let dual = p.dual.as_ref().map(|d| d.finished_subset(t.as_ref().and_then(|t| t.dual.as_ref()), &keys));
match merged.as_mut() {
None => {
merged = Some(Self {
ctx: p.ctx.clone(),
generator: p.generator.clone(),
cov,
dual,
fmt: p.fmt,
per_dab_color: p.per_dab_color,
dabs_done: p.dabs_done,
scratch: Vec::new(),
depth_scratch: Vec::new(),
dab_buf: Vec::new(),
dual_buf: Vec::new(),
all_dabs: None,
})
}
Some(m) => {
m.cov.union_max(&cov);
if let (Some(to), Some(from)) = (&mut m.dual, &dual) {
to.union_max(from);
}
}
}
}
let Some(mut merged) = merged else { return Rect::EMPTY };
let (before, after) = (live.bounds, merged.bounds());
// Back to the pre-stroke pixels where the previous call drew and nothing covers now.
let mut keys: Vec<_> = keys.into_iter().collect();
keys.sort_unstable();
let mut dmg = Rect::EMPTY;
let mut region = Vec::new();
for &(tx, ty) in &keys {
let tile = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE);
let old = tile.intersect(&before);
if !old.is_empty() {
pre.read_region_into(old, &mut region);
target.write_region(old, &region);
dmg = dmg.union(&old);
}
if merged.cov.tiles.contains_key(&(tx, ty)) {
dmg = dmg.union(&tile.intersect(&after));
}
}
merged.composite(pre, target, selection, lock_transparency, true);
live.tail_keys = tail_keys;
live.bounds = after;
dmg
}
/// `fmt` = target pixel format (needed for per-dab colour); `zoom` for smoothing.
pub fn new(brush: &BrushSettings, fmt: Option<PixelFormat>, zoom: f32) -> Self {
let per_dab_color = brush.color_dynamics.enabled && !brush.erase && fmt.is_some();
+39 -2
View File
@@ -44,6 +44,10 @@ impl UnicodeString {
}
}
/// Four-character class and key ids Photoshop writes as length-4 strings, never as codes
/// (`00000004 'warp'`, not `00000000 'warp'`).
pub const STRING_IDS: [&str; 3] = ["warp", "list", "time"];
/// A class / key / type identifier: a length-prefixed string, where a stored
/// length of 0 means a 4-character code follows.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -57,10 +61,15 @@ pub enum Id {
impl Id {
/// Convenience constructor: 4-byte strings become [`Id::Code`], others
/// [`Id::Str`].
/// [`Id::Str`]. The four-character names Photoshop stores as length-4 strings
/// ([`STRING_IDS`]: `warp`, `list`, `time`) are [`Id::Str`] too: Photoshop treats the
/// string and the code as different keys and doesn't recognise the code form.
pub fn new(s: &str) -> Self {
let b = s.as_bytes();
if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) }
match b {
[a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]),
_ => Id::Str(b.to_vec()),
}
}
/// The identifier's bytes.
pub fn as_bytes(&self) -> &[u8] {
@@ -609,6 +618,34 @@ mod tests {
assert_eq!(p.to_bytes(), b);
}
/// `warp`, `list` and `time` are written as length-4 strings, as Photoshop writes them, both
/// as keys and as class ids; other four-character names stay codes. Reading accepts both
/// forms, and a code read from a file is written back as a code.
#[test]
fn photoshop_string_ids_are_written_as_strings() {
let str_id = |s: &str| [&[0, 0, 0, 4][..], s.as_bytes()].concat();
let code_id = |s: &str| [&[0, 0, 0, 0][..], s.as_bytes()].concat();
for name in STRING_IDS {
assert_eq!(Id::new(name), Id::Str(name.as_bytes().to_vec()));
let d = Descriptor::new(name).with(name, Value::Integer(1));
let b = d.to_bytes();
// Name (empty unicode string), then the class id; then the item count and the key.
assert_eq!(&b[4..12], &str_id(name)[..], "class {name}");
assert_eq!(&b[16..24], &str_id(name)[..], "key {name}");
let back = Descriptor::from_bytes(&b).unwrap();
assert_eq!(back, d);
assert!(back.class_id.is(name) && back.get(name).is_some());
// A file that wrote the code form keeps it, and lookups still find the key.
let mut coded = b.clone();
coded.splice(16..24, code_id(name));
let back = Descriptor::from_bytes(&coded).unwrap();
assert_eq!(back.get(name), Some(&Value::Integer(1)));
assert_eq!(back.to_bytes(), coded);
}
assert_eq!(Id::new("view"), Id::Code(*b"view"));
assert_eq!(Id::new("Txt "), Id::Code(*b"Txt "));
}
#[test]
fn versioned_roundtrip() {
let v = VersionedDescriptor::new(sample());
+157 -30
View File
@@ -1762,6 +1762,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let mut close = None;
let mut tab_action = None;
let mut tab_move = None;
let mut tear_off = None;
let (mut focus_open, mut cancel_open) = (None, None);
let focused_open = app.jobs.focus.is_some();
// Layers dragged over a tab show its document (`layer_transfer`).
@@ -1851,18 +1852,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.clicked() {
activate = Some(i);
}
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
tab_drop_line(ui, &placed, p.x, &t);
}
if resp.drag_stopped() {
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
let to = tab_move_target(i, tab_drop_index(&placed, x));
if to != i {
tab_move = Some((i, to));
}
match tab_drag(ui, &resp, i, r, row, &placed, &t) {
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
None => {}
}
let has_path = st.path.is_some();
resp.context_menu(|ui| {
@@ -1925,6 +1918,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
app.ui.status = e;
app.ui.status_error = true;
}
float_torn_off(app, ui.ctx(), tear_off);
if let Some((id, params)) = tab_action
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
{
@@ -1968,6 +1962,70 @@ fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: boo
/// Where a tab dragged to `x` would land: before the first shown tab whose middle is right of
/// `x`, else after the last shown tab (its document index + 1), like [`TabStrip::slot`].
/// How far (points) above or below the tab strip a dragged tab must be to tear off (#3733).
const TEAR_OFF: f32 = 24.0;
/// Whether a tab dragged to `p` has left the strip and floats into its own window, as in
/// Photoshop; sideways drags along the strip still reorder (#217).
fn torn_off(strip: Rect, p: egui::Pos2) -> bool {
p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF
}
/// What a finished document-tab drag asks for.
#[derive(Clone, Copy, Debug, PartialEq)]
enum TabDrop {
/// Reorder the tab to this index.
Move(usize),
/// Float the document in its own OS window at this screen position (points), when known.
Float(Option<[f32; 2]>),
}
/// Drag a document tab: along the strip it reorders (a line shows where it will land); off the
/// strip it tears off into a floating window that can go anywhere, other screens included
/// (an outline under the pointer shows it). Returns the drop once the drag ends.
fn tab_drag(ui: &egui::Ui, resp: &egui::Response, i: usize, r: Rect, strip: Rect, placed: &[(usize, Rect)], t: &crate::theme::Tokens) -> Option<TabDrop> {
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
if torn_off(strip, p) {
let ghost = Rect::from_min_size(p - egui::vec2(40.0, 12.0), egui::vec2(240.0, 160.0));
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.id().with("tab-tear-off")));
painter.rect_filled(ghost, t.radius_sm, t.accent.gamma_multiply(0.12));
painter.rect_stroke(ghost, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
} else {
tab_drop_line(ui, placed, p.x, t);
}
}
if !resp.drag_stopped() {
return None;
}
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos()));
if let Some(p) = p.filter(|&p| torn_off(strip, p)) {
// The pointer is in this window's points; the OS wants screen coordinates. Keep the new
// window's title bar under the pointer, as the ghost showed.
let at = ui.ctx().input(|i| i.viewport().inner_rect).map(|inner| {
let s = inner.min + p.to_vec2() - egui::vec2(40.0, 12.0);
[s.x, s.y]
});
return Some(TabDrop::Float(at));
}
let to = tab_move_target(i, tab_drop_index(placed, p.map_or(r.center().x, |p| p.x)));
(to != i).then_some(TabDrop::Move(to))
}
/// Float a torn-off tab's document in its own window (`window.arrange.floatInWindow`).
fn float_torn_off(app: &mut PhotocraftApp, ctx: &egui::Context, tear_off: Option<(usize, Option<[f32; 2]>)>) {
let Some((doc, at)) = tear_off else { return };
let params = match at {
Some(at) => json!({"document": doc, "position": at}),
None => json!({"document": doc}),
};
if let Err(e) = crate::menus::invoke(app, ctx, "window.arrange.floatInWindow", params) {
app.ui.status = e;
app.ui.status_error = true;
}
}
fn tab_drop_index(placed: &[(usize, Rect)], x: f32) -> usize {
match placed.iter().find(|(_, r)| r.center().x >= x) {
Some(&(i, _)) => i,
@@ -2027,7 +2085,7 @@ const STUDIO_TAB_GAP: f32 = 4.0;
fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String {
let Some(st) = app.session.documents().get(i) else { return String::new() };
let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0);
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency));
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id));
let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some());
let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() };
let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default();
@@ -2051,6 +2109,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let (mut activate, mut close) = (None, None);
let mut tab_action = None;
let mut tab_move = None;
let mut tear_off = None;
let tab_count = app.session.documents().len();
let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx());
let mut drag_over = None;
@@ -2111,18 +2170,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.clicked() {
activate = Some(i);
}
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
tab_drop_line(ui, &placed, p.x, &t);
}
if resp.drag_stopped() {
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
let to = tab_move_target(i, tab_drop_index(&placed, x));
if to != i {
tab_move = Some((i, to));
}
match tab_drag(ui, &resp, i, r, strip, &placed, &t) {
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
None => {}
}
let has_path = app.session.documents().get(i).is_some_and(|d| d.path.is_some());
resp.context_menu(|ui| {
@@ -2179,6 +2230,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
app.ui.status = e;
app.ui.status_error = true;
}
float_torn_off(app, ui.ctx(), tear_off);
if let Some((id, params)) = tab_action
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
{
@@ -3517,6 +3569,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::stroke_constraint::draw_line_preview(app, &painter, &xf, p, tool, held.shift);
}
}
if primary && hand_pan && response.dragged() {
crate::transform_tool::draw_overlay(app, &painter, &xf);
}
app.tool_override = None;
if let Some(b) = layer_edges_rect(app, idx) {
painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside));
@@ -3679,9 +3734,21 @@ fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate
pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) {
let dash = 4.0f32;
let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32;
let white = Stroke::new(1.0, Color32::WHITE);
let black = Stroke::new(1.0, Color32::BLACK);
let clip = painter.clip_rect();
// Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a
// line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen
// whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted
// segment (a transformed outline) stays a line shape, which egui antialiases.
let mut mesh = egui::Mesh::default();
// A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line).
let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| {
let i = mesh.vertices.len() as u32;
for p in [a + side, a - side, b - side, b + side] {
mesh.colored_vertex(p, color);
}
mesh.add_triangle(i, i + 1, i + 2);
mesh.add_triangle(i, i + 2, i + 3);
};
for (a, b) in segs {
let pa = to_screen(*a);
let pb = to_screen(*b);
@@ -3690,12 +3757,19 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
}
let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5);
let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5);
painter.line_segment([pa, pb], white);
let len = pa.distance(pb);
if len < 0.5 {
continue;
}
let dir = (pb - pa) / len;
let side = dir.rot90() * 0.5;
let straight = pa.x == pb.x || pa.y == pb.y;
if straight {
// The white base runs half a pixel past both ends, so joined segments meet at corners.
quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE);
} else {
painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE));
}
// Phase by screen position so dashes line up across joined segments.
let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0);
let mut t = -start;
@@ -3703,11 +3777,18 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
let s0 = t.max(0.0);
let s1 = (t + dash).min(len);
if s1 > s0 {
painter.line_segment([pa + dir * s0, pa + dir * s1], black);
if straight {
quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK);
} else {
painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK));
}
}
t += dash * 2.0;
}
}
if !mesh.is_empty() {
painter.add(egui::Shape::mesh(mesh));
}
}
#[allow(dead_code)]
@@ -5047,7 +5128,10 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some(st) = app.session.documents().get(w.document) else { continue };
let title = format!("{} — window {}", st.doc.name, w.id);
let vid = egui::ViewportId::from_hash_of(("docwin", w.id));
let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
if let Some([x, y]) = w.position {
builder = builder.with_position([x, y]);
}
let mut view = w.view.clone();
let mut close = false;
ctx.show_viewport_immediate(vid, builder, |ui, _class| {
@@ -5704,6 +5788,49 @@ mod tests {
}
}
/// Dragging a tab off the strip floats its document in its own OS window instead of
/// reordering (#3733), in both strips; a short wobble below the strip still reorders.
#[test]
fn dragging_a_tab_off_the_strip_floats_it_in_a_window() {
use egui_kittest::kittest::Queryable;
let strip = Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(700.0, 26.0));
assert!(!torn_off(strip, egui::pos2(50.0, 13.0)));
assert!(!torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF - 1.0)), "a wobble below the strip still reorders");
assert!(torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF + 1.0)));
assert!(torn_off(strip, egui::pos2(50.0, -TEAR_OFF - 1.0)), "dragged up, off the window's top");
for kind in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Pro] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for name in ["a", "b", "c"] {
app.run("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap();
}
app.sync_views();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 300.0)).build_ui_state(
|ui, (app, ready): &mut (PhotocraftApp, bool)| {
if *ready {
tabs(app, ui);
}
},
(app, false),
);
PhotocraftApp::setup_context(&h.ctx, kind);
h.state_mut().1 = true;
h.run_steps(2);
let b = h.get_by_label_contains("b").rect();
let target = egui::pos2(b.center().x, b.bottom() + 150.0);
h.drag_at(b.center());
h.run_steps(1);
h.hover_at(target);
h.run_steps(1);
h.drop_at(target);
h.run_steps(2);
let app = &h.state().0;
let names: Vec<&str> = app.session.documents().iter().map(|d| d.doc.name.as_str()).collect();
assert_eq!(names, ["a", "b", "c"], "{kind:?}: tearing off doesn't reorder");
assert_eq!(app.ui.windows.iter().map(|w| w.document).collect::<Vec<_>>(), [1], "{kind:?}: b floats in its own window");
assert_eq!(app.session.active_index(), Some(1), "{kind:?}: the floated document is active");
}
}
#[test]
fn clone_overlay_is_circular_and_respects_visibility() {
let ctx = egui::Context::default();
+4 -3
View File
@@ -3,15 +3,16 @@
//! Rulers & Grids toggles. Every edit dispatches an engine or view command, so the panel stays thin.
use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use photocraft_doc::{ColorMode, Document, Layer, LayerContent, LayerId, SampleType};
use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{PhotocraftApp, icons, widgets};
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack.
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack,
/// whatever its (localized) name ([`Document::background`]).
pub fn is_background(doc: &Document, l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) && doc.layers.first().is_some_and(|b| b.id == l.id)
doc.is_background(l.id)
}
/// Does the Properties panel show the Document view? (No layer selected, or the Background layer.)
+2 -1
View File
@@ -616,7 +616,8 @@ fn views_and_windows_stay_with_their_documents() {
for (i, v) in app.ui.views.iter_mut().enumerate() {
v.zoom = i as f32 + 1.0;
}
app.ui.windows = (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true }).collect();
app.ui.windows =
(0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true, position: None }).collect();
let zooms = |app: &PhotocraftApp| app.ui.views.iter().map(|v| v.zoom).collect::<Vec<_>>();
let windows = |app: &PhotocraftApp| app.ui.windows.iter().map(|w| (w.id, w.document)).collect::<Vec<_>>();
assert_eq!(app.run("document.move", json!({"document": 0, "to": 3})).unwrap(), json!({"document": 3}));
+16 -4
View File
@@ -1093,12 +1093,12 @@ mod tests {
assert!(!has_dialog("filter.stylize.findEdges"));
}
#[test]
fn korean_covers_generated_filter_options_and_gallery_names() {
let ko = crate::i18n::lang_from_tag("ko-KR").unwrap();
/// Generated filter and adjustment labels (param names, symbolic choices) and gallery
/// names that `lang` has no catalog row for.
fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet<String> {
let mut missing = std::collections::BTreeSet::new();
let mut check = |s: String| {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(ko, &s) {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
missing.insert(s);
}
};
@@ -1125,9 +1125,21 @@ mod tests {
for cat in photocraft_algo::GALLERY_CATEGORIES {
check(cat.to_string());
}
missing
}
#[test]
fn korean_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap());
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
}
#[test]
fn italian_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap());
assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}");
}
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
/// params, choices and title in every UI language, not just Korean.
#[test]
File diff suppressed because it is too large. Load diff
+11 -2
View File
@@ -1284,10 +1284,19 @@ impl PhotocraftApp {
/// What a save writes by default under the preferences: File Handling › Image Previews
/// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox
/// here yet, so it saves the preview, as Photoshop's checkbox starts on.
/// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP
/// is written the way the active document's file was (its JPEG quality, a WebP lossy at
/// its quality or lossless), as Photoshop's Save and Save As options start there.
pub(crate) fn save_settings(&self) -> ExportSettings {
let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never;
ExportSettings { image_previews: previews, ..ExportSettings::default() }
let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() };
if let Some(st) = self.session.active() {
let e = &st.doc.file_encoding;
settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100));
settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless);
settings.webp_quality = e.webp_quality.map(|q| q.min(100));
}
settings
}
/// Whether saving the active document to `path` writes a copy. `tiff_layers` is the TIFF
+1 -1
View File
@@ -343,7 +343,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
let doc = app.session.active_index().ok_or("no document")?;
let wid = app.ui.alloc_id();
let view = app.ui.views[doc].clone();
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true });
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None });
Ok(json!({"window": wid}))
}
"window.theme.toggle" => {
+34 -1
View File
@@ -114,6 +114,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
app.move_preview = None;
return None;
};
let mask_target = app.layer_mask_targeted();
let st = app.session.documents().get(idx)?;
let (doc_id, revision, doc) = (st.doc.id, st.revision, st.doc.clone());
let fresh = app.move_preview.as_ref().is_some_and(|p| p.doc == doc_id && p.revision == revision);
@@ -134,7 +135,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
}
if p.offsets.last() != Some(&offset) {
let t0 = crate::gpu_canvas::now_ms();
match photocraft_engine::layer_multi_cmds::moved(&doc, &p.ids, offset.0, offset.1) {
match photocraft_engine::layer_multi_cmds::moved_target(&doc, &p.ids, offset.0, offset.1, mask_target) {
Ok(d) => {
// Duotone documents display through their inks.
let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d);
@@ -298,6 +299,38 @@ mod tests {
assert_eq!(r, Rect::new(0, 0, 64, 64));
}
/// Dragging with an unlinked mask targeted moves the mask alone, shown live and committed;
/// with the mask linked (or the pixels targeted) the layer moves.
#[test]
fn dragging_with_an_unlinked_mask_targeted_moves_the_mask_alone() {
for (linked, mask_target) in [(false, true), (false, false), (true, true)] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap();
app.sync_views();
app.session.execute("layer.new.layer", json!({})).unwrap();
app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap();
app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.session.execute("select.deselect", json!({})).unwrap();
app.session.execute("layer.layerMask.revealAll", json!({})).unwrap();
app.session.execute("layer.layerMask.linked", json!({"linked": linked})).unwrap();
app.ui.mask_target = mask_target;
app.ui.tool = Tool::Move;
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
let m = egui::Modifiers::NONE;
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 36.0, y: 16.0, pressure: 1.0 }, m);
let shown = super::display_doc(&mut app, 0).unwrap().0;
let id = app.session.active().unwrap().active_layer.unwrap();
let layer_moved = |d: &photocraft_doc::Document| d.layer(id).and_then(|l| l.surface()).is_some_and(|s| s.pixel(28, 16)[3] > 0.5);
let alone = !linked && mask_target;
assert_eq!(layer_moved(&shown), !alone, "preview, linked {linked} mask target {mask_target}");
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 36.0, y: 16.0 }, m);
let d = &app.session.active().unwrap().doc;
assert_eq!(layer_moved(d), !alone, "commit, linked {linked} mask target {mask_target}");
}
}
/// Mid-drag on a painted layer: is the layer shown at the pointer?
fn live(outline: bool) -> bool {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+1 -1
View File
@@ -2685,7 +2685,7 @@ fn layer_row(
let name_color = if l.visible { t.text } else { t.text_faint };
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
let italic = !t.pro && l.name == "Background" && l.locks.transparency;
let italic = !t.pro && doc.is_background(l.id);
// Photoshop rows show only the name; the kind sub-label is a Studio-theme addition.
let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_));
let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| {
+165 -6
View File
@@ -37,15 +37,71 @@ pub struct PresetUi {
pub current_tool_only: bool,
/// Custom Shape tool: the shape it draws.
pub custom_shape: String,
/// Folder/group order per panel, by name (#3658), like the dock's `tabOrder` (#2272). A panel
/// with no entry keeps the library's order; unknown names are ignored and groups missing from
/// a list follow it in library order. Saved with the UI state.
#[serde(rename = "groupOrder", alias = "group_order", deserialize_with = "lenient_group_order", skip_serializing_if = "BTreeMap::is_empty")]
pub group_order: BTreeMap<String, Vec<String>>,
/// In-place rename: (panel, preset or "group:<name>", text).
#[serde(skip)]
pub renaming: Option<(String, String, String)>,
}
/// Read `groupOrder` keeping what makes sense: non-object values, non-list entries and non-string
/// names are dropped instead of failing the whole saved UI state.
const MAX_GROUP_ORDER: usize = 10_000;
fn lenient_group_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
let v = Value::deserialize(d)?;
let mut out = BTreeMap::new();
for (panel, names) in v.as_object().into_iter().flatten() {
let Some(names) = names.as_array() else { continue };
out.insert(panel.clone(), names.iter().filter_map(Value::as_str).take(MAX_GROUP_ORDER).map(str::to_owned).collect());
}
Ok(out)
}
impl PresetUi {
pub fn shape(&self) -> &str {
if self.custom_shape.is_empty() { tl!("Heart") } else { &self.custom_shape }
}
/// Display order of the folders for `panel`, as library indices: the stored order first
/// (names that no longer exist are dropped), then groups missing from it in library order.
/// Mirrors the dock's per-group tab order (#2272, #3658).
pub(crate) fn display_order(&self, panel: &str, names: &[String]) -> Vec<usize> {
let mut out: Vec<usize> = Vec::with_capacity(names.len());
for name in self.group_order.get(panel).into_iter().flatten() {
if let Some(i) = names.iter().position(|n| n == name)
&& !out.contains(&i)
{
out.push(i);
}
}
for i in 0..names.len() {
if !out.contains(&i) {
out.push(i);
}
}
out
}
/// Move folder `from` so it is drawn before `before` (`None` = last) for `panel`. The
/// library's own order is stored as no entry, as the dock does for its tabs.
pub(crate) fn move_group(&mut self, panel: &str, names: &[String], from: &str, before: Option<&str>) {
if before == Some(from) {
return;
}
let mut order: Vec<String> = self.display_order(panel, names).into_iter().filter_map(|i| names.get(i).cloned()).collect();
let Some(at) = order.iter().position(|n| n == from) else { return };
let moved = order.remove(at);
let to = before.and_then(|b| order.iter().position(|n| n == b)).unwrap_or(order.len());
order.insert(to, moved);
if order.as_slice() == names {
self.group_order.remove(panel);
} else {
self.group_order.insert(panel.to_string(), order);
}
}
}
const PANELS: [&str; 6] =
@@ -319,15 +375,21 @@ pub(crate) fn browser(
}
}
}
let mut headers: Vec<(Rect, String)> = Vec::new();
let mut headers: Vec<(usize, Rect, String)> = Vec::new();
let mut dropped: Option<(String, Pos2)> = None;
// Folder drag (#3658): the order the folders are drawn in, and what the pointer is doing this
// frame (the insertion line and the drop are resolved once every header is known).
let mut drag: Option<(usize, Pos2)> = None;
let mut released: Option<(usize, Pos2)> = None;
let group_names: Vec<String> = groups.iter().map(|g| g.name.clone()).collect();
let display = st.display_order(panel, &group_names);
egui::ScrollArea::vertical().id_salt(("preset-scroll", panel)).max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 2.0);
for (gi, g) in groups.iter().enumerate() {
for &gi in &display {
let Some(g) = groups.get(gi) else { continue };
let open = group_open(st, panel, gi, &g.name);
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if hresp.hovered() {
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
if hresp.hovered() || hresp.dragged() {
ui.painter().rect_filled(hr, 2.0, t.hover.gamma_multiply(0.5));
}
let tri = Rect::from_center_size(pos2(hr.left() + 9.0, hr.center().y), vec2(12.0, 12.0));
@@ -351,7 +413,18 @@ pub(crate) fn browser(
ui.close();
}
});
headers.push((hr, g.name.clone()));
if hresp.dragged()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
drag = Some((gi, p));
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
if hresp.drag_stopped()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
released = Some((gi, p));
}
headers.push((gi, hr, g.name.clone()));
if !open {
continue;
}
@@ -434,9 +507,32 @@ pub(crate) fn browser(
});
}
}
// Insertion line while a folder is dragged by its header.
if let Some((from, p)) = drag {
let before = group_drop_before(&headers, from, p.y);
let anchor = match before {
Some(i) => headers.iter().find(|(x, _, _)| *x == i).map(|(_, r, _)| *r),
None => headers.last().map(|(_, r, _)| *r),
};
if let Some(r) = anchor {
let y = if before.is_some() { r.top() - 1.0 } else { r.bottom() + 1.0 };
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
}
}
});
// Apply the drop now every header rect is known; the next frame draws the new order.
if let Some((from, p)) = released {
let before = group_drop_before(&headers, from, p.y);
let from_name = groups.get(from).map(|g| g.name.clone());
let before_name = before.and_then(|i| groups.get(i).map(|g| g.name.clone()));
if let Some(from_name) = from_name {
st.move_group(panel, &group_names, &from_name, before_name.as_deref());
}
}
let recorded: Vec<(String, Rect)> = headers.iter().map(|(_, r, n)| (n.clone(), *r)).collect();
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(("preset-group-headers", panel)), recorded));
if let Some((key, pos)) = dropped {
if let Some((_, g)) = headers.iter().find(|(r, _)| r.contains(pos)) {
if let Some((_, _, g)) = headers.iter().find(|(_, r, _)| r.contains(pos)) {
ev.push(Ev::Move(key, g.clone()));
} else if canvas.contains(pos) {
ev.push(Ev::Drop(key, pos));
@@ -468,6 +564,25 @@ pub(crate) fn browser(
ev
}
/// The folder (by library index) a header dragged to `y` lands before (`None` = last). `headers`
/// is in display order; mirrors `dock::drop_before`.
fn group_drop_before(headers: &[(usize, Rect, String)], from: usize, y: f32) -> Option<usize> {
let hit = headers.iter().find(|(_, r, _)| y < r.center().y).map(|(i, _, _)| *i);
match hit {
Some(i) if i == from => Some(from),
Some(i) => {
let after_self = headers.iter().position(|(x, _, _)| *x == from).zip(headers.iter().position(|(x, _, _)| *x == i)).is_some_and(|(a, b)| b == a + 1);
if after_self { Some(from) } else { Some(i) }
}
None => None,
}
}
/// Folder header rects drawn last frame (name → rect), in display order; for tests and automation.
pub fn last_group_headers(ctx: &egui::Context, panel: &str) -> Vec<(String, Rect)> {
ctx.data(|d| d.get_temp(egui::Id::new(("preset-group-headers", panel)))).unwrap_or_default()
}
/// Shared handling of the generic events through `<prefix>.edit` / `.new`.
fn generic(app: &mut PhotocraftApp, panel: &str, prefix: &str, e: &Ev, new_params: Value) -> bool {
let edit = format!("{prefix}.edit");
@@ -1169,6 +1284,50 @@ mod tests {
assert!(matches!(l.content, photocraft_doc::LayerContent::Shape(_)));
}
/// #3658: a group/folder drag reorders by name, stores only a real reorder, and falls back to
/// the library's order when the saved order matches it or names a group that's gone.
#[test]
fn moving_a_folder_reorders_like_the_dock_tabs() {
let names: Vec<String> = ["Grays", "Pastel", "Pure", "Dark"].iter().map(|s| s.to_string()).collect();
let mut st = PresetUi::default();
assert_eq!(st.display_order("swatches", &names), vec![0, 1, 2, 3]);
st.move_group("swatches", &names, "Pastel", None);
assert_eq!(st.group_order.get("swatches").unwrap(), &["Grays", "Pure", "Dark", "Pastel"]);
st.move_group("swatches", &names, "Pastel", Some("Grays"));
assert_eq!(st.group_order.get("swatches").unwrap(), &["Pastel", "Grays", "Pure", "Dark"]);
assert_eq!(st.display_order("swatches", &names), vec![1, 0, 2, 3]);
// Back to the library's own order: no entry, as the dock stores its default tab order.
st.move_group("swatches", &names, "Pastel", Some("Pure"));
assert!(!st.group_order.contains_key("swatches"), "a no-op order is not stored");
// Out-of-range names, moves onto themselves and unknown `before` names change nothing bad.
for (from, before) in [("Nope", None), ("Grays", Some("Grays"))] {
let mut m = PresetUi::default();
m.move_group("swatches", &names, from, before);
assert!(m.group_order.is_empty(), "{from} {before:?}");
assert_eq!(m.display_order("swatches", &names), vec![0, 1, 2, 3]);
}
// A missing group in a saved order follows the rest in library order.
let mut m = PresetUi::default();
m.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]);
assert_eq!(m.display_order("swatches", &names), vec![3, 0, 1, 2]);
}
/// #3658: the folder order persists with the UI state and reads leniently (an old state has no
/// field, and garbage in one never fails the whole state).
#[test]
fn folder_order_persists_and_reads_leniently() {
let old: PresetUi = serde_json::from_value(json!({"sizes": {"swatches": 22.0}})).unwrap();
assert!(old.group_order.is_empty(), "an older state without the field still loads");
let mut st = PresetUi::default();
st.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]);
let back: PresetUi = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
assert_eq!(back.group_order.get("swatches").unwrap(), &["Dark", "Grays"]);
for bad in [json!("x"), json!(7), json!({"swatches": "paths"}), json!({"swatches": [1, null, {"a": 1}]})] {
let back: PresetUi = serde_json::from_value(json!({"groupOrder": bad})).unwrap();
assert!(back.group_order.get("swatches").is_none_or(Vec::is_empty), "{bad}");
}
}
/// Regression: preset drops mapped the pointer through the raw widget rect, ignoring the
/// rulers' 16 px inset — a dropped shape landed 8 screen px from the pointer, and a drop on
/// the ruler strip counted as a canvas drop. Drops now map through, and target, the same
+68 -11
View File
@@ -268,6 +268,29 @@ fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64) -> f32 {
if vertical { rxf.to_screen(0.0, px).y } else { rxf.to_screen(px, 0.0).x }
}
/// Document pixels per ruler unit and the labelled tick step (in ruler units) of the top ruler
/// (`y_axis` false) or the left one, from Preferences › Units & Rulers and the zoom. Minor ticks
/// sit at tenths of the step.
fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) {
let ur = &app.session.prefs().units_and_rulers;
let (unit, ppi) = (ur.rulers, ur.point_size.per_inch());
let (dpi, size) =
app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64]));
let px_per_unit = unit.to_px(1.0, dpi, if y_axis { size[1] } else { size[0] }, ppi).max(1e-9);
// The top ruler runs along document x, which pixel aspect correction stretches.
let zoom = if y_axis { xf.zoom } else { xf.zoom_x() };
(px_per_unit, nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), unit == Unit::Pixels))
}
/// `pos` (document px) moved to the nearest tick mark the ruler draws: where a guide lands when
/// Shift is held while dragging it out of a ruler, as in Photoshop (#3770).
fn snap_to_tick(app: &PhotocraftApp, xf: &ViewXform, vertical: bool, pos: f64) -> f64 {
// A vertical guide sits at an x position, which the top ruler measures.
let (px_per_unit, step) = ruler_scale(app, xf, !vertical);
let tick = step / 10.0 * px_per_unit;
if tick.is_finite() && tick > 0.0 { (pos / tick).round() * tick } else { pos }
}
/// The number a ruler prints at the tick `v` (in ruler units). Like Photoshop, rulers show the
/// distance from the origin, so values left of / above it read without a minus sign.
fn ruler_label(v: f64, step: f64, whole: bool) -> String {
@@ -292,17 +315,11 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &
let font = egui::FontId::proportional(9.0);
let tick = Stroke::new(1.0, t.text_faint);
// Preferences › Units & Rulers: labels in the ruler unit (percent of the document's side).
let ur = &app.session.prefs().units_and_rulers;
let (unit, ppi) = (ur.rulers, ur.point_size.per_inch());
let (dpi, size) =
app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64]));
let whole = unit == photocraft_engine::prefs::Unit::Pixels;
for (vertical, extent) in [(false, size[0]), (true, size[1])] {
let unit = app.session.prefs().units_and_rulers.rulers;
let whole = unit == Unit::Pixels;
for vertical in [false, true] {
let p = p.with_clip_rect(if vertical { left } else { top });
let px_per_unit = unit.to_px(1.0, dpi, extent, ppi).max(1e-9);
// The top ruler runs along document x, which pixel aspect correction stretches.
let zoom = if vertical { xf.zoom } else { xf.zoom_x() };
let step = nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), whole);
let (px_per_unit, step) = ruler_scale(app, xf, vertical);
let (u0, u1) = if vertical {
ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit)
} else {
@@ -352,7 +369,8 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &
}
if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_started()) {
let d = xf.to_doc(pos);
let pos = crate::snap_ui::snap_guide(app, vertical, if vertical { d[0] } else { d[1] });
let along = if vertical { d[0] } else { d[1] };
let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) };
app.guide_drag = Some(GuideDrag { vertical, index: None, pos });
}
if resp.drag_stopped()
@@ -661,4 +679,43 @@ mod tests {
assert!(u1 - u0 > 0.0, "flip={flip}: range is empty");
}
}
/// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws
/// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped.
#[test]
fn shift_dragged_guides_land_on_ruler_ticks() {
let full = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max);
let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 };
let new_app = || {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 256, "height": 128})).unwrap();
app
};
let drag_guide = |modifiers: egui::Modifiers| {
let builder = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0));
let mut h = builder.build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), new_app());
// Out of the left ruler: a vertical guide, dropped at document x = 37.
let (from, to) = (pos2(RULER / 2.0, 100.0), xf.to_screen(37.0, 64.0));
h.event_modifiers(egui::Event::PointerMoved(from), modifiers);
h.event_modifiers(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }, modifiers);
h.run_steps(1);
h.event_modifiers(egui::Event::PointerMoved(to), modifiers);
h.run_steps(1);
h.event_modifiers(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }, modifiers);
h.run_steps(2);
h.state().session.active().unwrap().doc.guides.vertical.clone()
};
assert_eq!(drag_guide(egui::Modifiers::NONE), vec![37.0]);
// Pixels at 100%: labels every 100 px, ticks every 10 px.
assert_eq!(drag_guide(egui::Modifiers::SHIFT), vec![40.0]);
let mut app = new_app();
// At 200% the labels are every 50 px, so the ticks every 5 px.
assert_eq!(snap_to_tick(&app, &ViewXform { zoom: 2.0, ..xf }, true, 37.0), 35.0);
// Percent of each side: ticks every 2.5% of 256 px across, every 5% of 128 px down.
app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap();
assert!((snap_to_tick(&app, &xf, true, 37.0) - 38.4).abs() < 1e-9);
assert!((snap_to_tick(&app, &xf, false, 37.0) - 38.4).abs() < 1e-9);
}
}
+27
View File
@@ -204,3 +204,30 @@ fn image_previews_preference_reaches_the_exporter() {
assert_eq!(*seen.borrow(), [expect; 3], "{value}");
}
}
/// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality,
/// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults.
#[test]
fn save_settings_start_at_the_opened_files_encoding() {
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
let px: Vec<u8> = (0..32u32 * 24).flat_map(|i| [(i * 3) as u8, (i / 32 * 9) as u8, (i % 97) as u8]).collect();
let img = Image::from_raw(32, 24, ChannelLayout::Rgb, SampleType::U8, px).unwrap();
let mut app = app();
assert_eq!(app.save_settings().jpeg_quality, None);
assert!(app.save_settings().webp_lossless);
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 65, ..Default::default() }).unwrap();
app.open_bytes("photo.jpg", &jpeg).unwrap();
let q = app.save_settings().jpeg_quality.unwrap();
assert!(q.abs_diff(65) <= 2, "estimated {q}");
let lossy = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 80, ..Default::default() }).unwrap();
app.open_bytes("photo.webp", &lossy).unwrap();
let s = app.save_settings();
assert!(!s.webp_lossless);
assert!(s.webp_quality.unwrap().abs_diff(80) <= 2, "{:?}", s.webp_quality);
let lossless = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
app.open_bytes("art.webp", &lossless).unwrap();
assert!(app.save_settings().webp_lossless);
}
+4
View File
@@ -494,6 +494,10 @@ pub struct DocWindow {
pub document: usize,
pub view: View,
pub open: bool,
/// Where the window opens on screen (outer top-left, in points); `None` lets the OS place it.
/// A tab dragged off the tab strip opens its window where it was dropped (#3733).
#[serde(default)]
pub position: Option<[f32; 2]>,
}
/// Options-bar state for tools (Photoshop keeps these per tool).
+36
View File
@@ -286,6 +286,20 @@ mod tests {
h.run_steps(1);
}
/// Drag from `from` to `to` in steps, as when a user drags a group header.
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=6 {
h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// The first swatch: below the first group header (22 px), after the 8 px grid inset.
fn first_cell(h: &Harness<'_, PhotocraftApp>) -> egui::Pos2 {
h.ctx.input(|i| i.viewport_rect()).min + vec2(8.0 + 12.0 + 8.0, 22.0 + 2.0 + 12.0 + 8.0)
@@ -397,4 +411,26 @@ mod tests {
assert_eq!(out[0].0, "out/Swatches.ase");
assert!(out[0].1.starts_with(b"ASEF"));
}
/// #3658: dragging a group header reorders the folders, and the new order sticks in the UI
/// state (UI state is serde, so it saves and reloads with the rest of the panel state).
#[test]
fn dragging_a_group_header_reorders_and_persists() {
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut h = Harness::builder().with_size(vec2(300.0, 4000.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app);
h.run_steps(2);
let headers = crate::preset_panels::last_group_headers(&h.ctx, PANEL);
assert!(headers.len() >= 2, "the default library has folders: {headers:?}");
let names: Vec<String> = headers.iter().map(|(n, _)| n.clone()).collect();
let (first, first_r) = &headers[0];
let (last, last_r) = headers.last().unwrap();
// Drag the last folder's header above the first; the insertion lands before it.
drag(&mut h, last_r.center(), egui::pos2(first_r.center().x, first_r.top() + 1.0));
let order = h.state().ui.presets_ui.display_order(PANEL, &names);
assert_eq!(order.len(), names.len());
assert_eq!(names.get(order[0]).map(String::as_str), Some(last.as_str()), "the dragged folder moved to the front: {order:?}");
assert_eq!(names.get(order[1]).map(String::as_str), Some(first.as_str()));
// The reorder is remembered by name (not by the positions it happened to have).
assert_eq!(h.state().ui.presets_ui.group_order.get(PANEL).and_then(|o| o.first()), Some(last));
}
}
@@ -279,6 +279,32 @@ fn holding_space_still_pans_while_transforming() {
assert_ne!(h.state().ui.views[0].center, center0, "the view panned instead");
}
#[test]
fn transform_preview_stays_visible_while_panning() {
let mut h = harness();
begin(&mut h);
let texture = h.state().transform_preview.as_ref().unwrap().texture.pick(&h.ctx, 1.0, 1.0, false).0;
let has_preview = |h: &Harness<'static, PhotocraftApp>| {
h.output().shapes.iter().any(|clipped| matches!(&clipped.shape, egui::epaint::Shape::Mesh(mesh) if mesh.texture_id == texture))
};
assert!(has_preview(&h), "the transformed pixels are visible before panning");
let center0 = h.state().ui.views[0].center;
let from = screen(&h, [200.0, 160.0]);
let to = from + vec2(50.0, 40.0);
h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerMoved(to));
h.run_steps(1);
assert_ne!(h.state().ui.views[0].center, center0, "the held Hand panned the view");
assert!(has_preview(&h), "the transformed layer stays visible during the pan");
}
/// A transform started by dragging a Move-tool control: that first drag is undoable too.
#[test]
fn the_first_drag_from_the_move_tools_controls_undoes() {
+98
View File
@@ -1783,6 +1783,102 @@ fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnc
resp.on_hover_text(tl!(&tip)).clicked()
}
/// The Character panel's OpenType buttons, in Photoshop's order: (feature tag, command id,
/// tooltip). The tooltips are the Type › OpenType menu labels.
pub(crate) const OPENTYPE_BUTTONS: [(&str, &str, &str); 8] = [
("liga", "type.openType.standardLigatures", "Standard Ligatures"),
("calt", "type.openType.contextualAlternates", "Contextual Alternates"),
("dlig", "type.openType.discretionaryLigatures", "Discretionary Ligatures"),
("swsh", "type.openType.swash", "Swash"),
("salt", "type.openType.stylisticAlternates", "Stylistic Alternates"),
("titl", "type.openType.titlingAlternates", "Titling Alternates"),
("ordn", "type.openType.ordinals", "Ordinals"),
("frac", "type.openType.fractions", "Fractions"),
];
/// Photoshop's OpenType button row: each shows pressed while its feature is on at the type
/// target (the selection start, else the layer) and toggles it through the Type › OpenType
/// command, on the same scope as the other Character controls.
fn opentype_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, c: &photocraft_doc::text::CharStyle, full: f32) {
let cw = cell_width(full, OPENTYPE_BUTTONS.len());
let mut clicked = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (tag, id, tip) in OPENTYPE_BUTTONS {
let on = photocraft_engine::type_extra_cmds::feature_on(c, tag);
if toggle_cell(ui, cw, on, tip, |ui, r, col| opentype_glyph(ui.painter(), r, tag, col)) {
clicked = Some((id, on));
}
}
});
if let Some((id, on)) = clicked {
toggle_opentype(app, id, on);
}
}
/// Runs an OpenType toggle command on the Character panel's scope: the edited layer's selected
/// characters (or its whole text) while typing, else every selected type layer. The new state
/// is explicit, so it follows the button the user saw rather than the layer's first run.
pub(crate) fn toggle_opentype(app: &mut PhotocraftApp, id: &str, shown_on: bool) {
let Some(mut p) = formatting_params(app) else { return };
p["on"] = json!(!shown_on);
let _ = app.run(id, p);
}
/// Small glyph labels for the OpenType buttons (drawn here, no Adobe artwork): fi, st, ct with
/// a ligature arc, a swash A, aa, T, 1st and ½.
fn opentype_glyph(p: &egui::Painter, r: egui::Rect, tag: &str, col: Color32) {
let font = egui::FontId::proportional(13.0);
let centred = |s: &str, font: egui::FontId| {
let g = p.layout_no_wrap(s.to_string(), font, col);
let pos = r.center() - g.size() / 2.0;
let gr = egui::Rect::from_min_size(pos, g.size());
p.galley(pos, g, col);
gr
};
match tag {
"liga" => {
centred("fi", font);
}
"calt" => {
centred("st", font);
}
"dlig" => {
let gr = centred("ct", font);
// The ligature's connecting arc from the c over to the t.
let a = egui::pos2(gr.left() + gr.width() * 0.3, gr.top() + gr.height() * 0.42);
let b = egui::pos2(gr.right() - gr.width() * 0.2, gr.top() + 4.0);
let mid = egui::pos2((a.x + b.x) / 2.0, gr.top() + 1.5);
p.line_segment([a, mid], Stroke::new(1.0, col));
p.line_segment([mid, b], Stroke::new(1.0, col));
}
"swsh" => {
let gr = centred("A", crate::theme::semibold(13.0));
// A swash tail sweeping out under the letter.
let y = gr.bottom() - 3.0;
p.line_segment([egui::pos2(gr.left() - 3.0, y + 1.5), egui::pos2(gr.right() + 2.0, y - 0.5)], Stroke::new(1.0, col));
}
"salt" => {
centred("aa", font);
}
"titl" => {
centred("T", crate::theme::semibold(13.0));
}
"ordn" => {
let one = p.layout_no_wrap("1".into(), font, col);
let st = p.layout_no_wrap("st".into(), egui::FontId::proportional(9.0), col);
let left = r.center().x - (one.size().x + st.size().x) / 2.0;
let top = r.center().y - one.size().y / 2.0;
let st_x = left + one.size().x;
p.galley(egui::pos2(left, top), one, col);
p.galley(egui::pos2(st_x, top - 1.0), st, col);
}
_ => {
centred("½", font);
}
}
}
/// Width of each of `n` equal cells filling `avail` with 2 pt gaps.
fn cell_width(avail: f32, n: usize) -> f32 {
let n = n.max(1) as f32;
@@ -1957,6 +2053,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
}
});
ui.add_space(ROW_GAP);
opentype_row(app, ui, &c, full);
ui.add_space(ROW_GAP);
}
if paragraph && section(ui, "paragraph", tl!("Paragraph")) {
use photocraft_doc::text::TextAlign as A;
+55 -1
View File
@@ -1371,7 +1371,16 @@ mod multi_layer_formatting {
for i in 0..2 {
let mut expected = before[i].clone();
expected.font_family = "JetBrains Mono".into();
expected.postscript_name = None;
// #3556: the new family's installed face at the peer's weight and italic is named (no
// name without such a face), so a PSD names a real font.
expected.postscript_name = photocraft_text::shared()
.lock()
.unwrap()
.fonts
.faces("JetBrains Mono")
.into_iter()
.find(|f| f.italic == expected.italic && f.weight.round() as u16 == expected.weight)
.and_then(|f| f.postscript_name);
assert_eq!(style(h.state(), ids[i], 0), expected, "family changes preserve each peer's face and metrics");
}
assert_eq!(style(h.state(), ids[2], 0), before[2]);
@@ -1455,6 +1464,51 @@ mod multi_layer_formatting {
assert_eq!(text(&app, ids[0]).antialias, AntiAlias::Sharp);
}
/// The Character panel's OpenType buttons (TYPE-218-5, #3752): Photoshop's eight, in its
/// order; each shows pressed while its feature is on and toggles it on the selected type
/// layers, or on the selected characters while typing.
#[test]
fn character_panel_opentype_buttons_toggle_and_show_the_feature() {
use egui::accesskit::Toggled;
use egui_kittest::kittest::NodeT;
use photocraft_engine::type_extra_cmds::feature_on;
let (app, ids) = fixture();
let untouched = style(&app, ids[2], 0);
let mut h = controls(app, true);
let shown = |h: &Harness<'static, PhotocraftApp>, label: &str| h.get_by_label(label).accesskit_node().toggled();
let order: Vec<String> = h
.get_all_by_role(Role::Button)
.filter_map(|n| n.accesskit_node().label())
.filter(|l| super::super::OPENTYPE_BUTTONS.iter().any(|(_, _, tip)| tip == l))
.collect();
let expected: Vec<&str> = super::super::OPENTYPE_BUTTONS.iter().map(|(_, _, tip)| *tip).collect();
assert_eq!(order, expected, "every OpenType button, in Photoshop's order");
assert_eq!(shown(&h, "Standard Ligatures"), Some(Toggled::True), "ligatures are on by default");
assert_eq!(shown(&h, "Fractions"), Some(Toggled::False));
h.get_by_label("Fractions").click();
h.run();
for id in &ids[..2] {
assert!(feature_on(&style(h.state(), *id, 0), "frac"), "selected layers get the feature");
}
assert_eq!(style(h.state(), ids[2], 0), untouched, "an unselected layer is untouched");
h.run();
assert_eq!(shown(&h, "Fractions"), Some(Toggled::True), "the button shows the feature on");
h.get_by_label("Standard Ligatures").click();
h.run();
h.run();
assert!(!style(h.state(), ids[0], 0).ligatures && !style(h.state(), ids[1], 0).ligatures);
assert_eq!(shown(&h, "Standard Ligatures"), Some(Toggled::False));
// While typing, only the selected characters (1..3) change.
editing(h.state_mut(), ids[0]);
h.run();
h.get_by_label("Swash").click();
h.run();
let swash = |h: &Harness<'static, PhotocraftApp>, ci| feature_on(&style(h.state(), ids[0], ci), "swsh");
assert!(!swash(&h, 0) && swash(&h, 1) && swash(&h, 2) && !swash(&h, 3));
h.run();
assert_eq!(shown(&h, "Swash"), Some(Toggled::True), "shown at the selection start");
}
/// Type › Anti-Alias and Type › Orientation check the edited (else selected) layer's value.
#[test]
fn type_menu_checks_the_current_anti_alias_and_orientation() {
+33 -4
View File
@@ -577,7 +577,7 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res
return Ok(json!(k));
}
if let Some(k) = id.strip_prefix("window.arrange.") {
return arrange(app, k);
return arrange(app, k, p);
}
match id {
"view.extras" => {
@@ -643,15 +643,23 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result<Value, String> {
Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]}))
}
/// A finite `[x, y]` screen position within a sane range (window systems reject wild values).
fn screen_position(v: &Value) -> Option<[f32; 2]> {
const LIMIT: f64 = 1.0e6;
let a = v.as_array().filter(|a| a.len() == 2)?;
let n = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= LIMIT).map(|x| x as f32);
Some([n(0)?, n(1)?])
}
pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) -> u64 {
let wid = app.ui.alloc_id();
let mut view = app.ui.views.get(doc).cloned().unwrap_or_default();
view.fit_pending = offset > 0 || view.fit_pending;
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true });
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None });
wid
}
fn arrange(app: &mut PhotocraftApp, k: &str) -> Result<Value, String> {
fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result<Value, String> {
if LAYOUTS.contains(&k) {
app.ui.view.arrange = k.to_string();
// Each document fits its new cell.
@@ -664,10 +672,31 @@ fn arrange(app: &mut PhotocraftApp, k: &str) -> Result<Value, String> {
app.ui.windows.clear();
Ok(json!({"arrange": "tabs"}))
}
"newWindowForDocument" | "floatInWindow" => {
"newWindowForDocument" => {
let doc = app.session.active_index().ok_or("no document")?;
Ok(json!({"window": float_window(app, doc, 0)}))
}
// Optional `document` (index; default the active one) and `position` (`[x, y]`, the
// window's screen position in points): dragging a tab off the tab strip uses both (#3733).
"floatInWindow" => {
let doc = match p.get("document") {
None | Some(Value::Null) => app.session.active_index().ok_or("no document")?,
Some(v) => {
let i = v.as_u64().ok_or("document must be a document index")?;
usize::try_from(i).ok().filter(|&i| i < app.session.documents().len()).ok_or(format!("no document {i}"))?
}
};
let position = match p.get("position") {
None | Some(Value::Null) => None,
Some(v) => Some(screen_position(v).ok_or("position must be [x, y] in points")?),
};
let wid = float_window(app, doc, 0);
if let Some(w) = app.ui.windows.iter_mut().find(|w| w.id == wid) {
w.position = position;
}
app.session.set_active(doc);
Ok(json!({"window": wid}))
}
"floatAllInWindows" | "cascade" => {
let n = app.session.documents().len();
let ids: Vec<u64> = (0..n).map(|d| float_window(app, d, d)).collect();
+26
View File
@@ -292,6 +292,32 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() {
assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3"));
}
/// Float in Window takes the document and the screen position a dragged-off tab was dropped at
/// (#3733); bad params are errors, not panics.
#[test]
fn float_in_window_takes_a_document_and_position() {
let (mut app, ctx) = app_with(3);
let r = menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({"document": 1, "position": [1950.0, 40.5]})).unwrap();
let w = app.ui.windows.last().unwrap();
assert_eq!(r, json!({"window": w.id}));
assert_eq!((w.document, w.position), (1, Some([1950.0, 40.5])));
assert_eq!(app.session.active_index(), Some(1));
menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({})).unwrap();
assert_eq!(app.ui.windows.last().map(|w| (w.document, w.position)), Some((1, None)), "defaults: the active document, placed by the OS");
for bad in [
json!({"document": 3}),
json!({"document": -1}),
json!({"document": "b"}),
json!({"position": [1.0]}),
json!({"position": [1.0, "x"]}),
json!({"position": [1.0e30, 0.0]}),
json!({"position": 5}),
] {
assert!(menu(&mut app, &ctx, "window.arrange.floatInWindow", bad.clone()).is_err(), "{bad}");
}
assert_eq!(app.ui.windows.len(), 2, "rejected params open no window");
}
#[test]
fn save_selection_opens_its_dialog_and_saves_on_confirm() {
// #3713: Select › Save Selection… asks for the channel, name and operation first.
+4 -3
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Save keeps a JPEG's quality and a WebP's lossy/lossless encoding) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -25,11 +25,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) |
| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) |
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) |
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) |
| GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done |
| BMP | R/W | yes | yes | 8-bit |
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP |
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) |
| AVIF | R/W | no (#463) | optional feature, off by default | |
| JPEG XL | R/W | inside DNG only | no | |
| HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit |
@@ -140,5 +140,6 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) |
| 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning |
| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` |
+5 -3
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -76,8 +76,9 @@ what an agent can't finish alone.
### G8. Type depth
- **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script
fallback fonts and golden tests (TYPE-218-3), OpenType panel UI (TYPE-218-5), live Warp Text
preview (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type
fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus
(TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), live Warp Text preview
(TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type
(TYPE-218-9), text free transform (#2630). Done since the re-measure: manual and optical kerning
with PSD round trip (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985).
- **Evidence:** scorecard Type: 3 done, 2 partial, 4 missing. 2026-10-11: 2/3/4/5 clicks select
@@ -227,6 +228,7 @@ what an agent can't finish alone.
| 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open |
| 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open |
| 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted |
| 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing |
| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open |
| 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed |
| 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues |
+62
View File
@@ -0,0 +1,62 @@
# Italian terminology and dynamic UI labels
This note goes with the completion of the Italian catalog (`crates/ui-egui/src/i18n/it.tsv`).
The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked 1,287
labels that only exist at runtime and that other catalogs (Brazilian Portuguese, Dutch, Czech)
already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects,
History, Layers, path and channel menu labels, metadata fields, status messages, System Info and
dialog text. In Italian those showed in English.
They are now translated, and a review pass fixed mistranslations and inconsistent terms in the
existing entries. Translations are written from the meaning of PhotoCraft's English labels; no
proprietary translation resources, fonts, screenshots or user documents are included.
## Register and style
Commands are imperatives in the second person singular (*Elimina*, *Applica*, *Crea
pacchetto*); text addresses the user as *tu*. Only the first word is capitalised, except in
command names joined by a slash, which keep both capitals (*Luminosità/Contrasto*,
*HSL/Scala di grigio*). Decimals use a comma.
## Terminology
| English source | Italian | Note |
|---|---|---|
| Layer | Livello | |
| Canvas | Quadro | also in *Come il quadro*, *Applica ai limiti del quadro* |
| Preset | Predefinito | *Salva come predefinito* |
| Default(s) | Predefinito / Predefiniti | never *default* or *valori predefiniti* |
| Stroke | Traccia | contour and painterly strokes alike (*Tracce angolate*, *Direzione traccia*); a dash stays *Tratto* |
| Sharpen | Contrasta / Contrasto | also in Camera Raw (*Quantità contrasto*) |
| Noise | Disturbo | *Riduzione disturbo di luminanza*, *Riduzione disturbo cromatico* |
| Highlights | Luci | pairs with *Ombre* |
| Snapshot | Istantanea | |
| Smart Filters | Filtri avanzati | |
| Pattern | Pattern | *Timbro pattern* |
| Gradient stops | Punti della sfumatura | |
| Spot color | Tinta piatta | *Canale tinta piatta* |
| Click | clic | *Fai clic*, *{key}-clic*, *doppio clic* |
| Keyboard shortcut | Scorciatoia da tastiera | |
| Footage | Filmato | *Interpreta filmato*, *Sostituisci filmato* |
| Facet | Sfaccetta | |
| SemiBold / ExtraBold / ExtraLight | Semigrassetto / Extragrassetto / Extraleggero | one spelling for every weight |
## Not covered
Engine error and status messages, user document, layer and preset names, and automation
command IDs stay in English, as in every language. A few dynamic labels the other catalogs carry
(`Ask Before Saving Saved Embedded`, `Clear Selection`, the `Heal` family) are not used by the
current code; they are translated to stay aligned with the other catalogs.
## Validation
- `italian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`)
enumerates every filter and adjustment parameter, symbolic choice and gallery name, like the
Korean test, and requires an Italian row for each. With the catalog on `main` before this change
it lists 388 missing labels; now none.
- `cargo test -p photocraft-ui-egui --lib`: the catalog tests pass. Two layout tests,
`type_controls_fill_the_panel_at_every_width_and_scale` and
`properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well
without this change.
- `cargo xtask i18n-coverage`: 1,726 / 1,726 for `it`.
- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=it` rendered Lighting Effects, the
Camera Raw filter and Wind with Italian labels, without missing glyphs or clipped text.
+5 -3
View File
@@ -1,10 +1,10 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech review follow-ups) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Italian dynamic labels; previously minor: Czech review follow-ups) · **Target:** Adobe Photoshop 2026
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
[Simplified Chinese](localization-zh-hans.md).
[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md).
**How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key
universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog
@@ -47,7 +47,8 @@ catalog).
8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional
Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522),
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (2,411).
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the
other catalogs translate, [notes](localization-it.md)).
Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and
transform accessibility names, rasterization prompts, profile mismatch messages and preset
@@ -77,3 +78,4 @@ engine localization or native review is complete.
| 2026-10-10 | minor | Right-to-left shell layout (menu bar, title, options and status bars, toolbox and dock sides) |
| 2026-10-11 | minor | Czech dynamic status/notice and hardware text, render labels and Navigator zoom context; engine messages remain English |
| 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed |
| 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent |
+3 -3
View File
@@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Tools](#tools) | 28 | 14 | 1 | |
| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) |
| [Type](#type) | 4 | 2 | 3 | |
| [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) |
| [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
| [Distribution](#distribution) | 5 | 4 | 2 | |
| [Open bugs](#open-bugs) | 29 | 2 | 1 | |
@@ -214,7 +214,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI |
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test) |
| TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet |
| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus |
| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks |
| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) |
| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 |
| TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) |
@@ -230,7 +230,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T
| AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids |
| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Panel deletes whole actions only (actions.rs:146) |
| AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. |
| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | missing | [#219](https://github.com/storytold/photocraft/issues/219) | One replay test (actions.rs:171: new layer, rect select, fill) |
| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) |
| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel |
| AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only |
| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (History snapshots) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⇧-dragged guides land on ruler ticks) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -149,6 +149,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Smart guides (magenta alignment lines) | done | |
| Smart guide distance and spacing labels | missing | |
| Ruler units px/in/cm/mm/pt/pica/% | done | |
| ⇧-drag a guide out of a ruler: it lands on the nearest ruler tick | done | `rulers.rs` `snap_to_tick` (#3770) |
| Status bar follows the ruler unit | partial | #2602 |
| Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 |
| Guides stored as f64 | missing | `Vec<f32>` (UI-217-20) |
@@ -213,3 +214,4 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions |
| 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning |
| 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) |
| 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) |
+2 -2
View File
@@ -35,9 +35,9 @@ note = "actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel,
[[item]]
id = "AUTO-219-4"
target = "Replay tests for crop, filters, selections and styled text on a second document"
status = "missing"
status = "done"
issue = 219
note = "One replay test (actions.rs:171: new layer, rect select, fill)"
note = "actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay)"
[[item]]
id = "AUTO-219-5"
+1 -1
View File
@@ -37,7 +37,7 @@ id = "TYPE-218-5"
target = "OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus"
status = "partial"
issue = 218
note = "Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus"
note = "Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks"
[[item]]
id = "TYPE-218-6"