Integration: track t15

# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	docs/file-format-parity.md
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-22 15:38:12 +00:00
71 changed files with 1696 additions and 111 deletions
+2 -1
View File
@@ -27,7 +27,7 @@ next to them.
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -39,6 +39,7 @@ next to them.
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+16 -7
View File
@@ -15,9 +15,10 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
--tiff-compression compresses it (none, the default; lzw; zip).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
@@ -26,10 +27,10 @@ USAGE:
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -73,20 +74,20 @@ struct Subcommand {
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
@@ -226,6 +227,14 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
if let Some(c) = a.get("--tiff-compression") {
o.encode.tiff_compression = match c {
"none" => photocraft_codecs::TiffCompression::None,
"lzw" => photocraft_codecs::TiffCompression::Lzw,
"zip" => photocraft_codecs::TiffCompression::Deflate,
_ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
};
}
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 20h18" />
<rect x="5" y="4" width="5" height="12" rx="1" />
<rect x="14" y="9" width="5" height="7" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M12 3v18" />
<rect x="5" y="5" width="14" height="5" rx="1" />
<rect x="8" y="14" width="8" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3v18" />
<rect x="8" y="5" width="12" height="5" rx="1" />
<rect x="8" y="14" width="7" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M20 3v18" />
<rect x="4" y="5" width="12" height="5" rx="1" />
<rect x="9" y="14" width="7" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4h18" />
<rect x="5" y="8" width="5" height="12" rx="1" />
<rect x="14" y="8" width="5" height="7" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 12h18" />
<rect x="5" y="5" width="5" height="14" rx="1" />
<rect x="14" y="8" width="5" height="8" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 7h16" />
<rect x="7" y="2" width="10" height="3" rx="1" />
<path d="M4 14h16" />
<rect x="7" y="9" width="10" height="3" rx="1" />
<path d="M4 21h16" />
<rect x="7" y="16" width="10" height="3" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 516 B

+22
View File
@@ -0,0 +1,22 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="2" y="6" width="4" height="12" rx="1" />
<path d="M4 2v2" />
<path d="M4 20v2" />
<rect x="10" y="6" width="4" height="12" rx="1" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<rect x="18" y="6" width="4" height="12" rx="1" />
<path d="M20 2v2" />
<path d="M20 20v2" />
</svg>

After

Width:  |  Height:  |  Size: 585 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<path d="M21 4v16" />
<rect x="9" y="7" width="6" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<rect x="5" y="7" width="3" height="10" rx="1" />
<path d="M10 4v16" />
<rect x="12" y="7" width="3" height="10" rx="1" />
<path d="M17 4v16" />
<rect x="19" y="7" width="3" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 517 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M7 4v16" />
<rect x="2" y="7" width="3" height="10" rx="1" />
<path d="M14 4v16" />
<rect x="9" y="7" width="3" height="10" rx="1" />
<path d="M21 4v16" />
<rect x="16" y="7" width="3" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 516 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<rect x="7" y="5" width="10" height="3" rx="1" />
<path d="M4 10h16" />
<rect x="7" y="12" width="10" height="3" rx="1" />
<path d="M4 17h16" />
<rect x="7" y="19" width="10" height="3" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 517 B

+22
View File
@@ -0,0 +1,22 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="6" y="2" width="12" height="4" rx="1" />
<path d="M2 4h2" />
<path d="M20 4h2" />
<rect x="6" y="10" width="12" height="4" rx="1" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<rect x="6" y="18" width="12" height="4" rx="1" />
<path d="M2 20h2" />
<path d="M20 20h2" />
</svg>

After

Width:  |  Height:  |  Size: 585 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<path d="M4 21h16" />
<rect x="7" y="9" width="10" height="6" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

+92 -5
View File
@@ -897,14 +897,39 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
label[i] = img as i32;
}
}
if no == 0 || nn == no {
if no == 0 {
continue;
}
let (ox, oy) = (sx / no as f64, sy / no as f64);
let (cx, cy) = (nx / nn as f64, ny / nn as f64);
let contained = nn == no;
let (ox, oy) = if contained {
// Wholly inside the composite (#5745: two photos in one document, the second moved
// by Auto-Align so the canvas crops it to the part over the first). Its side of the
// seam is the one it lies towards from the composite's centre; an image centred on
// the composite has no side, and the earlier images keep it.
let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize);
for (i, l) in label.iter().enumerate() {
if *l >= 0 {
px += (i % w) as f64;
py += (i / w) as f64;
pn += 1;
}
}
let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64);
if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 {
continue;
}
(px, py)
} else {
(sx / no as f64, sy / no as f64)
};
let vertical = (cx - ox).abs() >= (cy - oy).abs();
// Image on the increasing side along the seam's cross axis?
let positive = if vertical { cx > ox } else { cy > oy };
// A contained image has no pixels of its own, so a seam on its outer edge would leave it
// nothing: break cost ties away from its side (towards larger k when it lies on the
// decreasing side) so it keeps its side of the overlap.
let prefer_high = contained && !positive;
let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() };
let (lines, len) = if vertical { (h, w) } else { (w, h) };
let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line };
@@ -925,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
let mut best = (0.0f32, usize::MAX);
if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) {
best = (f32::INFINITY, usize::MAX);
for dk in [-1i64, 0, 1] {
let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] };
for dk in dks {
let kk = k as i64 + dk;
if kk < 0 || kk >= len as i64 {
continue;
@@ -952,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if seam[line] != usize::MAX {
continue;
}
let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue };
let Some(k) = (0..len)
.filter(|&k| overlap[idx(line, k)])
.min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) }))
else {
continue;
};
let (mut l, mut k) = (line, k);
loop {
seam[l] = k;
@@ -972,9 +1003,13 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if s == usize::MAX {
continue;
}
let on_side = |k: usize| if positive { k > s } else { k < s };
// A contained image has no other cells, so where the overlap has nothing on its side
// of the seam (a line one cell wide) it takes the seam cell rather than nothing.
let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k));
for k in 0..len {
let i = idx(line, k);
if overlap[i] && ((positive && k > s) || (!positive && k < s)) {
if overlap[i] && (on_side(k) || (take_seam && k == s)) {
label[i] = img as i32;
}
}
@@ -1585,6 +1620,58 @@ mod tests {
assert!(max_jump < 0.1, "max step {max_jump}");
assert!(cov.iter().all(|c| *c == 1.0));
}
#[test]
fn an_image_wholly_inside_the_composite_still_gets_its_side() {
// #5745: the first image covers the whole 120×40 grid, the second only its right part
// (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything.
let (w, h) = (120usize, 40usize);
let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]];
let cover: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()];
let labels = seam_labels(w, h, &luma, &cover, &[0, 1]);
for y in 0..h {
assert_eq!(labels[y * w + 5], 0, "row {y}");
assert_eq!(labels[y * w + w - 1], 1, "row {y}");
// The second image's region is one run at the right of its seam.
let row = &labels[y * w..(y + 1) * w];
let first = row.iter().position(|l| *l == 1).unwrap();
assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
// The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap
// instead of on the image's outer edge, which used to leave it nothing.
let left: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()];
let labels = seam_labels(w, h, &luma, &left, &[0, 1]);
for y in 0..h {
let row = &labels[y * w..(y + 1) * w];
assert_eq!(row[0], 1, "row {y}: {row:?}");
assert_eq!(row[w - 1], 0, "row {y}");
let last = row.iter().rposition(|l| *l == 1).unwrap();
assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
let (tw, th) = (40usize, 120usize);
let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]];
let top: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()];
let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]);
for x in 0..tw {
assert_eq!(labels[x], 1, "column {x}");
assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}");
}
// One column or row wide: the seam can only run through it, and the image keeps it.
for col in [0, w - 1] {
let strip: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()];
let labels = seam_labels(w, h, &luma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}");
}
let strip: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()];
let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}");
// An image centred on the composite has no side: the first image keeps the overlap.
let centred: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()];
assert!(seam_labels(w, h, &luma, &centred, &[0, 1]).iter().all(|l| *l == 0));
// Two images covering the same cells, likewise.
let same = vec![vec![true; w * h]; 2];
assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0));
}
}
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
+159 -10
View File
@@ -151,12 +151,20 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// Native-channel pixels of a layer over `area` (transparent outside its content).
fn pixels(l: &Layer, area: Rect) -> Option<(Vec<f32>, photocraft_color::PixelFormat)> {
let s = l.surface()?;
Some((s.read_region(area), s.format()))
/// The most memory Auto-Blend's working buffers may take past the canvas: a bound independent
/// of the layers' own bounding boxes, which a sparse layer (a dot at each far corner) can make
/// arbitrarily large, and of how many layers are blended.
const MAX_BLEND_BYTES: u64 = 4 << 30;
/// What Auto-Blend's buffers hold per work-area pixel, as f32: each layer's pixels, luminance,
/// alpha and mask weight, plus the merged picture and the depth map.
fn blend_bytes_per_pixel(layers: u64, channels: u64) -> u64 {
layers.saturating_mul(channels.saturating_add(3)).saturating_add(channels.saturating_mul(2)).saturating_mul(4)
}
/// Auto-Blend's buffers, for allocation errors.
const BLEND_BUFFERS: &str = "Auto-Blend Layers";
fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.autoBlendLayers";
let ids = selected_pixel_layers(s);
@@ -181,13 +189,24 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc: std::sync::Arc<Document> = st.doc.clone();
let mut area = Rect::EMPTY;
let mut union = Rect::EMPTY;
let mut content_px = 0u64;
let px_of = |r: &Rect| u64::from(r.width()) * u64::from(r.height());
for id in &ids {
if let Some(b) = doc.layer(*id).and_then(|l| l.surface()).map(|s| s.content_bounds()) {
area = area.union(&b);
union = union.union(&b);
content_px = content_px.saturating_add(px_of(&b));
}
}
let area = area.intersect(&doc.bounds());
// The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers
// partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All
// shows the whole panorama. Layers so far apart that their bounds are mostly empty, or
// whose buffers would exceed `MAX_BLEND_BYTES`, keep to the canvas.
let canvas = doc.bounds();
let channels = ids.iter().filter_map(|id| doc.layer(*id).and_then(|l| l.surface()).map(|s| s.channels() as u64)).max().unwrap_or(4);
let per_px = blend_bytes_per_pixel(ids.len() as u64, channels);
let fits = px_of(&union).saturating_mul(per_px) <= MAX_BLEND_BYTES && px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2);
let area = if fits { union } else { union.intersect(&canvas) };
if area.is_empty() {
return Err(EngineError::Other("the selected layers are empty".into()));
}
@@ -198,9 +217,10 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
let mut fmt = None;
for id in &ids {
let l = doc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
let (px, f) = pixels(l, area).ok_or(EngineError::NoLayer(*id))?;
let surf = l.surface().ok_or(EngineError::NoLayer(*id))?;
let (px, f) = (crate::allocation::read_region(surf, area, BLEND_BUFFERS)?, surf.format());
let n = f.channels();
let (mut lv, mut av) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
let (mut lv, mut av) = (crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?, crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?);
for i in 0..w * h {
let c = to_rgba(&f, &px[i * n..(i + 1) * n]);
lv[i] = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
@@ -228,7 +248,9 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
let surfs: Vec<Surface> =
ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::<Result<_>>()?;
let order = seam_order(&surfs, 0);
let b = seam_blend(&surfs, area, &order, seamless, false, None);
// Seamless Tones and Colors also evens out exposure and colour between the layers (gain
// compensation over their overlaps), as in Photomerge.
let b = seam_blend(&surfs, area, &order, seamless, seamless, None);
let weights: Vec<Vec<f32>> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect();
let blended = seamless.then(|| b.composite.read_region(area));
(weights, blended)
@@ -476,6 +498,133 @@ mod tests {
assert!(s.active().unwrap().doc.is_background(bg), "undo restores the Background");
}
/// Seamless Tones and Colors evens out a 10% exposure step between two panorama layers.
#[test]
fn auto_blend_panorama_seamless_matches_exposure() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap();
let mut ids = Vec::new();
for (name, x0, x1, gain) in [("left", 0, 130, 1.0f32), ("right", 70, 200, 1.1)] {
s.execute("layer.new.layer", json!({ "name": name })).unwrap();
s.edit(name, |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x0, 0, x1, 60), &[0.2 * gain, 0.4 * gain, 0.5 * gain, 1.0]);
Ok(())
})
.unwrap();
ids.push(s.active().unwrap().active_layer.unwrap());
}
select(&mut s, &ids);
let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap();
let (a, b) = (blended.pixel(10, 30), blended.pixel(190, 30));
// 0.5 vs 0.55 without gain compensation.
assert!((a[2] - b[2]).abs() < 0.015, "{a:?} {b:?}");
}
/// #5745: the top layer hangs off the canvas's right edge, so cropped to the canvas it lies
/// wholly over the bottom one. It used to get an all-black mask; now it shows on its side.
#[test]
fn auto_blend_panorama_of_a_layer_the_canvas_crops_to_all_overlap() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "left"})).unwrap();
s.edit("l", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let left = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
s.edit("r", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let right = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[left, right]);
s.execute("edit.autoBlendLayers", json!({"method": "panorama", "seamlessTones": false})).unwrap();
let doc = &s.active().unwrap().doc;
let ml = doc.layer(left).unwrap().mask.as_ref().unwrap();
let mr = doc.layer(right).unwrap().mask.as_ref().unwrap();
assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0));
assert_eq!((ml.value(195, 30), mr.value(195, 30)), (0.0, 1.0));
for x in 0..200 {
assert_eq!(ml.value(x, 30) + mr.value(x, 30), 1.0, "x {x} shows one layer");
}
}
/// #5745: a layer Auto-Align left partly off the canvas is masked and blended over all of
/// its bounds, not cut off at the canvas edge.
#[test]
fn auto_blend_panorama_masks_past_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "left"})).unwrap();
s.edit("l", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 150, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let left = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
s.edit("r", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let right = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[left, right]);
let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
let ml = doc.layer(left).unwrap().mask.as_ref().unwrap();
let mr = doc.layer(right).unwrap().mask.as_ref().unwrap();
assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0));
assert_eq!((ml.value(300, 30), mr.value(300, 30)), (0.0, 1.0), "off the canvas");
let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap();
assert_eq!(blended.pixel(300, 30)[3], 1.0, "the merged layer covers the off-canvas part");
}
/// A sparse layer whose bounds are huge (a dot at each far corner) raises its own content
/// estimate, so the extent past the canvas is capped independently: it keeps to the canvas.
#[test]
fn auto_blend_sparse_huge_bounds_keep_to_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "a"})).unwrap();
s.edit("a", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
surf.fill_rect(Rect::new(0, 0, 150, 200), &[0.2, 0.4, 0.5, 1.0]);
surf.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let a = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "b"})).unwrap();
s.edit("b", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 0, 200, 200), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let b = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[a, b]);
let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
let bb = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap().content_bounds();
assert!(bb.x1 <= 200 && bb.y1 <= 200, "{bb:?}");
}
/// The budget past the canvas counts every layer's buffers, not just the work-area pixels.
#[test]
fn auto_blend_budget_counts_layers_and_channels() {
// Two RGBA layers: 4 bytes × (2 layers × (4 channels + luma, alpha, weight) + merged + depth).
assert_eq!(blend_bytes_per_pixel(2, 4), 88);
assert!(blend_bytes_per_pixel(5, 4) > blend_bytes_per_pixel(2, 4));
// A 2^27-pixel area would need about 11 GiB for just two RGBA layers: it keeps to the canvas.
assert!((1u64 << 27).saturating_mul(blend_bytes_per_pixel(2, 4)) > MAX_BLEND_BYTES);
assert_eq!(blend_bytes_per_pixel(u64::MAX, 4), u64::MAX, "saturates instead of overflowing");
}
#[test]
fn auto_blend_stack_without_seamless() {
let mut s = Session::new();
+23 -4
View File
@@ -973,6 +973,17 @@ fn preserves_roundness(p: &Value) -> bool {
p.get("preserve").and_then(Value::as_str) == Some("roundness")
}
/// Whether any of the boolean `flags` is on in `p` (present, not null and not `false`).
fn flag_set(p: &Value, flags: &[&str]) -> bool {
flags.iter().any(|f| p.get(*f).is_some_and(|v| !v.is_null() && *v != Value::Bool(false)))
}
/// Select > Modify with "Apply effect at canvas bounds": the descriptor has no field for it, so such a
/// step is not exported (it would replay without the option).
fn applies_at_canvas_bounds(p: &Value) -> bool {
flag_set(p, &["applyAtCanvasBounds"])
}
/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's
/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such
/// a step can't be exported faithfully.
@@ -2290,6 +2301,14 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true)));
// `source` is the command's older name for `contents`.
d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") {
// `Pttn` names no pattern and holds no placement: a chosen pattern, a scale, an angle
// or a phase would replay with the current pattern at its defaults (#7543, #7544,
// #7545, #7546).
let num = |k: &str, default: f64| p.get(k).is_some_and(|v| !v.is_null() && v.as_f64() != Some(default));
let phase = p.get("phase").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| a.iter().any(|c| c.as_f64() != Some(0.0))));
if p.get("pattern").is_some_and(|v| !v.is_null()) || num("scale", 100.0) || num("angle", 0.0) || phase {
return None;
}
d.with("Usng", enm("FlCn", "Pttn"))
} else {
// An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `.
@@ -3416,10 +3435,10 @@ const EVENTS: &[Event] = &[
},
// Selection.
ev!("Invs", "inverse", "Inverse", "select.inverse", &[]),
ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)]),
ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)]),
ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)]),
ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)]),
ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)], unless applies_at_canvas_bounds),
ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)], unless applies_at_canvas_bounds),
ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)], unless applies_at_canvas_bounds),
ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)], unless applies_at_canvas_bounds),
ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]),
ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]),
ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]),
+27
View File
@@ -1758,6 +1758,33 @@ fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry(
}
}
/// #7540-#7542: Feather, Contract and Smooth (and Expand) with Apply Effect at Canvas Bounds, and
/// #7543-#7546: a pattern Paint Bucket with a chosen pattern, scale, angle or phase, can't be
/// written, so strict export refuses them. Their defaults still export and round-trip.
#[test]
fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() {
let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() };
for id in ["select.modify.feather", "select.modify.expand", "select.modify.contract", "select.modify.smooth"] {
assert!(export("Audit7", &[&one(id, json!({"radius": 2, "applyAtCanvasBounds": true}))], &[], false).is_err(), "{id}");
let plain = json!({"radius": 2, "applyAtCanvasBounds": false});
let (bytes, _) = export("Audit7", &[&one(id, plain)], &[], false).unwrap_or_else(|e| panic!("{id}: {e}"));
assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![(id.to_string(), json!({"radius": 2}))]);
}
let bucket = |extra: Value| {
let mut p = json!({"x": 5, "y": 5, "contents": "pattern", "opacity": 70, "tolerance": 80});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
one("paint.bucket", p)
};
for extra in [json!({"pattern": "AuditPattern"}), json!({"scale": 50}), json!({"angle": 30}), json!({"phase": [3, 5]})] {
assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_err(), "{extra}");
}
for extra in [json!({}), json!({"scale": 100, "angle": 0, "phase": [0, 0]})] {
assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_ok(), "{extra}");
}
}
/// Photoshop's Close has no document target: a Close of a specific document must not be
/// exported as a plain Close, which would close the active document on playback.
#[test]
+37 -4
View File
@@ -309,6 +309,8 @@ pub(crate) struct SaveOpts {
pub tga_bits: Option<photocraft_io::TgaBits>,
/// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16.
pub dpx_bits: Option<photocraft_io::DpxBits>,
/// TIFF: the image compression (`"tiffCompression": "none" | "lzw" | "zip"`); unset, none.
pub tiff_compression: Option<photocraft_codecs::TiffCompression>,
/// DDS: the compression and whether to write the mip chain (`"ddsCompression"`,
/// `"ddsMipmaps"`); unset, uncompressed without mipmaps.
pub dds: photocraft_io::DdsOptions,
@@ -326,6 +328,7 @@ impl Default for SaveOpts {
tiff_layers: false,
tga_bits: None,
dpx_bits: None,
tiff_compression: None,
dds: photocraft_io::DdsOptions::default(),
image_previews: true,
psd_maximize_compatibility: true,
@@ -345,6 +348,7 @@ impl SaveOpts {
tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false),
tga_bits: tga_bits_param(p, cmd)?,
dpx_bits: dpx_bits_param(p, cmd)?,
tiff_compression: tiff_compression_param(p, cmd)?,
dds: dds_param(p, cmd)?.unwrap_or_default(),
image_previews: file_handling.image_previews != crate::prefs::Ask::Never,
psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never,
@@ -377,6 +381,21 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::TgaB
}
}
/// The TIFF compression in `p` (`"tiffCompression": "none" | "lzw" | "zip"`, Photoshop's TIFF
/// Options; absent or null: none). Anything else is a bad parameter of command `cmd` (#5301).
pub fn tiff_compression_param(p: &Value, cmd: &str) -> Result<Option<photocraft_codecs::TiffCompression>> {
use photocraft_codecs::TiffCompression;
match p.get("tiffCompression") {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_str() {
Some("none") => Ok(Some(TiffCompression::None)),
Some("lzw") => Ok(Some(TiffCompression::Lzw)),
Some("zip") => Ok(Some(TiffCompression::Deflate)),
_ => Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("`tiffCompression` must be \"none\", \"lzw\" or \"zip\", got {v}") }),
},
}
}
/// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's
/// depth). Anything else is a bad parameter of command `cmd`.
pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::DpxBits>> {
@@ -431,6 +450,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
// lossy or lossless as it was), as Photoshop's Save does.
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
opts.encode.dpx_bits = save.dpx_bits;
if let Some(c) = save.tiff_compression {
opts.encode.tiff_compression = c;
}
opts.encode.dds = save.dds;
if let Some(q) = save.quality {
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
@@ -566,6 +588,16 @@ pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
}
}
/// Edit › Preferences › Raw Defaults › Resolution: the ppi a raw developed on open gets, as
/// Camera Raw's workflow options set it (the raw itself states none). `warnings` are the
/// import's notes, which say whether the raw was developed or only its preview opened.
pub fn raw_defaults(s: &Session, warnings: &[String], doc: &mut Document) {
let ppi = s.prefs().raw_defaults.resolution;
if ppi.is_finite() && warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)) {
doc.resolution_dpi = ppi.clamp(1.0, 10_000.0) as f32;
}
}
pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option<String>) -> Result<Value> {
crate::allocation::checkpoint("importing document")?;
let decode_name = match as_ext {
@@ -575,6 +607,7 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&
let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?;
let mut doc = r.document;
tag_exr_colorspace(s, &decode_name, &mut doc);
raw_defaults(s, &r.warnings, &mut doc);
let path = match template_name(s, name) {
Some(untitled) => {
doc.name = untitled;
@@ -1723,7 +1756,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save a Copy…",
&["File"],
Some("Cmd+Alt+S"),
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##,
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##,
native_doc,
save_a_copy
),
@@ -1786,7 +1819,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
&["File", "Automate"],
None,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
batch
),
@@ -1795,7 +1828,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Image Processor…",
&["File", "Scripts"],
None,
r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
image_processor
),
@@ -1823,7 +1856,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layers to Files…",
&["File", "Export"],
None,
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##,
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##,
native_doc,
layers_to_files
),
+68
View File
@@ -92,6 +92,35 @@ fn batch_same_format_saves_camera_raws_as_psd() {
}
}
/// Edit › Preferences › Raw Defaults › Resolution sets the ppi a raw opens with, through
/// File › Open (the background open job) and through opening bytes; other files keep their own.
#[test]
fn a_developed_raw_opens_at_the_raw_defaults_resolution() {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
let (w, h) = (24, 16);
let raw = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build();
let dir = tmp("raw-defaults");
let path = join(&dir, "IMG_0002.dng");
std::fs::write(&path, &raw).unwrap();
let open = |s: &mut Session, path: &str| match s.start_open(path, crate::jobs::OpenSource::Path(path.into())).unwrap() {
crate::jobs::Started::Job(id) => {
s.wait_job(id).unwrap();
}
crate::jobs::Started::Done(_) => {}
};
let mut s = Session::new();
open_bytes_as(&mut s, "IMG_0001.dng", &raw, None, None).unwrap();
assert_eq!(doc(&s).resolution_dpi, 300.0, "the default");
s.execute("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap();
open_bytes_as(&mut s, "IMG_0001.dng", &raw, None, None).unwrap();
assert_eq!(doc(&s).resolution_dpi, 240.0);
open(&mut s, &path);
assert_eq!(doc(&s).resolution_dpi, 240.0, "File › Open");
let png = png(&dir, "a.png", 4, 4, "#ff0000");
open(&mut s, &png);
assert_eq!(doc(&s).resolution_dpi, 72.0, "not a raw");
}
#[test]
fn revert_reloads_as_one_undoable_step() {
let dir = tmp("revert");
@@ -1477,3 +1506,42 @@ fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() {
assert!(!super::openable_name("archive.tar.gz"));
assert!(!super::openable_name("notes.txt"));
}
/// The Compression tag (259) of a TIFF's first directory.
fn tiff_compression_tag(b: &[u8]) -> Option<u16> {
let le = b.get(..2)? == b"II";
let u16_at = |i: usize| b.get(i..i + 2).map(|s| if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) });
let u32_at =
|i: usize| b.get(i..i + 4).map(|s| if le { u32::from_le_bytes([s[0], s[1], s[2], s[3]]) } else { u32::from_be_bytes([s[0], s[1], s[2], s[3]]) });
let ifd = u32_at(4)? as usize;
(0..usize::from(u16_at(ifd)?)).map(|k| ifd + 2 + 12 * k).find(|e| u16_at(*e) == Some(259)).and_then(|e| u16_at(e + 8))
}
/// #5301: `tiffCompression` picks a TIFF's compression (TIFF Options: None, LZW, ZIP); unset,
/// none. Anything else is refused before anything is written.
#[test]
fn save_a_copy_writes_the_tiff_compression_asked_for() {
let dir = tmp("tiff-compression");
for (params, tag) in
[(json!({}), 1), (json!({"tiffCompression": "none"}), 1), (json!({"tiffCompression": "lzw"}), 5), (json!({"tiffCompression": "zip"}), 8)]
{
let mut s = session(12, 6, 8);
let out = join(&dir, "a.tif");
let mut p = params.clone();
p["path"] = json!(out);
s.execute("file.saveACopy", p).unwrap();
let bytes = std::fs::read(&out).unwrap();
assert_eq!(tiff_compression_tag(&bytes), Some(tag), "{params}");
assert_eq!(photocraft_io::import("a.tif", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6));
}
let mut s = session(8, 8, 8);
let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty()));
let before = state(&s);
for bad in [json!("jpeg"), json!("LZW"), json!(5), json!(true)] {
let out = join(&dir, "bad.tif");
let r = s.execute("file.saveACopy", json!({"path": out, "tiffCompression": bad}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}");
assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written");
assert_eq!(state(&s), before, "{bad}: the document is as it was");
}
}
+1
View File
@@ -551,6 +551,7 @@ impl Session {
move |s, mut r: photocraft_io::ImportResult| {
// File Handling's ACEScg option, before the colour prompt looks at the profile.
crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document);
crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document);
// Ask When Opening: held back until the prompt is answered (`take_held_open`). A
// developed raw has no embedded profile to ask about.
let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
+11 -1
View File
@@ -444,6 +444,8 @@ impl Default for HistoryLog {
pub struct FileHandling {
pub image_previews: Ask,
pub lowercase_extension: bool,
/// File › Save a Copy suggests the document's own name rather than "<name> copy" (#8080).
pub do_not_append_copy: bool,
/// Saves run in the background while you keep working (on), or in the foreground under a
/// modal progress dialog that blocks the app until the file is written (off).
pub save_in_background: bool,
@@ -467,11 +469,20 @@ pub struct FileHandling {
pub recent_files: Vec<String>,
}
impl FileHandling {
/// What File › Save a Copy appends to the document's name: " copy", or nothing with Do not
/// append "copy" to filename when saving a copy (#8080).
pub fn copy_suffix(&self) -> &'static str {
if self.do_not_append_copy { "" } else { " copy" }
}
}
impl Default for FileHandling {
fn default() -> Self {
Self {
image_previews: Ask::Always,
lowercase_extension: true,
do_not_append_copy: false,
save_in_background: true,
autosave: true,
autosave_minutes: 10,
@@ -1063,7 +1074,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"enhancedControls.scrubbySliderAcceleration",
"rawDefaults.colorSpace",
"rawDefaults.bitDepth",
"rawDefaults.resolution",
"rawDefaults.sharpenFor",
"rawDefaults.openAsSmartObject",
"rawDefaults.applyAutoTone",
+14 -14
View File
@@ -67,20 +67,20 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
("window.theme.pro", "Pro", &["Window", "Theme", "Dark Theme"], None),
("window.theme.proMedium", "Pro Medium Gray", &["Window", "Theme", "Dark Theme"], None),
("window.theme.studio", "Studio", &["Window", "Theme", "Dark Theme"], None),
("window.theme.solarizedDark", "Solarized", &["Window", "Theme", "Dark Theme"], None),
("window.theme.adwaitaDark", "Adwaita", &["Window", "Theme", "Dark Theme"], None),
("window.theme.breezeDark", "Breeze", &["Window", "Theme", "Dark Theme"], None),
("window.theme.softDark", "Soft", &["Window", "Theme", "Dark Theme"], None),
("window.theme.proLightGray", "Pro Light Gray", &["Window", "Theme", "Light Theme"], None),
("window.theme.proLight", "Pro Light", &["Window", "Theme", "Light Theme"], None),
("window.theme.studioLight", "Studio", &["Window", "Theme", "Light Theme"], None),
("window.theme.classic", "Classic", &["Window", "Theme", "Light Theme"], None),
("window.theme.adwaita", "Adwaita", &["Window", "Theme", "Light Theme"], None),
("window.theme.breezeLight", "Breeze", &["Window", "Theme", "Light Theme"], None),
("window.theme.softLight", "Soft", &["Window", "Theme", "Light Theme"], None),
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
+76
View File
@@ -0,0 +1,76 @@
//! The Move tool's Align and Distribute controls (Photoshop shows them whenever the Move tool is
//! active; they enable once enough layers are selected). Every button dispatches an existing
//! `layer.align.*` / `layer.distribute.*` command.
use serde_json::json;
use crate::theme::Tokens;
use crate::{PhotocraftApp, icons};
/// (command id, icon, label). The labels are the Layer menu's, already translated.
const ALIGN: [(&str, &str, &str); 6] = [
("layer.align.leftEdges", "align-edge-left", "Left Edges"),
("layer.align.horizontalCenters", "align-edge-hcenter", "Horizontal Centers"),
("layer.align.rightEdges", "align-edge-right", "Right Edges"),
("layer.align.topEdges", "align-edge-top", "Top Edges"),
("layer.align.verticalCenters", "align-edge-vcenter", "Vertical Centers"),
("layer.align.bottomEdges", "align-edge-bottom", "Bottom Edges"),
];
const DISTRIBUTE: [(&str, &str, &str); 8] = [
("layer.distribute.topEdges", "distribute-top", "Top Edges"),
("layer.distribute.verticalCenters", "distribute-vcenter", "Vertical Centers"),
("layer.distribute.bottomEdges", "distribute-bottom", "Bottom Edges"),
("layer.distribute.leftEdges", "distribute-left", "Left Edges"),
("layer.distribute.horizontalCenters", "distribute-hcenter", "Horizontal Centers"),
("layer.distribute.rightEdges", "distribute-right", "Right Edges"),
("layer.distribute.horizontally", "distribute-horizontal", "Horizontally"),
("layer.distribute.vertically", "distribute-vertical", "Vertically"),
];
/// The six align icons followed by a menu holding both groups with icons and names.
pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
ui.spacing_mut().item_spacing.x = 0.0;
for (cmd, icon, label) in ALIGN {
let tip = format!("{}: {}", tl!("Align"), tl!(label));
let on = app.session.is_enabled(cmd);
if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, &tip)).inner.clicked() {
let _ = app.run(cmd, json!({}));
}
}
ui.add_space(6.0);
let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute"));
egui::Popup::menu(&more).show(|ui| {
ui.set_min_width(200.0);
let mut section = |ui: &mut egui::Ui, title: &str, items: &[(&str, &str, &str)]| {
ui.label(egui::RichText::new(title).small().color(t.text_dim));
for (cmd, icon, label) in items {
let on = app.session.is_enabled(cmd);
let tint = if on { t.icon } else { t.text_dim };
let button = egui::Button::image_and_text(icons::image(icon, 16.0, tint), tl!(label));
if ui.add_enabled(on, button).clicked() {
let _ = app.run(cmd, json!({}));
ui.close();
}
}
};
section(ui, tl!("Align"), &ALIGN);
ui.separator();
section(ui, tl!("Distribute"), &DISTRIBUTE);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_button_is_a_live_command_with_an_icon() {
let specs = photocraft_engine::command_specs();
for (cmd, icon, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) {
assert!(specs.iter().any(|c| c.id == *cmd), "{cmd} is not a command");
assert!(icons::exists(icon), "{icon} is not embedded");
}
}
}
+24 -1
View File
@@ -469,12 +469,16 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) -
fn replace_raw_document(
s: &mut photocraft_engine::Session,
doc_id: photocraft_doc::DocId,
r: photocraft_io::ImportResult,
mut r: photocraft_io::ImportResult,
wb: bool,
) -> photocraft_engine::Result<Value> {
let index =
s.documents().iter().position(|st| st.doc.id == doc_id).ok_or_else(|| photocraft_engine::EngineError::Other("the raw document was closed".into()))?;
let path = s.documents().get(index).and_then(|st| st.path.clone());
// The resolution it opened with (Preferences › Raw Defaults), which re-developing keeps.
if let Some(st) = s.documents().get(index) {
r.document.resolution_dpi = st.doc.resolution_dpi;
}
s.close(index);
let (opened, _) = s.open_document(r.document, path);
let index = match s.execute("document.move", json!({"document": opened, "to": index})) {
@@ -1886,6 +1890,25 @@ mod tests {
assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app));
}
/// #7789: Preferences › Raw Defaults › Resolution sets a raw's ppi when the app opens it
/// itself (no background job) and from automation, and a Camera Raw re-develop keeps it.
#[test]
fn a_raw_opens_and_redevelops_at_the_raw_defaults_resolution() {
let (mut app, dng) = raw_app();
let ctx = egui::Context::default();
app.run("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap();
app.open_bytes("IMG_0004.dng", &dng).unwrap();
assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0);
let raw = app.pending_raw_open.take().unwrap();
open_raw(&mut app, &ctx, raw).unwrap();
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap();
let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
assert_eq!(r["redeveloped"], true, "{r}");
assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "kept by the re-develop");
app.open_automation_bytes("IMG_0005.dng", &dng).unwrap();
assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "automation open");
}
#[test]
fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() {
let (mut app, dng) = raw_app();
+78 -3
View File
@@ -4926,8 +4926,9 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
pts.push(h);
}
}
// Just the outline and its rubber band: the vertices aren't handles to grab.
crate::tool_feedback::draw_ants(painter, &pts, false);
// Just the outline and its rubber band (the vertices aren't handles to grab), solid until
// the selection is made (#7495).
crate::tool_feedback::draw_solid_path(painter, &pts, false);
}
crate::crop_straighten::draw(app, painter, |p| xf.to_screen(p[0] as f32, p[1] as f32), hover);
crate::blur_pins::draw(app, painter, xf);
@@ -5204,7 +5205,13 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
// On pixel centres like the marquee's ants, so a 1 pt line stays crisp wherever the
// canvas sits (a pixel edge would blur it to grey).
let pts: Vec<Pos2> = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect();
crate::tool_feedback::draw_ants(painter, &pts, false);
// The Lasso's outline is solid until the release makes the selection (#7495); a Patch
// or Content-Aware Move area keeps its ants.
if d.tool == Tool::Lasso {
crate::tool_feedback::draw_solid_path(painter, &pts, false);
} else {
crate::tool_feedback::draw_ants(painter, &pts, false);
}
}
Tool::Gradient => {
let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32);
@@ -9078,3 +9085,71 @@ mod display_memo_tests;
#[cfg(test)]
#[path = "canvas/blend_viewport_tests.rs"]
mod blend_viewport_regression;
#[cfg(test)]
mod lasso_preview_tests {
use super::*;
fn xf() -> ViewXform {
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }
}
/// What `draw` paints: (black dashes, black solid paths).
fn painted(app: &mut PhotocraftApp, draw: impl Fn(&mut PhotocraftApp, &egui::Painter)) -> (usize, usize) {
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui.painter()));
output.textures_delta.clear();
let mut flat = Vec::new();
for clipped in output.shapes {
let mut stack = vec![clipped.shape];
while let Some(shape) = stack.pop() {
match shape {
egui::Shape::Vec(v) => stack.extend(v),
s => flat.push(s),
}
}
}
let dashes = flat.iter().filter(|s| matches!(s, egui::Shape::LineSegment { stroke, .. } if stroke.color == Color32::BLACK)).count();
let solid = flat.iter().filter(|s| matches!(s, egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::BLACK))).count();
(dashes, solid)
}
fn document() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
app.sync_views();
app
}
/// #7495: as in Photoshop, the Lasso's, Polygonal Lasso's and Magnetic Lasso's outline is a
/// solid line until the selection is made; a Patch area dragged out keeps its marching ants.
#[test]
fn lasso_outlines_are_solid_until_the_selection_is_made() {
let points = vec![[20.0, 20.0, 1.0], [120.0, 30.0, 1.0], [150.0, 140.0, 1.0], [40.0, 160.0, 1.0]];
for tool in [Tool::Lasso, Tool::Patch] {
let mut app = document();
app.ui.tool = tool;
app.drag = Some(Drag::new(tool, [20.0, 20.0], points.clone(), egui::Modifiers::NONE, false));
let (dashes, solid) = painted(&mut app, |app, p| draw_drag_preview(app, p, &xf()));
if tool == Tool::Lasso {
assert_eq!((dashes, solid), (0, 1), "{tool:?}: one solid outline, no ants");
} else {
assert!(dashes > 10 && solid == 0, "{tool:?}: still marching ants ({dashes} dashes)");
}
}
let mut app = document();
app.ui.tool = Tool::PolygonLasso;
app.ui.polygon = vec![[20.0, 20.0], [120.0, 30.0], [150.0, 140.0]];
let (dashes, solid) = painted(&mut app, |app, p| draw_tool_state(app, p, &xf(), Some(pos2(60.0, 150.0))));
assert_eq!((dashes, solid), (0, 1), "Polygonal Lasso: its sides and rubber band are one solid line");
let mut app = document();
app.ui.tool = Tool::MagneticLasso;
app.ui.magnetic.path = vec![[20.0, 20.0], [80.0, 25.0], [120.0, 60.0]];
app.ui.magnetic.anchors = vec![0, 2];
app.ui.magnetic.live = vec![[120.0, 60.0], [130.0, 100.0]];
let (dashes, solid) = painted(&mut app, |app, p| draw_tool_state(app, p, &xf(), None));
assert_eq!((dashes, solid), (0, 1), "Magnetic Lasso: the traced border is solid");
}
}
+53
View File
@@ -888,6 +888,23 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) {
spec_dialog(app, command, label, spec, fields);
}
/// The Pen options bar's Make Selection (Path mode, #5703): the canvas menu's Make Selection…
/// dialog for the path being drawn (committed first, as the menu does) or the active path.
pub(crate) fn make_selection(app: &mut PhotocraftApp) -> Result<(), String> {
let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
// Where `pen_commit` puts the path being drawn: a shape layer or the targeted vector mask,
// else the Paths panel path it adds to (a selected saved path, or the Work Path), which it
// selects so it is the active path.
let pending_layer = pending && (app.ui.tool_options.vector_mode == "shape" || crate::vector_ui::targeted_vector_mask(app).is_some());
if pending {
crate::vector_ui::pen_commit(app, false);
}
let name = if pending_layer { Some("layer".to_string()) } else { crate::vector_ui::active_path_name(app) }
.ok_or_else(|| "No path is available for this action".to_string())?;
pen_dialog(app, "path.toSelection", "Make Selection…", &name);
Ok(())
}
fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> {
if command == "select.isolateLayers" {
crate::menus::invoke(app, ctx, command, json!({}))?;
@@ -1672,6 +1689,42 @@ mod tests {
assert!(app.session.active().unwrap().doc.selection.is_some());
}
/// #5703: the Pen options bar's Make Selection (Path mode) opens the same dialog for the path
/// being drawn (finished into the Work Path first), and OK selects inside it; with no path at
/// all it reports that instead.
#[test]
fn pen_options_bar_makes_a_selection_from_the_path() {
let mut app = app();
app.ui.tool = Tool::Pen;
assert!(make_selection(&mut app).is_err(), "no path yet");
assert!(app.ui.dialogs.is_empty());
for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] {
crate::vector_ui::pen_down(&mut app, x, y, false);
crate::vector_ui::pen_up(&mut app);
}
make_selection(&mut app).unwrap();
let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap();
crate::dialogs::confirm(&mut app, dialog).unwrap();
let st = app.session.active().unwrap();
assert!(st.doc.work_path.is_some() && st.doc.selection.is_some());
// The active (Work) path again, with no pen path in progress.
app.run("select.deselect", json!({})).unwrap();
make_selection(&mut app).unwrap();
let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap();
crate::dialogs::confirm(&mut app, dialog).unwrap();
assert!(app.session.active().unwrap().doc.selection.is_some());
// Drawn with a saved path selected, the path is added to that one (#4721): the dialog is
// for it, not for a Work Path.
app.run("path.rename", json!({"name": "work", "to": "Cutout"})).unwrap();
app.ui.selected_path = Some("Cutout".into());
for (x, y) in [(4.0, 24.0), (12.0, 24.0), (12.0, 30.0)] {
crate::vector_ui::pen_down(&mut app, x, y, false);
crate::vector_ui::pen_up(&mut app);
}
make_selection(&mut app).unwrap();
assert_eq!(app.ui.dialogs.last().and_then(|d| d.fields.get("name")), Some(&json!("Cutout")));
}
/// #1056: the Path Selection and Direct Selection tools' right-click opens the path menu for
/// the selected path, as the Pen's does, instead of nothing.
#[test]
+20
View File
@@ -1128,3 +1128,23 @@ fn save_of_a_flat_file_asks_after_the_dialog_when_another_app_changed_it() {
assert!(!app.session.active().unwrap().is_dirty());
assert_eq!(app.session.journal.last().map(|s| s.0.as_str()), Some("file.save"), "still recorded as a plain Save");
}
/// File › Save a Copy suggests "<name> copy", and with Preferences › File Handling › Do not
/// append "copy" to filename when saving a copy, the document's own name, as in Photoshop
/// (#8080). The preference goes through `prefs.set` like the Preferences dialog's checkbox.
#[test]
fn save_a_copy_appends_copy_unless_the_preference_says_not_to() {
let ctx = egui::Context::default();
for (off, name) in [(false, "My image copy.psd"), (true, "My image.psd")] {
let want = std::path::Path::new("/pics").join(name).to_string_lossy().into_owned();
let (mut app, open, _) = jpeg_app(Some("/pics/My image.JPEG"), false);
app.run("prefs.set", json!({"path": "fileHandling.doNotAppendCopy", "value": off})).unwrap();
menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
assert!(
matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if *suggested == want),
"{off}: {:?}",
open.borrow().first().map(|(r, _)| r.clone())
);
}
}
+6
View File
@@ -3827,6 +3827,7 @@ navigator Zoom Zvětšení
Chisel Hard Tvrdé tesání
Chisel Soft Měkké tesání
Open color-less EXR as ACEScg Otevřít EXR bez údaje o barevném prostoru jako ACEScg
Do not append "copy" to filename when saving a copy Nepřidávat „copy“ k názvu souboru při ukládání kopie
# View › Guides › right-click a guide
Edit Selected Guides Upravit vybraná vodítka
@@ -3913,3 +3914,8 @@ navigator Zoom Zvětšení
GPU adapter Adaptér GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Nejprve potvrďte nebo zrušte otevřený dialog kroku akce
Pro Medium Gray Pro (středně šedý)
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro (světle šedý)
Pro Light Pro (světlý)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -3224,6 +3224,7 @@ curves Smooth Glätten
Chisel Hard Meißeln hart
Chisel Soft Meißeln weich
Open color-less EXR as ACEScg Farbangabenloses EXR als ACEScg öffnen
Do not append "copy" to filename when saving a copy Beim Speichern einer Kopie „copy“ nicht an den Dateinamen anhängen
# Export As metadata and profile options
Color Profile Farbprofil
EXIF EXIF
@@ -3311,3 +3312,8 @@ curves Smooth Glätten
GPU adapter GPU-Adapter
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Bestätige oder brich zuerst den offenen Dialog des Aktionsschritts ab
Pro Medium Gray Pro (Mittelgrau)
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro (Hellgrau)
Pro Light Pro (Hell)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -3914,6 +3914,7 @@ curves Smooth Εξομάλυνση
Chisel Hard Σκληρή σμίλευση
Chisel Soft Μαλακή σμίλευση
Open color-less EXR as ACEScg Άνοιγμα EXR χωρίς χρωματική δήλωση ως ACEScg
Do not append "copy" to filename when saving a copy Να μην προστίθεται «copy» στο όνομα αρχείου κατά την αποθήκευση αντιγράφου
# Export As metadata and profile options
EXIF EXIF
@@ -4002,3 +4003,8 @@ curves Smooth Εξομάλυνση
GPU adapter Προσαρμογέας GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Επιβεβαιώστε ή ακυρώστε πρώτα το ανοιχτό παράθυρο βήματος της ενέργειας
Pro Medium Gray Pro μεσαίου γκρι
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro ανοιχτού γκρι
Pro Light Φωτεινό Pro
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+6
View File
@@ -4177,6 +4177,7 @@ navigator Zoom Zoom
Chisel Hard Cincelar duro
Chisel Soft Cincelar suave
Open color-less EXR as ACEScg Abrir EXR sin espacio de color como ACEScg
Do not append "copy" to filename when saving a copy No añadir «copy» al nombre de archivo al guardar una copia
# Export As metadata and profile options
# View › Guides › right-click a guide
@@ -4264,3 +4265,8 @@ navigator Zoom Zoom
GPU adapter Adaptador de GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Confirme o cancele primero el cuadro de diálogo abierto del paso de la acción
Pro Medium Gray Pro (gris medio)
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro (gris claro)
Pro Light Pro (claro)
Can't render this file because it contains an unexpected character in line 4 and column 47.
+6
View File
@@ -3223,6 +3223,7 @@ curves Smooth Lisser
Chisel Hard Ciselage dur
Chisel Soft Ciselage doux
Open color-less EXR as ACEScg Ouvrir les EXR sans espace colorimétrique en ACEScg
Do not append "copy" to filename when saving a copy Ne pas ajouter « copy » au nom de fichier lors de l’enregistrement d’une copie
# View › Guides › right-click a guide
Edit Selected Guides Modifier les repères sélectionnés
@@ -3313,3 +3314,8 @@ curves Smooth Lisser
GPU adapter Adaptateur GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Validez ou annulez d’abord la boîte de dialogue ouverte de l’étape de l’action
Pro Medium Gray Pro gris moyen
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro gris clair
Pro Light Pro clair
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -4115,6 +4115,7 @@ cameraRaw Yellow Sárga
Chisel Hard Kemény vésés
Chisel Soft Lágy vésés
Open color-less EXR as ACEScg Színinformáció nélküli EXR megnyitása ACEScg-ként
Do not append "copy" to filename when saving a copy Ne fűzze a „copy” szót a fájlnévhez másolat mentésekor
# View › Guides › right-click a guide
Edit Selected Guides Kijelölt segédvonalak szerkesztése
@@ -4219,3 +4220,8 @@ cameraRaw Yellow Sárga
GPU adapter GPU-adapter
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Előbb hagyd jóvá vagy vond vissza a művelet lépésének nyitott párbeszédablakát
Pro Medium Gray Pro középszürke
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro világosszürke
Pro Light Pro világos
Can't render this file because it contains an unexpected character in line 6 and column 47.
+22
View File
@@ -795,6 +795,22 @@
Split Warp Vertically Bagi Warp Vertikal
Statistics… Statistik…
Refine Strokes Goresan penyempurnaan
# Counted status and panel messages
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} swatch diekspor ke {file}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} swatch diimpor dari {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} kuas diimpor dari {group}
@plural Played {n} step|Played {n} steps {n} langkah diputar
@plural {n} step|{n} steps {n} langkah
@plural Saved for Web: {n} file|Saved for Web: {n} files Disimpan untuk Web: {n} file
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} Smart Object tertaut yang berubah di disk telah diperbarui
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} layer yang direkam oleh layer comp ini telah dihapus. Perbarui layer comp, atau hapus peringatannya.
@plural {n} plane|{n} planes {n} bidang
@plural {n} preset|{n} presets {n} preset
@plural {n} sample point removed|{n} sample points removed {n} titik sampel dihapus
@plural {n} slice|{n} slices {n} slice
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} peringatan preset kuas lainnya)
@plural {what} with {n} warning|{what} with {n} warnings {what} dengan {n} peringatan
@plural {n} item|{n} items {n} item
Auto Otomatis
(print to the printer) (cetak ke printer)
@@ -3210,6 +3226,7 @@ curves Smooth Haluskan
Chisel Hard Pahat Keras
Chisel Soft Pahat Lembut
Open color-less EXR as ACEScg Buka EXR tanpa informasi warna sebagai ACEScg
Do not append "copy" to filename when saving a copy Jangan tambahkan "copy" ke nama file saat menyimpan salinan
# View › Guides › right-click a guide
Edit Selected Guides Edit Panduan Terpilih
@@ -3300,3 +3317,8 @@ curves Smooth Haluskan
GPU adapter Adaptor GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Konfirmasi atau batalkan dulu dialog langkah tindakan yang terbuka
Pro Medium Gray Pro Abu-abu Sedang
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro Abu-abu Terang
Pro Light Pro Terang
Can't render this file because it contains an unexpected character in line 4 and column 47.
+6
View File
@@ -4179,6 +4179,7 @@ navigator Zoom Zoom
Chisel Hard Cesello duro
Chisel Soft Cesello morbido
Open color-less EXR as ACEScg Apri gli EXR senza spazio colore come ACEScg
Do not append "copy" to filename when saving a copy Non aggiungere "copy" al nome del file quando si salva una copia
# Color management, Liquify, glyph group, warp and Camera Raw labels the Serbian, Vietnamese and Japanese catalogs added
Adobe-rgb-compat Adobe RGB (compatibile)
@@ -4419,3 +4420,8 @@ shape Appearance Aspetto
GPU adapter Adattatore GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Conferma o annulla prima la finestra di dialogo aperta del passo dell’azione
Pro Medium Gray Pro grigio medio
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro grigio chiaro
Pro Light Pro chiaro
Can't render this file because it contains an unexpected character in line 2659 and column 53.
+6
View File
@@ -3893,6 +3893,7 @@ curves Smooth スムーズ
Chisel Hard チゼルハード
Chisel Soft チゼルソフト
Open color-less EXR as ACEScg 色空間のないEXRをACEScgとして開く
Do not append "copy" to filename when saving a copy コピーを保存するときにファイル名に「copy」を付けない
# View › Guides › right-click a guide
Edit Selected Guides 選択したガイドを編集
Clear Selected Guides 選択したガイドを消去
@@ -3980,3 +3981,8 @@ curves Smooth スムーズ
GPU adapter GPU アダプター
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 先に開いているアクションのステップのダイアログを確定またはキャンセルしてください
Pro Medium Gray プロ(中間グレー)
Solarized Solarized
Breeze Breeze
Pro Light Gray プロ(ライトグレー)
Pro Light プロ(ライト)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+23
View File
@@ -789,6 +789,22 @@
Split Warp Vertically 뒤틀기 세로로 분할
Statistics… 통계…
Refine Strokes 다듬기 획
# Counted status and panel messages
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 색상 견본 {n}개를 {file}(으)로 내보냈습니다
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}에서 색상 견본 {n}개를 가져왔습니다
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group}에서 브러시 {n}개를 가져왔습니다
@plural Played {n} step|Played {n} steps 단계 {n}개를 재생했습니다
@plural {n} step|{n} steps 단계 {n}개
@plural Saved for Web: {n} file|Saved for Web: {n} files 웹용으로 저장됨: 파일 {n}개
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 디스크에서 변경된 연결된 스마트 오브젝트 {n}개를 업데이트했습니다
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 이 레이어 구성 요소에 기록된 레이어 {n}개가 삭제되었습니다. 레이어 구성 요소를 업데이트하거나 경고를 지우세요.
@plural {n} plane|{n} planes 평면 {n}개
@plural {n} preset|{n} presets 사전 설정 {n}개
@plural {n} sample point removed|{n} sample points removed 샘플 지점 {n}개를 제거했습니다
@plural {n} slice|{n} slices 분할 영역 {n}개
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (브러시 사전 설정 경고 {n}개 더 있음)
@plural {what} with {n} warning|{what} with {n} warnings {what}: 경고 {n}개
@plural {n} item|{n} items 항목 {n}개
Auto 자동
(print to the printer) (프린터로 인쇄)
@@ -3400,6 +3416,7 @@ curves Smooth 매끄럽게
Chisel Hard 단단하게 조각
Chisel Soft 부드럽게 조각
Open color-less EXR as ACEScg 색 공간 정보 없는 EXR을 ACEScg로 열기
Do not append "copy" to filename when saving a copy 사본 저장 시 파일 이름에 "copy"를 붙이지 않음
# Export As metadata and profile options
Color Profile 색상 프로필
EXIF EXIF
@@ -3407,6 +3424,7 @@ curves Smooth 매끄럽게
# View › Guides › right-click a guide
Edit Selected Guides 선택한 안내선 편집
Clear Selected Guides 선택한 안내선 지우기
Make Selection 선택 영역 만들기
Bicubic Automatic 쌍입방 자동
Bicubic Smoother 쌍입방 더 부드럽게
Bicubic Sharper 쌍입방 더 선명하게
@@ -3492,3 +3510,8 @@ curves Smooth 매끄럽게
GPU adapter GPU 어댑터
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 먼저 열려 있는 액션 단계 대화 상자를 확인하거나 취소하세요
Pro Medium Gray 프로 중간 회색
Solarized Solarized
Breeze Breeze
Pro Light Gray 프로 밝은 회색
Pro Light 프로 밝게
Can't render this file because it contains an unexpected character in line 3189 and column 53.
+10 -3
View File
@@ -4,6 +4,13 @@ use super::*;
use crate::{PhotocraftApp, Services};
use serde_json::json;
/// The File menu's title as the menu bar draws it in `lang`: the translation, plus the English
/// access key where it has no Latin letter of its own ("文件(F)", #5863).
fn file_title(lang: Lang) -> String {
let (shown, _) = crate::menu_keys::localized(&[tr(lang, "File").to_string()], &["File"], &[Some('F')]);
shown.into_iter().next().unwrap_or_default()
}
fn lang(code: &str) -> Lang {
Lang::from_code(code).expect("registered language")
}
@@ -265,7 +272,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() {
let h = harness();
assert_eq!(h.state().session.prefs().interface.language, "auto");
assert_eq!(current(), lang(code), "{tag}");
assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang(code), "File")), "first-launch menu: {tag}");
assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "first-launch menu: {tag}");
});
});
}
@@ -316,7 +323,7 @@ fn an_explicit_language_survives_restart_on_a_different_system_language() {
let h = harness_with_services(services);
assert_eq!(h.state().session.prefs().interface.language, "ko");
assert_eq!(current(), lang("ko"));
assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang("ko"), "File")));
assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang("ko"))));
});
});
}
@@ -335,7 +342,7 @@ fn a_control_request_changes_the_drawing_language_in_the_same_frame() {
h.step();
assert_eq!(reply.try_recv().expect("response")["ok"], true);
assert_eq!(current(), lang(code));
assert!(drawn_text(&h).iter().any(|text| text == tr(lang(code), "File")), "the menu must change in this frame: {code}");
assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "the menu must change in this frame: {code}");
assert!(std::sync::Arc::ptr_eq(&document, &h.state().session.active().expect("document").doc), "switching language must preserve the document");
}
assert!(h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": []})).is_err());
+48
View File
@@ -906,6 +906,54 @@ mod tests {
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 1, 2, 2, 1]);
}
/// Counted status messages read in Simplified Chinese instead of falling back to English (they were
/// missing from the catalog).
#[test]
fn simplified_chinese_counted_messages_are_translated() {
let lang = Lang::from_code("zh-hans").expect("zh-hans registered");
assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "已播放 3 个步骤");
let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "水彩")]), "已从 水彩 导入 2 个画笔");
assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 个切片");
}
/// Counted status messages read in Korean instead of falling back to English (they were
/// missing from the catalog).
#[test]
fn korean_counted_messages_are_translated() {
let lang = Lang::from_code("ko").expect("ko registered");
assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "단계 3개를 재생했습니다");
let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "수채화")]), "수채화에서 브러시 2개를 가져왔습니다");
assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "분할 영역 5개");
}
/// Counted status messages read in Indonesian instead of falling back to English (they were
/// missing from the catalog).
#[test]
fn indonesian_counted_messages_are_translated() {
let lang = Lang::from_code("id").expect("id registered");
assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "3 langkah diputar");
let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "Cat Air")]), "2 kuas diimpor dari Cat Air");
assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 slice");
}
/// Counted status messages read in Polish with all three forms instead of falling back to
/// English (they were missing from the catalog).
#[test]
fn polish_counted_messages_use_all_three_forms() {
let pl = Lang::from_code("pl").expect("pl registered");
let steps = |n| trn(pl, n, "Played {n} step", "Played {n} steps");
assert_eq!(
[steps(1), steps(3), steps(5), steps(12), steps(22)],
["Odtworzono 1 krok", "Odtworzono 3 kroki", "Odtworzono 5 kroków", "Odtworzono 12 kroków", "Odtworzono 22 kroki"]
);
let brushes = trn(pl, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}");
assert_eq!(fmt(&brushes, &[("group", "Akwarele")]), "Zaimportowano 2 pędzle z Akwarele");
assert_eq!(trn(pl, 5, "{n} slice", "{n} slices"), "5 plasterków");
}
#[test]
fn czech_plural_rule() {
let forms: Vec<usize> = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect();
+6
View File
@@ -3976,6 +3976,7 @@ curves Smooth Vloeiend maken
Chisel Hard Beitel hard
Chisel Soft Beitel zacht
Open color-less EXR as ACEScg EXR zonder kleurruimte als ACEScg openen
Do not append "copy" to filename when saving a copy Geen "copy" aan de bestandsnaam toevoegen bij het opslaan van een kopie
# View › Guides › right-click a guide
Edit Selected Guides Geselecteerde hulplijnen bewerken
@@ -4062,3 +4063,8 @@ curves Smooth Vloeiend maken
GPU adapter GPU-adapter
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Bevestig of annuleer eerst het geopende dialoogvenster van de handelingsstap
Pro Medium Gray Pro middelgrijs
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro lichtgrijs
Pro Light Pro licht
Can't render this file because it contains an unexpected character in line 8 and column 34.
+22
View File
@@ -811,6 +811,22 @@
Split Warp Vertically Podziel wypaczenie w pionie
Statistics… Statystyki…
Refine Strokes Pociągnięcia udoskonalania
# Counted status and panel messages (plural forms: 1 | 2–4 except 12–14 | the rest)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Wyeksportowano {n} próbkę koloru do {file}|Wyeksportowano {n} próbki kolorów do {file}|Wyeksportowano {n} próbek kolorów do {file}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Zaimportowano {n} próbkę koloru z {file}|Zaimportowano {n} próbki kolorów z {file}|Zaimportowano {n} próbek kolorów z {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Zaimportowano {n} pędzel z {group}|Zaimportowano {n} pędzle z {group}|Zaimportowano {n} pędzli z {group}
@plural Played {n} step|Played {n} steps Odtworzono {n} krok|Odtworzono {n} kroki|Odtworzono {n} kroków
@plural {n} step|{n} steps {n} krok|{n} kroki|{n} kroków
@plural Saved for Web: {n} file|Saved for Web: {n} files Zapisano dla Internetu: {n} plik|Zapisano dla Internetu: {n} pliki|Zapisano dla Internetu: {n} plików
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Zaktualizowano {n} połączony obiekt inteligentny zmieniony na dysku|Zaktualizowano {n} połączone obiekty inteligentne zmienione na dysku|Zaktualizowano {n} połączonych obiektów inteligentnych zmienionych na dysku
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Usunięto {n} warstwę zapisaną w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstwy zapisane w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstw zapisanych w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.
@plural {n} plane|{n} planes {n} płaszczyzna|{n} płaszczyzny|{n} płaszczyzn
@plural {n} preset|{n} presets {n} ustawienie wstępne|{n} ustawienia wstępne|{n} ustawień wstępnych
@plural {n} sample point removed|{n} sample points removed Usunięto {n} punkt próbkowania|Usunięto {n} punkty próbkowania|Usunięto {n} punktów próbkowania
@plural {n} slice|{n} slices {n} plasterek|{n} plasterki|{n} plasterków
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} kolejne ostrzeżenie dotyczące ustawień wstępnych pędzli)|{warning} (+{n} kolejne ostrzeżenia dotyczące ustawień wstępnych pędzli)|{warning} (+{n} kolejnych ostrzeżeń dotyczących ustawień wstępnych pędzli)
@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} ostrzeżenie|{what}: {n} ostrzeżenia|{what}: {n} ostrzeżeń
@plural {n} item|{n} items {n} element|{n} elementy|{n} elementów
Auto Automatycznie
@@ -3814,6 +3830,7 @@ curves Smooth Wygładź
Chisel Hard Dłuto twarde
Chisel Soft Dłuto miękkie
Open color-less EXR as ACEScg Otwieraj EXR bez profilu koloru jako ACEScg
Do not append "copy" to filename when saving a copy Nie dodawaj „copy” do nazwy pliku przy zapisywaniu kopii
# View › Guides › right-click a guide
Edit Selected Guides Edytuj zaznaczone prowadnice
@@ -3902,3 +3919,8 @@ curves Smooth Wygładź
GPU adapter Karta GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Najpierw zatwierdź lub anuluj otwarte okno kroku operacji
Pro Medium Gray Pro (średni szary)
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro (jasnoszary)
Pro Light Pro (jasny)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+155
View File
@@ -4006,6 +4006,7 @@ layerLabel No Color Sem cor
Chisel Hard Cinzel duro
Chisel Soft Cinzel suave
Open color-less EXR as ACEScg Abrir EXR sem espaço de cor como ACEScg
Do not append "copy" to filename when saving a copy Não acrescentar "copy" ao nome do arquivo ao salvar uma cópia
# View › Guides › right-click a guide
Edit Selected Guides Editar guias selecionadas
@@ -4092,3 +4093,157 @@ layerLabel No Color Sem cor
GPU adapter Adaptador de GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Confirme ou cancele primeiro a caixa de diálogo aberta da etapa da ação
# Channels panel menu: channel commands, spot channels, Quick Mask, Color Indicates
New Channel… Novo canal…
Duplicate Channel Duplicar canal
Delete Channel Excluir canal
Rename Channel… Renomear canal…
New Spot Channel… Novo canal de cor especial…
Merge Spot Channel Mesclar canal de cor especial
Convert to Alpha Channel Converter em canal alfa
Split Channels Dividir canais
Merge Channels… Mesclar canais…
Exit Quick Mask Sair da máscara rápida
Color Indicates Masked Areas A cor indica áreas mascaradas
Color Indicates Selected Areas A cor indica áreas selecionadas
# Layer Style › Blending Options: advanced blending and Blend If
General Blending Mesclagem geral
Shallow Raso
Deep Profundo
Blend Interior Effects as Group Mesclar efeitos internos como grupo
Blend Clipped Layers as Group Mesclar camadas recortadas como grupo
Transparency Shapes Layer A transparência define a forma da camada
Layer Mask Hides Effects A máscara de camada oculta os efeitos
Vector Mask Hides Effects A máscara vetorial oculta os efeitos
This Layer: Esta camada:
Underlying Layer: Camada subjacente:
# Canvas right-click menus: pasteboard color, paths and shapes
Dark Gray Cinza-escuro
Medium Gray Cinza médio
Select Custom Color… Selecionar cor personalizada…
Color Picker (Custom Canvas Color) Seletor de cores (Cor personalizada da tela de pintura)
Make Work Path… Criar demarcador de trabalho…
Create Vector Mask Criar máscara vetorial
Make Selection… Fazer seleção…
Fill Path… Preencher demarcador…
Clipping Path… Demarcador de corte…
Free Transform Path Transformação livre do demarcador
Unite Shapes at Overlap Unir formas na sobreposição
Subtract Shapes at Overlap Subtrair formas na sobreposição
Copy Fill Copiar preenchimento
Copy Complete Stroke Copiar traçado completo
Paste Fill Colar preenchimento
Paste Complete Stroke Colar traçado completo
Make Symmetry Path Criar demarcador de simetria
Disable Symmetry Path Desativar demarcador de simetria
# Color Picker titles
Color Picker (Foreground Color) Seletor de cores (Cor de primeiro plano)
Color Picker (Background Color) Seletor de cores (Cor de fundo)
Pick Color From Image Escolher cor da imagem
# Rasterize prompts
This layer must be rasterized before proceeding. Esta camada precisa ser rasterizada antes de continuar.
This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta camada de texto precisa ser rasterizada antes de continuar. O texto não poderá mais ser editado. Rasterizar o texto?
This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta camada de forma precisa ser rasterizada antes de continuar. Ela não poderá mais ser editada como forma. Rasterizar a forma?
This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente precisa ser rasterizado antes de continuar. O conteúdo não poderá mais ser editado. Rasterizar o objeto inteligente?
This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta camada de preenchimento precisa ser rasterizada antes de continuar. O conteúdo do preenchimento não poderá mais ser editado. Rasterizar a camada?
# GPU and display status (status bar, System Info)
Graphics adapter: {adapter} Adaptador gráfico: {adapter}
Backend: {backend} ({type}) Backend: {backend} ({type})
Driver: {driver} Driver: {driver}
GPU backend preference: {backend} Preferência de backend da GPU: {backend}
Selected at launch: {backend} Selecionado ao iniciar: {backend}
Image compositor: {processor} Compositor de imagens: {processor}
Window renderer: {renderer} Renderizador da janela: {renderer}
Graphics adapter Adaptador gráfico
Software adapter Adaptador de software
unknown type tipo desconhecido
Fallback: {reason} Modo alternativo: {reason}
This session: {reason} Nesta sessão: {reason}
Platform: {os} {arch} Plataforma: {os} {arch}
GPU retry scheduled Nova tentativa da GPU agendada
Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Salve seu trabalho e reinicie o PhotoCraft para tentar a aceleração por GPU novamente. A renderização pela CPU continua ativa nesta sessão.
GPU device was lost; using the CPU renderer. O dispositivo da GPU foi perdido; usando o renderizador da CPU.
GPU error; using the CPU renderer. Erro da GPU; usando o renderizador da CPU.
Monitor profile: {profile} Perfil do monitor: {profile}
Monitor profile in use: {profile} Perfil do monitor em uso: {profile}
Monitor profile in use on {display}: {profile} Perfil do monitor em uso em {display}: {profile}
Monitor profile on {display}: {profile} Perfil do monitor em {display}: {profile}
Monitor profile on {display} (main window): {profile} Perfil do monitor em {display} (janela principal): {profile}
Display profiles: {reason} Perfis de tela: {reason}
Display profile not used Perfil de tela não usado
previous reading kept; the last re-read failed: {reason} leitura anterior mantida; a última releitura falhou: {reason}
The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A tela de pintura é exibida como sRGB. Escolha um perfil de monitor em Editar › Configurações de cores.
The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A tela de pintura em {display} é exibida como sRGB. Escolha um perfil de monitor em Editar › Configurações de cores.
# Status messages
Nothing to paste: the clipboard holds no image Nada para colar: a área de transferência não contém imagem
Opened {url} Aberto: {url}
Preferences saved Preferências salvas
Preferences were reset: {error} As preferências foram redefinidas: {error}
Couldn't save preferences: {error}. Retrying… Não foi possível salvar as preferências: {error}. Tentando novamente…
Preferences can't be saved Não é possível salvar as preferências
PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. O PhotoCraft continua tentando; até que um salvamento dê certo, as alterações nas preferências serão perdidas ao fechar.
Invalid panel layout; restored the default dock Layout de painéis inválido; a disposição padrão dos painéis foi restaurada
Recovered {name} Recuperado: {name}
Autosave failed: {error} Falha ao salvar automaticamente: {error}
Exported presets to {path} Predefinições exportadas para {path}
Ran script {name} Script executado: {name}
Sent to the printer ({spooler}) Enviado para a impressora ({spooler})
Printed to {path} Impresso em {path}
Exported {path} ({size}) Exportado: {path} ({size})
Exported {path} Exportado: {path}
Saved a copy as {path} Cópia salva como {path}
Exported contents to {path} Conteúdo exportado para {path}
Saved {path}; image assets: {n} in {dir} Salvo: {path}; recursos de imagem: {n} em {dir}
Couldn't open {name}: {error} Não foi possível abrir {name}: {error}
Added swatch {name} Amostra adicionada: {name}
{message} (notes: {n}; {warning}) {message} (observações: {n}; {warning})
Step {step} ({id}) failed: {error} Falha na etapa {step} ({id}): {error}
Could not rename the action: {error} Não foi possível renomear a ação: {error}
Action playback failed: {error} Falha ao reproduzir a ação: {error}
Clone source set at {x}, {y} Origem da clonagem definida em {x}, {y}
Color management: {error} Gerenciamento de cores: {error}
No custom shape "{name}": pick one in the options bar or Window › Shapes Nenhuma forma personalizada “{name}”: escolha uma na barra de opções ou em Janela › Formas
{label} is not available: {reason} {label} não está disponível: {reason}
Open Recent is unavailable on the web Abrir recentes não está disponível na web
Importing {what} from {file}… Importando {what} de {file}…
brushes pincéis
gradients degradês
This adjustment ({key}) is kept as imported and can't be edited yet. Este ajuste ({key}) é mantido como foi importado e ainda não pode ser editado.
{shortcut} assigned. {shortcut} atribuído.
{shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} já está em uso por {commands} e será removido de lá quando você clicar em OK.
# Panels and small controls
Grid view Visualização em grade
List view Visualização em lista
New Brush Preset Nova predefinição de pincel
Toggle the Brush Settings panel Alternar o painel Configurações do pincel
Scroll menu up Rolar o menu para cima
Scroll menu down Rolar o menu para baixo
{index} of {total} {index} de {total}
Data Set: {name} ({index}/{total}) Conjunto de dados: {name} ({index}/{total})
(none) (nenhum)
Apply {option}: {state} Aplicar {option}: {state}
on ativado
off desativado
# Liquify tools (tooltips)
Forward Warp Deformação para frente
Twirl Clockwise Torcer no sentido horário
Twirl Counterclockwise Torcer no sentido anti-horário
Pucker Franzir
Bloat Inflar
Push Left Empurrar para a esquerda
Freeze Mask Congelar máscara
Thaw Mask Descongelar máscara
Pro Medium Gray Pro cinza médio
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro cinza claro
Pro Light Pro claro
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -1087,6 +1087,7 @@
# Paragraph and character panels
Font Size: Кегль:
Font size Кегль
Type: Текст:
Style Стиль
Style: Normal Стиль: Обычный
@@ -3188,6 +3189,7 @@ curves Smooth Сгладить
Chisel Hard Жесткая огранка
Chisel Soft Мягкая огранка
Open color-less EXR as ACEScg Открывать EXR без цветового профиля как ACEScg
Do not append "copy" to filename when saving a copy Не добавлять «copy» к имени файла при сохранении копии
# View › Guides › right-click a guide
Edit Selected Guides Изменить выбранные направляющие
@@ -3280,3 +3282,8 @@ curves Smooth Сгладить
GPU adapter Адаптер GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Сначала подтвердите или отмените открытый диалог шага операции
Pro Medium Gray Средне-серая Pro
Solarized Solarized
Breeze Breeze
Pro Light Gray Светло-серая Pro
Pro Light Светлая Pro
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -4268,6 +4268,7 @@ shape Appearance Izgled
Chisel Hard Tvrdo klesanje
Chisel Soft Meko klesanje
Open color-less EXR as ACEScg Otvori EXR bez podatka o bojama kao ACEScg
Do not append "copy" to filename when saving a copy Ne dodaji „copy“ u ime fajla pri čuvanju kopije
Show home recent files Prikaži nedavne datoteke na početnom ekranu
# View › Guides › right-click a guide
@@ -4373,3 +4374,8 @@ shape Appearance Izgled
GPU adapter GPU adapter
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Prvo potvrdite ili otkažite otvoreni dijalog koraka radnje
Pro Medium Gray Pro srednje siva
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro svetlosiva
Pro Light Pro svetla
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -3928,6 +3928,7 @@ cameraData Lens Lens
Chisel Hard Sert Keski
Chisel Soft Yumuşak Keski
Open color-less EXR as ACEScg Renk bilgisi olmayan EXR'yi ACEScg olarak aç
Do not append "copy" to filename when saving a copy Kopya kaydederken dosya adına "copy" ekleme
# View › Guides › right-click a guide
Edit Selected Guides Seçili Kılavuzları Düzenle
@@ -4032,3 +4033,8 @@ cameraData Lens Lens
GPU adapter GPU bağdaştırıcısı
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin
Pro Medium Gray Pro Orta Gri
Solarized Solarize
Breeze Esinti
Pro Light Gray Pro Açık Gri
Pro Light Profesyonel Işık
Can't render this file because it contains an unexpected character in line 8 and column 34.
+6
View File
@@ -3915,6 +3915,7 @@ curves Smooth Згладити
Chisel Hard Жорстке різьблення
Chisel Soft М'яке різьблення
Open color-less EXR as ACEScg Відкривати EXR без колірного профілю як ACEScg
Do not append "copy" to filename when saving a copy Не додавати «copy» до імені файлу під час збереження копії
# View › Guides › right-click a guide
Edit Selected Guides Редагувати вибрані напрямні
@@ -4011,3 +4012,8 @@ curves Smooth Згладити
GPU adapter Адаптер GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Спочатку підтвердьте або скасуйте відкритий діалог кроку операції
Pro Medium Gray Середньосіра «Профі»
Solarized Solarized
Breeze Breeze
Pro Light Gray Світло-сіра «Профі»
Pro Light Світла «Профі»
Can't render this file because it contains an unexpected character in line 1394 and column 18.
+6
View File
@@ -3686,6 +3686,7 @@ curves Smooth Làm mượt
Chisel Hard Đục cứng
Chisel Soft Đục mềm
Open color-less EXR as ACEScg Mở EXR không khai báo màu sắc dưới dạng ACEScg
Do not append "copy" to filename when saving a copy Không thêm "copy" vào tên tệp khi lưu bản sao
# View › Guides › right-click a guide
Edit Selected Guides Sửa đường căn đã chọn
@@ -3793,3 +3794,8 @@ curves Smooth Làm mượt
GPU adapter Bộ điều hợp GPU
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Hãy xác nhận hoặc hủy hộp thoại bước hành động đang mở trước
Pro Medium Gray Pro xám vừa
Solarized Solarized
Breeze Breeze
Pro Light Gray Pro xám nhạt
Pro Light Pro sáng
Can't render this file because it contains an unexpected character in line 6 and column 47.
+23
View File
@@ -819,6 +819,22 @@
Split Warp Vertically 垂直分割变形
Statistics… 统计…
Refine Strokes 调整描边
# Counted status and panel messages
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已将 {n} 个色板导出到 {file}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已从 {file} 导入 {n} 个色板
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已从 {group} 导入 {n} 个画笔
@plural Played {n} step|Played {n} steps 已播放 {n} 个步骤
@plural {n} step|{n} steps {n} 个步骤
@plural Saved for Web: {n} file|Saved for Web: {n} files 已存储为 Web 所用格式:{n} 个文件
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 个在磁盘上更改过的链接智能对象
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此图层复合记录的 {n} 个图层已被删除。请更新图层复合,或清除警告。
@plural {n} plane|{n} planes {n} 个平面
@plural {n} preset|{n} presets {n} 个预设
@plural {n} sample point removed|{n} sample points removed 已移除 {n} 个取样点
@plural {n} slice|{n} slices {n} 个切片
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(另有 {n} 条画笔预设警告)
@plural {what} with {n} warning|{what} with {n} warnings {what},含 {n} 条警告
@plural {n} item|{n} items {n} 项
Auto 自动
(print to the printer) (输出到打印机)
@@ -2842,6 +2858,7 @@ curves Smooth 平滑
Chisel Hard 雕刻清晰
Chisel Soft 雕刻柔和
Open color-less EXR as ACEScg 将无色彩空间信息的 EXR 以 ACEScg 打开
Do not append "copy" to filename when saving a copy 存储副本时不在文件名后添加“copy”
# Export As metadata and profile options
Color Profile 颜色配置文件
EXIF EXIF
@@ -2849,6 +2866,7 @@ curves Smooth 平滑
# View › Guides › right-click a guide
Edit Selected Guides 编辑所选参考线
Clear Selected Guides 清除所选参考线
Make Selection 建立选区
Bicubic Automatic 双三次(自动)
Bicubic Smoother 双三次(较平滑)
Bicubic Sharper 双三次(较锐利)
@@ -2937,3 +2955,8 @@ curves Smooth 平滑
GPU adapter GPU 适配器
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 请先确认或取消打开的动作步骤对话框
Pro Medium Gray 专业中灰
Solarized Solarized
Breeze Breeze
Pro Light Gray 专业浅灰
Pro Light 专业浅色
Can't render this file because it contains an unexpected character in line 2623 and column 53.
+7
View File
@@ -2827,6 +2827,7 @@ curves Smooth 平滑
Chisel Hard 雕刻清晰
Chisel Soft 雕刻柔和
Open color-less EXR as ACEScg 將無色彩空間資訊的 EXR 以 ACEScg 開啟
Do not append "copy" to filename when saving a copy 儲存副本時不在檔名後加上「copy」
# Export As metadata and profile options
Color Profile 色彩描述檔
EXIF EXIF
@@ -2834,6 +2835,7 @@ curves Smooth 平滑
# View › Guides › right-click a guide
Edit Selected Guides 編輯所選參考線
Clear Selected Guides 清除所選參考線
Make Selection 建立選取範圍
Bicubic Automatic 雙三次 (自動)
Bicubic Smoother 雙三次 (較平滑)
Bicubic Sharper 雙三次 (較銳利)
@@ -2922,3 +2924,8 @@ curves Smooth 平滑
GPU adapter GPU 介面卡
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 請先確認或取消開啟中的動作步驟對話框
Pro Medium Gray 專業中灰
Solarized Solarized
Breeze Breeze
Pro Light Gray 專業淺灰
Pro Light 專業淺色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -6,6 +6,20 @@ pub static ICONS: &[(&str, &[u8])] = &[
("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")),
("align-left", include_bytes!("../../../assets/icons/align-left.svg")),
("align-right", include_bytes!("../../../assets/icons/align-right.svg")),
("align-edge-bottom", include_bytes!("../../../assets/icons/align-edge-bottom.svg")),
("align-edge-hcenter", include_bytes!("../../../assets/icons/align-edge-hcenter.svg")),
("align-edge-left", include_bytes!("../../../assets/icons/align-edge-left.svg")),
("align-edge-right", include_bytes!("../../../assets/icons/align-edge-right.svg")),
("align-edge-top", include_bytes!("../../../assets/icons/align-edge-top.svg")),
("align-edge-vcenter", include_bytes!("../../../assets/icons/align-edge-vcenter.svg")),
("distribute-bottom", include_bytes!("../../../assets/icons/distribute-bottom.svg")),
("distribute-hcenter", include_bytes!("../../../assets/icons/distribute-hcenter.svg")),
("distribute-horizontal", include_bytes!("../../../assets/icons/distribute-horizontal.svg")),
("distribute-left", include_bytes!("../../../assets/icons/distribute-left.svg")),
("distribute-right", include_bytes!("../../../assets/icons/distribute-right.svg")),
("distribute-top", include_bytes!("../../../assets/icons/distribute-top.svg")),
("distribute-vcenter", include_bytes!("../../../assets/icons/distribute-vcenter.svg")),
("distribute-vertical", include_bytes!("../../../assets/icons/distribute-vertical.svg")),
("app-window", include_bytes!("../../../assets/icons/app-window.svg")),
("artboard", include_bytes!("../../../assets/icons/artboard.svg")),
("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")),
@@ -208,3 +208,42 @@ fn custom_page_up_and_page_down_bindings_fire() {
}
assert_eq!(crate::shortcuts::parse("Shift+Pageup"), Some(egui::KeyboardShortcut::new(Modifiers::SHIFT, Key::PageUp)));
}
/// A click on the empty space below the last row of the Layers panel deselects every layer, as
/// in Photoshop (#8100); a click on a row still selects it.
#[test]
fn a_click_below_the_layers_deselects_them() {
use egui::{Event, PointerButton, pos2, vec2};
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let top = s.execute("layer.new.layer", json!({"name": "Top"})).unwrap()["layer"].as_u64().unwrap();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(s, crate::Services::default())
});
let ctx = h.ctx.clone();
let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}}));
crate::control::handle(h.state_mut(), &ctx, &r);
h.run_steps(8);
let click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2| {
h.hover_at(at);
h.run_steps(1);
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
};
let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().selected_layers();
assert_eq!(selected(&h), vec![photocraft_doc::LayerId(top)]);
let rows = crate::layer_row_ui::recorded(&h.ctx);
let bottom = rows.iter().map(|r| r.row.bottom()).fold(f32::MIN, f32::max);
let row = rows.iter().find(|r| r.layer == top).unwrap().row;
click(&mut h, pos2(row.center().x, bottom + 40.0));
assert!(selected(&h).is_empty(), "the empty space deselects every layer");
assert_eq!(h.state().session.active().unwrap().active_layer, None);
// Past the double-click window, on the row as it is now drawn.
h.run_steps(40);
let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == top).unwrap().row;
click(&mut h, pos2(row.left() + 46.0, row.center().y));
assert_eq!(selected(&h), vec![photocraft_doc::LayerId(top)], "a row still selects");
}
+6 -2
View File
@@ -19,6 +19,7 @@ pub mod adjust_dialog;
pub mod adjust_editors;
pub mod adjust_preview;
pub mod adjust_ui;
mod align_bar;
mod alt_grab;
pub mod analysis_ui;
pub mod anchor_tools;
@@ -1520,7 +1521,9 @@ impl PhotocraftApp {
}
let import = self.services.import.as_ref().ok_or("no importer configured")?;
let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
let (doc, warnings) = import(name, bytes, max_svg_group_depth)?;
let (mut doc, warnings) = import(name, bytes, max_svg_group_depth)?;
// Preferences › Raw Defaults › Resolution, as the engine's open applies it.
photocraft_engine::file_cmds::raw_defaults(&self.session, &warnings, &mut doc);
// Edit › Color Settings policies apply on open; Ask When Opening holds the file back
// until its prompt is answered, and later files wait behind it (`open_prompt`).
// Missing fonts are read from the document itself: it may not be open yet (#4559).
@@ -1570,7 +1573,8 @@ impl PhotocraftApp {
fn import_automation_document(&mut self, name: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
let import = self.services.import.as_ref().ok_or("no importer configured")?;
let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
let (doc, warnings) = import(name, bytes, max_svg_group_depth)?;
let (mut doc, warnings) = import(name, bytes, max_svg_group_depth)?;
photocraft_engine::file_cmds::raw_defaults(&self.session, &warnings, &mut doc);
// The caller records the path it read from.
self.session.add_document(doc, None);
if let Some(st) = self.session.active_mut() {
+2 -1
View File
@@ -420,7 +420,8 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, hover:
}
let screen = |q: &[f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
let pts: Vec<Pos2> = m.path.iter().chain(m.live.iter().skip(1)).map(screen).collect();
crate::tool_feedback::draw_ants(painter, &pts, false);
// Solid until the selection is made, as in Photoshop (#7495).
crate::tool_feedback::draw_solid_path(painter, &pts, false);
for p in m.anchors.iter().filter_map(|&i| m.path.get(i)).map(screen) {
let r = Rect::from_center_size(p, vec2(5.0, 5.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
+45 -1
View File
@@ -8,7 +8,8 @@
//! They are assigned per menu level from the labels as shown (so translated), unique within
//! the level: Photoshop's letters for the top menus where the label has them (F E I L Y S T V
//! W H), else the first unused letter, word starts first. Only ASCII letters and digits are keys
//! the keyboard reports as such, so a label without one (Japanese, Chinese…) has no mnemonic.
//! the keyboard reports as such, so a label without one (Japanese, Chinese…) shows its English
//! label's letter after it, as Windows menus do in those languages: "文件(F)" ([`localized`]).
//!
//! The state is view state in egui memory, like [`crate::menu_nav::Nav`]. On macOS the native
//! menu bar replaces the in-window one and this never runs.
@@ -128,6 +129,49 @@ pub fn assign(labels: &[&str], preferred: &[Option<char>]) -> Vec<Option<char>>
out
}
/// The labels to show and their mnemonics for translated `labels` whose English originals are
/// `english` (same order). A label with an ASCII letter or digit keeps its text and is assigned
/// as [`assign`] does. One without (Chinese, Japanese, Korean…) can't be typed on its own, so it
/// shows its English label's letter after it in parentheses and takes that letter, as Windows menus
/// do in those languages ("文件(F)", #5863). Mnemonics stay unique within the group, and a shown
/// "(F)" is always the row's key.
pub fn localized(labels: &[String], english: &[&str], preferred: &[Option<char>]) -> (Vec<String>, Vec<Option<char>>) {
let typed = |l: &str| l.chars().any(|c| c.is_ascii_alphanumeric());
// The key goes before a trailing ellipsis, as Windows writes it: "新建(N)…".
let with_key = |l: &str, k: char| match l.strip_suffix('…').map(|b| (b, "…")).or_else(|| l.strip_suffix("...").map(|b| (b, "..."))) {
Some((base, tail)) => format!("{base}({k}){tail}"),
None => format!("{l}({k})"),
};
let english_keys = assign(english, preferred);
let mut shown = Vec::with_capacity(labels.len());
let mut pref = Vec::with_capacity(labels.len());
for (i, label) in labels.iter().enumerate() {
match english_keys.get(i).copied().flatten().filter(|_| !typed(label)) {
Some(k) => {
shown.push(with_key(label, k));
pref.push(Some(k));
}
None => {
shown.push(label.clone());
pref.push(preferred.get(i).copied().flatten());
}
}
}
// The suffixed labels take their keys first: a label with letters of its own must not take
// the key a suffix shows ("Camera Raw 滤镜…" before "转换为智能滤镜(C)" leaves C to the latter).
let suffixed = |i: &usize| shown.get(*i) != labels.get(*i);
let order: Vec<usize> = (0..shown.len()).filter(suffixed).chain((0..shown.len()).filter(|i| !suffixed(i))).collect();
let ordered: Vec<&str> = order.iter().map(|&i| shown.get(i).map_or("", String::as_str)).collect();
let ordered_pref: Vec<Option<char>> = order.iter().map(|&i| pref.get(i).copied().flatten()).collect();
let mut keys = vec![None; shown.len()];
for (&i, key) in order.iter().zip(assign(&ordered, &ordered_pref)) {
if let Some(slot) = keys.get_mut(i) {
*slot = key;
}
}
(shown, keys)
}
/// `prefix + label` as menu text with the mnemonic's first occurrence in `label` underlined
/// (none when `mnemonic` is None). `color` is the text colour; None takes the widget's.
pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option<char>, color: Option<Color32>) -> egui::WidgetText {
+28
View File
@@ -242,6 +242,34 @@ fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() {
assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again");
}
/// #5863: a label with no Latin letter (Chinese, Japanese…) shows its English label's letter in
/// parentheses, before a trailing ellipsis, and takes it as its mnemonic; labels with letters and
/// the top menus' Photoshop letters are as before, and the letters stay unique.
#[test]
fn labels_without_latin_letters_show_their_english_key() {
let labels: Vec<String> = ["文件", "编辑", "Image", "图层"].map(String::from).to_vec();
let english = ["File", "Edit", "Image", "Layer"];
let preferred = [Some('F'), Some('E'), Some('I'), Some('L')];
let (shown, keys) = crate::menu_keys::localized(&labels, &english, &preferred);
assert_eq!(shown, ["文件(F)", "编辑(E)", "Image", "图层(L)"]);
assert_eq!(keys, [Some('F'), Some('E'), Some('I'), Some('L')]);
// Rows: the key goes before the ellipsis; a clash takes the next letter of the shown label.
let labels: Vec<String> = ["新建…", "打开...", "关闭"].map(String::from).to_vec();
let (shown, keys) = crate::menu_keys::localized(&labels, &["New…", "Open...", "Close"], &[]);
assert_eq!(shown, ["新建(N)…", "打开(O)...", "关闭(C)"]);
assert_eq!(keys, [Some('N'), Some('O'), Some('C')]);
let (shown, keys) = crate::menu_keys::localized(&["关闭".to_string(), "复制".to_string()], &["Close", "Copy"], &[]);
assert_eq!(shown, ["关闭(C)", "复制(O)"]);
assert_eq!(keys, [Some('C'), Some('O')]);
// A shown "(C)" keeps its key even when a label with letters of its own comes first.
let (shown, keys) = crate::menu_keys::localized(&["Copy".to_string(), "关闭".to_string()], &["Open", "Close"], &[]);
assert_eq!(shown, ["Copy", "关闭(C)"]);
assert_eq!(keys, [Some('O'), Some('C')]);
// An English UI is unchanged.
let labels: Vec<String> = ["File", "Edit"].map(String::from).to_vec();
assert_eq!(crate::menu_keys::localized(&labels, &["File", "Edit"], &[Some('F'), Some('E')]).0, labels);
}
/// The whole app: with the bar focused by an Alt tap, a command shortcut leaves the bar and runs
/// in the same press (#6573); the bar's own keys still drive it.
#[test]
+12 -9
View File
@@ -1150,9 +1150,10 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
let mut keys = crate::menu_keys::Keys::load(ui.ctx());
keys.take_first_row(&mut nav);
nav.underline = keys.show_underlines(ui.ctx());
let top_labels: Vec<String> = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect();
let translated: Vec<String> = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect();
let preferred: Vec<Option<char>> = TOP_MENUS.iter().map(|top| crate::menu_keys::TOP_PREFERRED.iter().find(|(t, _)| t == top).map(|(_, c)| *c)).collect();
let top_mnemonics = crate::menu_keys::assign(&top_labels.iter().map(String::as_str).collect::<Vec<_>>(), &preferred);
// A title with no Latin letter shows its English key: "文件(F)" (#5863).
let (top_labels, top_mnemonics) = crate::menu_keys::localized(&translated, &TOP_MENUS, &preferred);
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let v = &mut ui.style_mut().visuals;
@@ -1438,24 +1439,25 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
// of its first child.
// Each row's mnemonic (#4988), unique within this level, in display order: the leaves' labels
// and the submenus' names as shown.
let shown_labels: Vec<String> = {
// A label with no Latin letter shows its English label's key after it: "新建(N)…" (#5863).
let (translated, english): (Vec<String>, Vec<&str>) = {
let mut subs: Vec<&str> = Vec::new();
items
.iter()
.filter_map(|it| {
if it.path.len() == depth {
(it.label != "---").then(|| translated_menu_label(lang, it))
(it.label != "---").then(|| (translated_menu_label(lang, it), it.label.as_str()))
} else {
let name = it.path.get(depth).map(String::as_str)?;
(!subs.contains(&name)).then(|| {
subs.push(name);
crate::i18n::tr(lang, name).to_string()
(crate::i18n::tr(lang, name).to_string(), name)
})
}
})
.collect()
.unzip()
};
let mnemonics = crate::menu_keys::assign(&shown_labels.iter().map(String::as_str).collect::<Vec<_>>(), &[]);
let (shown_labels, mnemonics) = crate::menu_keys::localized(&translated, &english, &[]);
let mut next_row = 0usize;
let mut shown_subs: Vec<&str> = Vec::new();
let mut last_was_sep = true;
@@ -1468,7 +1470,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
}
continue;
}
let label = translated_menu_label(lang, it);
let label = shown_labels.get(next_row).cloned().unwrap_or_else(|| translated_menu_label(lang, it));
let mnemonic = mnemonics.get(next_row).copied().flatten();
next_row += 1;
// Checked, unchecked and plain rows share one checkmark column (#5705).
@@ -1504,8 +1506,9 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect();
let enabled = submenu_enabled(&child);
let mnemonic = mnemonics.get(next_row).copied().flatten();
let shown = shown_labels.get(next_row).cloned().unwrap_or_else(|| crate::i18n::tr(lang, name).to_string());
next_row += 1;
let title = crate::menu_keys::menu_row_text(ui, false, crate::i18n::tr(lang, name), mnemonic.filter(|_| nav.underline));
let title = crate::menu_keys::menu_row_text(ui, false, &shown, mnemonic.filter(|_| nav.underline));
ui.add_enabled_ui(enabled, |ui| {
nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| {
let (response, inner) = submenu_row(ui, title, |ui| render_level(ui, &child, depth + 1, clicked, nav));
+1 -1
View File
@@ -865,7 +865,7 @@ mod tests {
};
let themes = ids(children);
assert_eq!(themes.last().map(String::as_str), Some("window.theme.toggle"));
assert!(themes.contains(&"window.theme.pro".to_string()) && themes.contains(&"window.theme.classic".to_string()));
assert!(themes.contains(&"[Dark Theme]".to_string()) && themes.contains(&"[Light Theme]".to_string()));
assert_eq!(l.bar.find("window.theme.classic").unwrap().checked, Some(true));
assert_eq!(l.bar.find("window.theme.pro").unwrap().checked, Some(false));
assert_eq!(l.bar.find("window.theme.toggle").unwrap().label, "Next Appearance Mode");
+7 -28
View File
@@ -1461,34 +1461,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
widgets::vline(ui, 22.0);
widgets::checkbox(ui, &mut o.move_show_transform, tl!("Show Transform Controls"));
widgets::vline(ui, 22.0);
ui.spacing_mut().item_spacing.x = 0.0;
for (icon, tip, cmd) in [
("panels-top-left", tl!("Align left edges"), "layer.align.leftEdges"),
("app-window", tl!("Align horizontal centers"), "layer.align.horizontalCenters"),
("panel-right", tl!("Align right edges"), "layer.align.rightEdges"),
] {
let on = app.session.is_enabled(cmd);
if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, tip)).inner.clicked() {
let _ = app.run(cmd, json!({}));
}
}
ui.add_space(6.0);
let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute"));
egui::Popup::menu(&more).show(|ui| {
ui.set_min_width(200.0);
let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| {
ui.label(egui::RichText::new(tl!(&title)).small().color(t.text_dim));
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) {
if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(tl!(c.label))).clicked() {
let _ = app.run(c.id, json!({}));
ui.close();
}
}
};
section(ui, tl!("Align"), "layer.align.");
ui.separator();
section(ui, tl!("Distribute"), "layer.distribute.");
});
crate::align_bar::options(app, ui);
}
Tool::RectMarquee | Tool::EllipseMarquee => hint(
ui,
@@ -1515,6 +1488,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// turned on outside the Pro themes.
widgets::checkbox(ui, &mut app.ui.tool_options.move_show_transform, tl!("Show Transform Controls"));
hint(ui, tl!("Drag to move the active layer"));
widgets::vline(ui, 22.0);
crate::align_bar::options(app, ui);
}
Tool::Eyedropper => crate::eyedropper_ui::options(app, ui),
Tool::Zoom => {
@@ -2781,6 +2756,10 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if let Some(&(last, last_rect)) = drawn.last() {
let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom()));
layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions);
// A click on that empty space deselects every layer, as in Photoshop (#8100).
if below.height() > 0.0 && ui.interact(below, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() {
actions.push(("select.deselectLayers".into(), json!({})));
}
}
// Tab / ⇧Tab in a rename: rename the layer below / above next, as in Photoshop. The
// Background isn't renamed in place (double-clicking it converts it), so it ends there.
+4
View File
@@ -1228,6 +1228,10 @@ fn humanize(key: &str) -> String {
}
// Performance: presentation without vsync (#1343); like the other graphics settings, it
// takes effect when the window next opens.
// File Handling: Photoshop's wording (#8080).
if key == "doNotAppendCopy" {
return "Do not append \"copy\" to filename when saving a copy".into();
}
if key == "lowLatencyDrawing" {
return "Low-Latency Drawing (vsync off, applies at next launch)".into();
}
+18 -10
View File
@@ -113,6 +113,23 @@ impl ThemeKind {
pub fn is_pro(self) -> bool {
matches!(self, ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::ProLightGray | ThemeKind::ProLight)
}
/// Whether this theme belongs to the dark appearance family.
pub fn is_dark(self) -> bool {
matches!(
self,
ThemeKind::Studio
| ThemeKind::Pro
| ThemeKind::ProMedium
| ThemeKind::SolarizedDark
| ThemeKind::AdwaitaDark
| ThemeKind::BreezeDark
| ThemeKind::SoftDark
)
}
/// Whether this theme belongs to the light appearance family.
pub fn is_light(self) -> bool {
!self.is_dark()
}
pub fn next(self) -> Self {
let i = Self::ALL.iter().position(|k| *k == self).unwrap_or(0);
Self::ALL[(i + 1) % Self::ALL.len()]
@@ -822,16 +839,7 @@ impl Tokens {
}
pub fn dark(&self) -> bool {
matches!(
self.kind,
ThemeKind::Studio
| ThemeKind::Pro
| ThemeKind::ProMedium
| ThemeKind::SolarizedDark
| ThemeKind::AdwaitaDark
| ThemeKind::BreezeDark
| ThemeKind::SoftDark
)
self.kind.is_dark()
}
}
+26
View File
@@ -217,6 +217,20 @@ pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) {
painter.extend(ants(points, closed, phase));
}
/// A selection outline still being drawn (Lasso, Polygonal Lasso, Magnetic Lasso): a solid line,
/// as Photoshop draws it until the selection is made (#7495). Marching ants are for selections
/// that exist. A thin dark line on a soft light edge: Photoshop's look on light pixels, and still
/// visible on dark ones.
pub fn solid_path(points: &[Pos2], closed: bool) -> Vec<Shape> {
let make = |s: Stroke| if closed { Shape::closed_line(points.to_vec(), s) } else { Shape::line(points.to_vec(), s) };
vec![make(Stroke::new(3.0, Color32::from_white_alpha(140))), make(Stroke::new(1.0, Color32::BLACK))]
}
/// Draw [`solid_path`].
pub fn draw_solid_path(painter: &egui::Painter, points: &[Pos2], closed: bool) {
painter.extend(solid_path(points, closed));
}
/// Polygon approximating the ellipse inscribed in `r` (enough vertices to look smooth).
pub fn ellipse_points(r: egui::Rect) -> Vec<Pos2> {
let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0);
@@ -294,6 +308,18 @@ mod tests {
assert!(badge_glyph(Badge::Intersect, c, 3.0).iter().all(|[a, b]| a.x != b.x && a.y != b.y));
}
/// #7495: an outline still being drawn is one unbroken line (no dashes, nothing marching),
/// black over a light edge.
#[test]
fn solid_path_is_one_unbroken_dark_line_on_a_light_edge() {
let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)];
for closed in [false, true] {
let shapes = solid_path(&pts, closed);
assert!(shapes.iter().all(|s| matches!(s, Shape::Path(p) if p.closed == closed && p.points.len() == 3)), "{shapes:?}");
assert_eq!(strokes(&shapes), vec![Color32::from_white_alpha(140), Color32::BLACK], "the dark line on top");
}
}
#[test]
fn ants_use_two_contrasting_colours_and_march() {
let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)];
+4 -1
View File
@@ -1925,8 +1925,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
apply(app, ui.ctx(), json!({"fontStyle": style}));
}
}
let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover());
let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover());
crate::icons::paint(ui, r, "type", 13.0, t.icon);
icon.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tl!("Font size")));
// Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437).
crate::widgets::scrub_option_icon(ui, &icon, tl!("Font size"));
let (unit, label) = type_unit(app);
let mut display = size * unit;
if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() {
+104
View File
@@ -455,6 +455,110 @@ fn size_drag_is_live_and_one_history_step_per_drag() {
assert_eq!(size_at(h.state(), id, 0), 20.0);
}
fn options_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(900.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
ui.horizontal(|ui| super::options_bar(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(3);
h
}
/// Drags the options bar's "T" icon `dx` points to the right with `mods` held.
fn scrub_size_icon(h: &mut Harness<'static, PhotocraftApp>, dx: f32, mods: Modifiers) {
let icon = h.get_by_label("Font size").rect().center();
h.hover_at(icon);
h.run_steps(1);
press(h, icon, true);
for k in 1..=5 {
h.event(egui::Event::ModifiersChanged(mods));
h.hover_at(icon + vec2(dx * k as f32 / 5.0, 0.0));
h.run_steps(1);
}
press(h, icon + vec2(dx, 0.0), false);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn history_len(h: &Harness<'static, PhotocraftApp>) -> usize {
h.state().session.active().unwrap().history.entries().len()
}
/// Photoshop's options bar "T" icon is a scrubby slider for the size beside it, like the
/// Character panel's icons (#6437): a point per point dragged, the whole drag one undo step.
#[test]
fn options_bar_t_icon_scrubs_the_size_in_one_history_step() {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
let mut h = options_harness(app);
let steps = history_len(&h);
scrub_size_icon(&mut h, 30.0, Modifiers::NONE);
assert_eq!(size_at(h.state(), id, 0), 50.0, "30 points to the right: 20 pt → 50 pt");
assert_eq!(history_len(&h), steps + 1, "one history step for the whole drag");
assert!(h.state_mut().session.undo());
assert_eq!(size_at(h.state(), id, 0), 20.0);
}
/// While editing with characters selected, scrubbing the "T" icon sizes only the selection.
#[test]
fn options_bar_t_icon_scrubs_the_selected_characters_only() {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "Hello world", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
app.ui.text_edit = Some(crate::state::TextEdit {
layer: id.0,
caret: 11,
anchor: 6,
session: "s".into(),
created: false,
mask: false,
dragging: false,
resize: None,
preedit: None,
document: None,
});
let mut h = options_harness(app);
scrub_size_icon(&mut h, -8.0, Modifiers::NONE);
let sizes = [0, 5, 6, 10].map(|ci| size_at(h.state(), id, ci));
assert_eq!(sizes, [20.0, 20.0, 12.0, 12.0], "\"world\" goes 20 pt → 12 pt, \"Hello \" stays");
}
/// Scrubbing the "T" icon stops at the size field's limits: 1 pt and 1296 pt.
#[test]
fn options_bar_t_icon_scrub_stops_at_the_size_limits() {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
let mut h = options_harness(app);
scrub_size_icon(&mut h, -60.0, Modifiers::NONE);
assert_eq!(size_at(h.state(), id, 0), 1.0, "60 points left of 20 pt stops at the minimum");
scrub_size_icon(&mut h, 200.0, Modifiers::SHIFT);
assert_eq!(size_at(h.state(), id, 0), 1296.0, "Shift (10 pt per point) past the top stops at the maximum");
}
/// Rests the pointer on the widget named `label` past the tooltip delay; true when its tooltip
/// (a second node with the same name) shows.
fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, label: &str) -> bool {
let at = h.get_by_label(label).rect().center();
h.hover_at(at);
h.run_steps(8);
h.query_all_by_label(label).count() == 2
}
/// The scrubby "T" icon keeps its "Font size" tooltip: the size field's drag area, laid over the
/// icon after it, must not take its hover.
#[test]
fn options_bar_t_icon_shows_its_tooltip() {
let mut app = new_app();
app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap();
let mut h = options_harness(app);
assert!(tooltip_shows(&mut h, "Set the text color"), "the probe sees an ordinary tooltip");
h.hover_at(egui::pos2(890.0, 55.0));
h.run_steps(4);
assert!(tooltip_shows(&mut h, "Font size"), "hovering the \"T\" icon shows \"Font size\"");
}
/// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the
/// column's centre line.
fn vglyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 {
+12
View File
@@ -1292,6 +1292,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
),
),
}
// Path mode: Make Selection from the path being drawn or the active path, as Photoshop's
// options bar's "Make: Selection…" does (#5703).
if app.ui.tool_options.vector_mode == "path" {
crate::widgets::vline(ui, 22.0);
let has_path = active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
if ui.add_enabled(has_path, egui::Button::new(tl!("Make Selection"))).clicked()
&& let Err(e) = crate::canvas_tool_menu::make_selection(app)
{
app.ui.status = e;
app.ui.status_error = true;
}
}
return true;
}
let mut mode = "shape".to_string();
+4 -1
View File
@@ -1473,7 +1473,10 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value)
fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
let doc = app.session.active().ok_or("no document")?.doc.clone();
let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a);
let suggested = format!("{stem} copy.psd");
// "<name> copy.psd", or with Preferences › File Handling › Do not append "copy" to filename
// when saving a copy the document's own name (only the extension may change), as in
// Photoshop (#8080).
let suggested = format!("{stem}{}.psd", app.session.prefs().file_handling.copy_suffix());
// The copy is of the document as it was when asked.
app.pick_save(&suggested, move |app, path| {
// An RGB Targa asks for its bits per pixel first, and a DDS texture for its compression;
+20 -1
View File
@@ -405,6 +405,8 @@ struct LabelScrub {
max_gap: f32,
/// Options captions defer interaction until the adjacent field bounds are known.
option_area: bool,
/// An options icon's tooltip, shown by the drag area laid over it (which takes its hover).
tip: Option<&'static str>,
/// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast).
steps: f32,
started: bool,
@@ -426,12 +428,23 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response {
/// A tool-options caption whose scrub target includes the adjacent field's outer box.
/// Keep the original label bounds for linking; register the hit area before the numeric editor.
pub fn scrub_option_label(ui: &Ui, label: &Response) {
scrub_option_area(ui, label, None);
}
/// [`scrub_option_label`] for an icon with tooltip `tip`. The field's drag area is laid over the
/// icon after it, so egui hovers the area and not the icon: `on_hover_text` on the icon never shows.
pub fn scrub_option_icon(ui: &Ui, icon: &Response, tip: &'static str) {
scrub_option_area(ui, icon, Some(tip));
}
fn scrub_option_area(ui: &Ui, label: &Response, tip: Option<&'static str>) {
let scrub = LabelScrub {
pass: ui.ctx().cumulative_pass_nr(),
label: label.id.with("pc-option-scrub"),
rect: label.rect,
max_gap: 24.0,
option_area: true,
tip,
steps: 0.0,
started: false,
dragging: false,
@@ -487,6 +500,7 @@ fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap:
rect: original_rect,
max_gap,
option_area: false,
tip: None,
steps,
started: resp.drag_started(),
dragging: resp.dragged(),
@@ -558,7 +572,12 @@ fn value_field_box(
// numeric button. It is not focusable; the editor drawn next wins inside its bounds.
// Preserve a popup field's trailing arrow, and never extend outside the UI clip.
let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect());
let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG);
let mut resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG);
if let Some(tip) = s.tip
&& resp.hover_pos().is_some_and(|p| s.rect.contains(p))
{
resp = resp.on_hover_text(tip);
}
label_scrub_response(ui, &resp, s.rect, s.max_gap)
});
let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0));
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-17 · **Change:** minor (the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; earlier: earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 45 filters (31 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 45 filters (31 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's 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
@@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (45 mapped, among them Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) |
| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open 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, including Photoshop mixed-order type blocks (#4758); undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) |
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks (#4758); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) |
| 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); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) |
| 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 |
@@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) |
| 2026-10-17 | minor | `.atn`: Apply Layer Comp by name maps to `layerComp.apply` and exports as `applyComp` (#7101) |
| 2026-10-15 | minor | PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters with their seeds, colours and lens, and save like Photoshop (checked against a Photoshop 27 file); render filters on a smart object work within the object, so a flare sits where Photoshop puts it |
| 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; a dragged guide shows its X or Y beside the pointer, #7680; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **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,
@@ -164,6 +164,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 |
| Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) |
| Wheel steps a value field, ⇧ ×10: clicked into, wherever the pointer is; not clicked, a gesture that starts over it | partial | `field_wheel.rs` (#5473, #4061). Photoshop: a field takes the wheel once clicked into (shortcut guides, user reports); whether the pointer must stay over it is unconfirmed. Stepping on hover alone is Photopea's, kept deliberately |
| Type options bar: drag the "T" icon to change the font size | done | `type_tool.rs` `options_bar`: the icon scrubs the size field like the Character panel's icons (Shift x10, Alt fine), at layer or selection scope, one history step per drag, within 1–1296 pt (#6437) |
## Navigation
@@ -235,6 +236,7 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) |
| 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed |
| 2026-10-16 | minor | Hand tool ⌘ / ⌥ held are temporary Zoom In / Zoom Out (#6434); Scroll All Windows and Bird's Eye View rows added as missing |
| 2026-10-16 | minor | Type options bar: dragging the "T" icon scrubs the font size, as the Character panel's icons do (#6437) |
| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) |
| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) |
| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial |