mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 16:38:24 -06:00
Merge main into fix/history-options
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,3 +35,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -19,7 +19,9 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese 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) |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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) |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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) |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian 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) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese 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) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.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) |
|
||||
|
||||
Generated
+33
-31
@@ -1743,6 +1743,7 @@ dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"num-complex",
|
||||
"pulp",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
@@ -2331,9 +2332,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
@@ -4182,7 +4183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4224,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4234,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4247,7 +4248,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
@@ -4269,7 +4270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4283,7 +4284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4291,7 +4292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4312,7 +4313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4320,7 +4321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -4335,7 +4336,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4346,7 +4347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"exr",
|
||||
@@ -4380,7 +4381,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4400,14 +4401,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4424,14 +4425,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4460,14 +4461,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4478,7 +4479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4493,7 +4494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4504,7 +4505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4514,7 +4515,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4529,7 +4530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -4541,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -4552,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -4566,7 +4567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"craft-ui",
|
||||
@@ -4574,6 +4575,7 @@ dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
"flate2",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
@@ -4605,7 +4607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -4616,7 +4618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7449,7 +7451,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+34
-16
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -50,7 +50,9 @@ zune-core = "0.5"
|
||||
zune-jpeg = "0.5"
|
||||
proptest = "1"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui = "0.36"
|
||||
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
|
||||
# (egui_fonts.rs; the web build's size gate).
|
||||
egui = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
log = "0.4"
|
||||
@@ -81,13 +83,14 @@ codegen-units = 1
|
||||
|
||||
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
|
||||
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
|
||||
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
|
||||
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
|
||||
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
|
||||
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
|
||||
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
|
||||
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
|
||||
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
|
||||
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
|
||||
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
opt-level = "z"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
@@ -95,20 +98,35 @@ strip = "debuginfo"
|
||||
[profile.wasm-release.package."*"]
|
||||
opt-level = "s"
|
||||
|
||||
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
|
||||
# retain their speed-oriented optimization level.
|
||||
[profile.wasm-release.package.photocraft-ui-egui]
|
||||
opt-level = "z"
|
||||
# Decoding and rasterizing keep their speed.
|
||||
[profile.wasm-release.package.photocraft-codecs]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-raw]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-heif]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-vector]
|
||||
opt-level = "s"
|
||||
|
||||
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
|
||||
# builds and the speed-oriented pixel-processing overrides below retain their settings.
|
||||
[profile.wasm-release.package.photocraft-engine]
|
||||
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
|
||||
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
|
||||
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
|
||||
[profile.wasm-release.package.photocraft-doc]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-psd]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-format]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-affinity]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.serde_json]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-io]
|
||||
[profile.wasm-release.package.usvg]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-web]
|
||||
[profile.wasm-release.package.naga]
|
||||
opt-level = "z"
|
||||
|
||||
# Pixel processing keeps its speed-oriented optimization level.
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
|
||||
@@ -292,11 +292,15 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
|
||||
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let opts = export_opts(a)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
|
||||
// the save).
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
match (a.positional.as_slice(), a.get("--new")) {
|
||||
([file], None) => {
|
||||
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
|
||||
warn_all(err, &ws);
|
||||
reported_fonts.extend(ws);
|
||||
}
|
||||
([], Some(new)) => {
|
||||
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
|
||||
@@ -308,7 +312,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
if cmds.is_empty() && a.get("--out").is_none() {
|
||||
return Err("run needs at least one --cmd (or --out)".into());
|
||||
}
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
for (id, params) in cmds {
|
||||
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
|
||||
@@ -341,7 +344,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
warn_all(err, &remaining);
|
||||
if let Some(o) = a.get("--out") {
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
|
||||
// The save repeats the document's missing-font warnings; each was already reported above.
|
||||
let ws: Vec<String> =
|
||||
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -29,7 +29,7 @@ log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
|
||||
photocraft-tablet = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -992,8 +992,9 @@ mod tests {
|
||||
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
|
||||
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
|
||||
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
|
||||
for c in 0..4 {
|
||||
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
|
||||
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
|
||||
for c in 0..3 {
|
||||
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
|
||||
}
|
||||
}
|
||||
let cancel = || true;
|
||||
|
||||
@@ -169,7 +169,7 @@ fn apply_inner(
|
||||
return None;
|
||||
}
|
||||
if let Some(selection) = selection {
|
||||
crate::mix_selection(&mut data, output, selection, &src);
|
||||
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
|
||||
}
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
|
||||
@@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
|
||||
for mask in [None, Some(&selection)] {
|
||||
let mut expected_data = filtered.clone();
|
||||
if let Some(mask) = mask {
|
||||
crate::mix_selection(&mut expected_data, rect, mask, &src);
|
||||
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
|
||||
}
|
||||
let mut expected = source.clone();
|
||||
expected.write_region(rect, &expected_data);
|
||||
@@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
|
||||
let mut expected = source.clone();
|
||||
let mut data = filtered.clone();
|
||||
if let Some(mask) = mask {
|
||||
crate::mix_selection(&mut data, area, mask, &src);
|
||||
crate::mix_selection(&mut data, area, mask, &src, true);
|
||||
}
|
||||
expected.write_region(area, &data);
|
||||
let expected = Image::read(&expected, check);
|
||||
|
||||
@@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
|
||||
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
|
||||
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
|
||||
if let Some(selection) = selection {
|
||||
crate::mix_selection(&mut data, area, selection, &src);
|
||||
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
|
||||
}
|
||||
let mut result = surface.clone();
|
||||
result.write_region(area, &data);
|
||||
|
||||
+29
-4
@@ -1095,7 +1095,7 @@ fn apply_tiled_with_impl(
|
||||
return (*t, Vec::new());
|
||||
};
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(&mut data, *t, sel, src);
|
||||
mix_selection(&mut data, *t, sel, src, fmt.alpha);
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
@@ -1179,7 +1179,7 @@ fn apply_box_blur(
|
||||
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
|
||||
for (t, data) in v.iter_mut() {
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t));
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
}
|
||||
@@ -1202,8 +1202,9 @@ fn apply_box_blur(
|
||||
}
|
||||
|
||||
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
|
||||
/// coverage (channel 0 of `sel`).
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
|
||||
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
|
||||
let w = t.width() as usize;
|
||||
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
|
||||
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
@@ -1211,6 +1212,27 @@ fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
if k <= 0.0 {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = orig.get(x, y, c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if alpha {
|
||||
let a = px.len() - 1;
|
||||
let original_alpha = orig.get(x, y, a);
|
||||
let filtered_alpha = px[a];
|
||||
if original_alpha != filtered_alpha {
|
||||
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
|
||||
let original_weight = original_alpha * (1.0 - k);
|
||||
let filtered_weight = filtered_alpha * k;
|
||||
for (c, v) in px[..a].iter_mut().enumerate() {
|
||||
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
|
||||
}
|
||||
px[a] = mixed_alpha;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = orig.get(x, y, c);
|
||||
*v = o + (*v - o) * k;
|
||||
@@ -1229,6 +1251,9 @@ mod tests_ext;
|
||||
#[cfg(test)]
|
||||
mod tests_mosaic;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_selection_mix;
|
||||
|
||||
/// The current upstream sampler through the identical source/selection/tile pipeline.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
|
||||
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
|
||||
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
|
||||
let mut expected = surface.clone();
|
||||
expected.write_region(bounds, &expected_data);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
|
||||
@@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() {
|
||||
};
|
||||
let spans = Spans::new(radius, shape_inside(shape));
|
||||
let mut expected = conv_spans(&src, area, &ctx, &spans);
|
||||
crate::mix_selection(&mut expected, area, &selection, &src);
|
||||
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
|
||||
let mut reference = surface.clone();
|
||||
reference.write_region(area, &expected);
|
||||
for tile in [17, 256] {
|
||||
|
||||
@@ -167,17 +167,7 @@ fn storage_selection_and_tile_sizes_match_original_kernels() {
|
||||
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
|
||||
_ => continue,
|
||||
};
|
||||
let original = s.read_region(area);
|
||||
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
|
||||
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
|
||||
let k = selection.sample_channel(x, y, 0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
|
||||
}
|
||||
}
|
||||
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
|
||||
let mut want = s.clone();
|
||||
want.write_region(area, &raw);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
|
||||
@@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() {
|
||||
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
|
||||
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
|
||||
let mut data = reference::iris(&src, t, &ctx, &pins);
|
||||
crate::mix_selection(&mut data, t, &selection, &src);
|
||||
crate::mix_selection(&mut data, t, &selection, &src, alpha);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
use super::*;
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
|
||||
fn selection(coverage: f32) -> Surface {
|
||||
Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feathered_color_to_alpha_preserves_appearance_over_the_key() {
|
||||
let r = Rect::new(0, 0, 1, 1);
|
||||
let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 };
|
||||
for sample in SampleType::ALL {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for alpha in [0.5, 1.0] {
|
||||
let format = PixelFormat::new(mode, sample, true);
|
||||
let mut s = Surface::new(format);
|
||||
let mut p = vec![0.5; format.channels()];
|
||||
*p.last_mut().unwrap() = alpha;
|
||||
s.write_pixel(0, 0, &p);
|
||||
let original = s.pixel(0, 0);
|
||||
let a = format.channels() - 1;
|
||||
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let out = apply(&s, &filter, r, r, Some(&selection(coverage)));
|
||||
let actual = out.pixel(0, 0);
|
||||
let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 };
|
||||
for c in 0..a {
|
||||
let before = original[c] * original[a] + 1.0 - original[a];
|
||||
let after = actual[c] * actual[a] + 1.0 - actual[a];
|
||||
assert!(
|
||||
(before - after).abs() < tolerance,
|
||||
"{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() {
|
||||
let r = Rect::new(0, 0, 2, 1);
|
||||
let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap };
|
||||
for sample in SampleType::ALL {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
|
||||
s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
|
||||
s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]);
|
||||
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
|
||||
let p = out.pixel(0, 0);
|
||||
assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}");
|
||||
assert!((p[3] - 0.5).abs() < 1.0 / 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feathered_blurs_do_not_reintroduce_hidden_colour() {
|
||||
let r = Rect::new(0, 0, 3, 3);
|
||||
let mut s = Surface::new(PixelFormat::RGBA32F);
|
||||
s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]);
|
||||
s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
|
||||
for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] {
|
||||
let full = apply(&s, &filter, r, r, None);
|
||||
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
|
||||
let p = out.pixel(0, 1);
|
||||
assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage");
|
||||
assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage");
|
||||
assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_mixing_keeps_hdr_range_and_exact_endpoints() {
|
||||
let r = Rect::new(0, 0, 1, 1);
|
||||
let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] };
|
||||
let filtered = vec![8.0, -4.0, 0.75, 1.0];
|
||||
let mut data = filtered.clone();
|
||||
mix_selection(&mut data, r, &selection(0.5), &original, true);
|
||||
for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) {
|
||||
assert!((actual - expected).abs() < 1e-6, "{data:?}");
|
||||
}
|
||||
for coverage in [0.0, 1.0] {
|
||||
let mut data = filtered.clone();
|
||||
mix_selection(&mut data, r, &selection(coverage), &original, true);
|
||||
assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_mixing_without_alpha_keeps_every_channel_independent() {
|
||||
let r = Rect::new(0, 0, 1, 1);
|
||||
// One channel is a mask; three/four channels can be RGB or CMYK without alpha.
|
||||
for ch in [1, 3, 4] {
|
||||
let original = Image { rect: r, ch, data: vec![0.25; ch] };
|
||||
let mut data = vec![0.75; ch];
|
||||
mix_selection(&mut data, r, &selection(0.25), &original, false);
|
||||
assert_eq!(data, vec![0.375; ch]);
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
|
||||
None => Image::read(&surface, t.inflate(radius)),
|
||||
};
|
||||
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
|
||||
crate::mix_selection(&mut data, t, &selection, &src);
|
||||
crate::mix_selection(&mut data, t, &selection, &src, alpha);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,13 @@ impl Profile {
|
||||
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
|
||||
}
|
||||
|
||||
/// Whether colours can be converted into this profile for every intent. Input profiles with
|
||||
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
|
||||
/// describe a source but have no PCS → device path.
|
||||
pub fn can_be_destination(&self) -> bool {
|
||||
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
|
||||
}
|
||||
|
||||
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
|
||||
let i = match intent {
|
||||
Intent::Perceptual => 0,
|
||||
|
||||
+68
-1
@@ -450,6 +450,73 @@ fn malformed_profiles_are_rejected_without_panicking() {
|
||||
}
|
||||
}
|
||||
|
||||
// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40).
|
||||
// Keep the relative paper-white check and require finite endpoints with darker, bounded ink.
|
||||
fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool {
|
||||
// Allow only float rounding at the physical L* limits, not a different printing condition.
|
||||
let lightness = -1e-4..=100.0 + 1e-4;
|
||||
paper.iter().chain(&black).all(|v| v.is_finite())
|
||||
&& lightness.contains(&paper[0])
|
||||
&& lightness.contains(&black[0])
|
||||
&& paper[0] > 90.0
|
||||
&& black[0] < paper[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
|
||||
use photocraft_cms::Curve;
|
||||
use photocraft_cms::pipeline::Stage;
|
||||
use photocraft_cms::profile::{Lut, LutKind};
|
||||
|
||||
let mut p = cmyk().clone();
|
||||
// Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only;
|
||||
// the inherited coated profile's inverse is not an inverse of this generated model.
|
||||
p.a2b = [None, None, None];
|
||||
p.b2a = [None, None, None];
|
||||
p.a2b[1] = Some(Lut {
|
||||
kind: LutKind::Ab,
|
||||
inputs: 4,
|
||||
outputs: 3,
|
||||
stages: vec![
|
||||
Stage::Curves(vec![Curve::Identity; 4]),
|
||||
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
|
||||
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
|
||||
})),
|
||||
Stage::Curves(vec![Curve::Identity; 3]),
|
||||
],
|
||||
});
|
||||
let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
|
||||
let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
|
||||
let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
|
||||
assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}");
|
||||
// Explicit expected values, not the validation predicate: also catch wrong channels,
|
||||
// intent selection, constant output and incorrect LUT encoding/interpolation.
|
||||
for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] {
|
||||
let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric);
|
||||
// The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error.
|
||||
assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_endpoints_reject_invalid_values() {
|
||||
let paper = [100.0, 0.0, 0.0];
|
||||
let black = [48.0, 0.0, 0.0];
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
for channel in 0..3 {
|
||||
let mut p = paper;
|
||||
let mut k = black;
|
||||
p[channel] = bad;
|
||||
k[channel] = bad;
|
||||
assert!(!cmyk_endpoints_valid(p, black));
|
||||
assert!(!cmyk_endpoints_valid(paper, k));
|
||||
}
|
||||
}
|
||||
for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] {
|
||||
assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and
|
||||
/// converts through each.
|
||||
#[test]
|
||||
@@ -500,7 +567,7 @@ fn system_profiles() {
|
||||
// Real CMYK profile: paper lighter than 100 % K.
|
||||
let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
|
||||
let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
|
||||
assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
|
||||
assert!(cmyk_endpoints_valid(w, k), "{}: {w:?} {k:?}", path.display());
|
||||
// And it round-trips through its own BToA.
|
||||
if p.b2a[1].is_some() {
|
||||
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
|
||||
|
||||
@@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"]
|
||||
thiserror.workspace = true
|
||||
image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] }
|
||||
png = "0.18"
|
||||
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
|
||||
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
|
||||
exr = { version = "1.73", default-features = false }
|
||||
half = "2"
|
||||
serde_json.workspace = true
|
||||
@@ -36,6 +36,8 @@ photocraft-heif = { path = "../heif", optional = true }
|
||||
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon.workspace = true
|
||||
# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded.
|
||||
exr = { version = "1.73", default-features = false, features = ["rayon"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
@@ -61,6 +61,9 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
|
||||
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
|
||||
the same.
|
||||
|
||||
Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so
|
||||
`photocraft-io` can open them in Indexed Color mode.
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
|
||||
|
||||
@@ -16,7 +16,7 @@ cargo-fuzz = true
|
||||
libfuzzer-sys = "0.4"
|
||||
photocraft-codecs = { path = "..", features = ["heif"] }
|
||||
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
|
||||
heic-rs = "=0.1.1"
|
||||
heic-rs = "=0.1.4"
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
@@ -35,7 +35,7 @@ fn map_exr(e: exr::error::Error) -> CodecError {
|
||||
|
||||
/// Read the headers without the `exr` crate's validation (it rejects deep parts outright),
|
||||
/// after checking every part's declared size against the limits, before any pixel allocation.
|
||||
fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
|
||||
pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
|
||||
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
|
||||
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
|
||||
let mut total: u64 = 0;
|
||||
@@ -93,12 +93,31 @@ fn pick_part(meta: &MetaData) -> usize {
|
||||
best
|
||||
}
|
||||
|
||||
/// Decode the flat layers via the `exr` crate (it handles every flat compression) and
|
||||
/// return the requested part's layer.
|
||||
fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
|
||||
let image =
|
||||
read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(map_exr)?;
|
||||
image.layer_data.into_iter().nth(index).ok_or_else(|| err(format!("part {index} is missing from the file")))
|
||||
/// Selects one part for the `exr` crate's first-valid-layer reader: `create_channels_reader`
|
||||
/// is asked about the headers in file order and the first `Ok` wins, so every header before
|
||||
/// `index` (and, harmlessly, those after it) is declined. Only the chosen part's chunks are
|
||||
/// then decompressed and only its planes allocated; the other parts are skipped unread.
|
||||
struct OnlyPart<C> {
|
||||
inner: C,
|
||||
index: usize,
|
||||
seen: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart<C> {
|
||||
type Reader = C::Reader;
|
||||
|
||||
fn create_channels_reader(&'s self, header: &exr::meta::header::Header) -> exr::error::Result<Self::Reader> {
|
||||
let position = self.seen.get();
|
||||
self.seen.set(position.saturating_add(1));
|
||||
if position == self.index { self.inner.create_channels_reader(header) } else { Err(exr::error::Error::Invalid("part not requested".into())) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one part's flat layer via the `exr` crate (it handles every flat compression).
|
||||
/// Other parts are not decompressed, so an AOV file costs one part per request, not the file.
|
||||
pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
|
||||
let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) };
|
||||
only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr)
|
||||
}
|
||||
|
||||
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
|
||||
|
||||
@@ -11,7 +11,6 @@ use std::io::Cursor;
|
||||
|
||||
use exr::meta::MetaData;
|
||||
use exr::meta::attribute::AttributeValue;
|
||||
use exr::prelude::{ReadChannels, ReadLayers, read};
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
@@ -200,7 +199,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
|
||||
.iter()
|
||||
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
|
||||
.ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?;
|
||||
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
|
||||
// Also caps all parts' declared sizes together, like every other flat decode.
|
||||
let meta = super::exr::read_meta(bytes, limits)?;
|
||||
if meta.requirements.has_deep_data {
|
||||
return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files"));
|
||||
}
|
||||
@@ -214,13 +214,7 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
|
||||
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
|
||||
limits.check_bytes(w32, h32, per_pixel)?;
|
||||
|
||||
let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(
|
||||
|e| match e {
|
||||
exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")),
|
||||
e => CodecError::malformed(F, e),
|
||||
},
|
||||
)?;
|
||||
let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?;
|
||||
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
|
||||
|
||||
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
|
||||
@@ -32,6 +32,11 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
|
||||
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
|
||||
|
||||
@@ -98,6 +98,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
finish_decode(img, o, opts)
|
||||
}
|
||||
|
||||
/// Returns the embedded colour table for an indexed BMP, if it has one.
|
||||
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
via_image::bmp_palette(bytes)
|
||||
}
|
||||
|
||||
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
|
||||
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
|
||||
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
|
||||
|
||||
@@ -197,3 +197,32 @@ fn parts_are_capped_together() {
|
||||
let e = exr_info(&aov_file(), &limits).unwrap_err().to_string();
|
||||
assert!(e.contains("across 3 parts"), "{e}");
|
||||
}
|
||||
|
||||
/// Two ZIP1 parts of 4x4 RGBA; every chunk of part 0 is then overwritten with garbage, so
|
||||
/// inflating it fails.
|
||||
fn file_with_corrupt_first_part() -> Vec<u8> {
|
||||
let px = |k: f32| -> Vec<(&'static str, Vec<f32>)> { ["R", "G", "B", "A"].iter().map(|c| (*c, vec![k; 16])).collect() };
|
||||
let mut bytes = write_image(vec![layer_f32("first", Compression::ZIP1, &px(0.25)), layer_f32("second", Compression::ZIP1, &px(0.75))]);
|
||||
let mut cursor = std::io::Cursor::new(&bytes[..]);
|
||||
exr::meta::MetaData::read_from_buffered(&mut cursor, false).unwrap();
|
||||
let table = cursor.position() as usize;
|
||||
// Part 0 owns the first four of the eight chunks (one scan line per ZIP1 block).
|
||||
for i in 0..4 {
|
||||
let at = table + i * 8;
|
||||
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
|
||||
let size = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize;
|
||||
bytes[offset + 12..offset + 12 + size].fill(0xA5);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_one_part_does_not_decode_the_others() {
|
||||
let bytes = file_with_corrupt_first_part();
|
||||
// Part 0 is unreadable ...
|
||||
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
|
||||
// ... but asking for part 1 must not touch it.
|
||||
let img = decode_exr_part(&bytes, 1, &DecodeOptions::default()).expect("part 1 decodes on its own");
|
||||
assert_eq!((img.width(), img.height()), (4, 4));
|
||||
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
mod common;
|
||||
|
||||
use common::tiffgen::*;
|
||||
use photocraft_codecs::{Image, decode, exif_orientation, upright_exif};
|
||||
|
||||
fn classic_white_is_zero() -> Vec<u8> {
|
||||
@@ -95,3 +98,55 @@ fn big_tiff_orientation_6_and_8_are_applied_to_decoded_pixels() {
|
||||
assert_eq!(image.data(), &upright, "orientation {orientation}");
|
||||
}
|
||||
}
|
||||
|
||||
fn with_compression(mut dir: Dir, code: u64) -> Dir {
|
||||
for entry in &mut dir.entries {
|
||||
if entry.0 == 259 {
|
||||
entry.2 = vec![V::N(code)];
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_compressed_tiff_decodes() {
|
||||
// A flat mid-grey 16x16 image as a baseline JPEG stored in one strip (Compression = 7).
|
||||
let mut jpeg = Vec::new();
|
||||
let gray = vec![100u8; 16 * 16];
|
||||
jpeg_encoder::Encoder::new(&mut jpeg, 95).encode(&gray, 16, 16, jpeg_encoder::ColorType::Luma).expect("encode the JPEG strip");
|
||||
let mut dir = with_compression(strips(16, 16, 8, 1, 1, 16, &[0; 256]), 7);
|
||||
dir.chunks = vec![jpeg];
|
||||
let bytes = build(true, false, &[dir], &[0]).bytes;
|
||||
|
||||
let image = decode(&bytes).expect("a JPEG-compressed TIFF should decode");
|
||||
assert_eq!(image.dimensions(), (16, 16));
|
||||
assert!(image.data().iter().all(|&v| v.abs_diff(100) <= 4), "{:?}", image.data().get(..4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccitt_group4_tiff_decodes() {
|
||||
// 8x3 bilevel: a white row, then two black rows. Bits: V0 | H(white 0, black 8) | V0 V0 | EOFB.
|
||||
let bits = "1".to_string() + "001" + "00110101" + "000101" + "11" + "000000000001" + "000000000001";
|
||||
let mut data = Vec::new();
|
||||
for chunk in bits.as_bytes().chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (i, c) in chunk.iter().enumerate() {
|
||||
if *c == b'1' {
|
||||
byte |= 0x80 >> i;
|
||||
}
|
||||
}
|
||||
data.push(byte);
|
||||
}
|
||||
let mut dir = with_compression(strips(8, 3, 1, 1, 0, 3, &[0; 3]), 4);
|
||||
dir.chunks = vec![data];
|
||||
let bytes = build(true, false, &[dir], &[0]).bytes;
|
||||
|
||||
let image = decode(&bytes).expect("a CCITT G4 TIFF should decode");
|
||||
assert_eq!(image.dimensions(), (8, 3));
|
||||
let d = image.data();
|
||||
assert_eq!(d.len(), 24, "{d:?}");
|
||||
assert!(d[..8].iter().all(|&v| v == d[0]), "{d:?}");
|
||||
assert!(d[8..].iter().all(|&v| v == d[8]), "{d:?}");
|
||||
assert_ne!(d[0], d[8], "{d:?}");
|
||||
assert_eq!(d[0], 255, "white row of a WhiteIsZero image");
|
||||
}
|
||||
|
||||
@@ -233,6 +233,11 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
|
||||
/// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model
|
||||
/// and depth), optionally flattened over an opaque `background`; rendered and written in bands,
|
||||
/// so no full-size float composite is held. Tiles equal to the default pixel are pruned.
|
||||
///
|
||||
/// Fully transparent pixels are written as the default pixel. Converting transparent black
|
||||
/// leaves colour behind in CMYK (K = 100%) and Lab (neutral a/b = 0.5), and those pixels would
|
||||
/// otherwise count as content: the result would span the whole canvas.
|
||||
///
|
||||
/// A CMYK document of plain Normal layers is composited in its ink values instead
|
||||
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778).
|
||||
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
|
||||
@@ -249,6 +254,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
|
||||
}
|
||||
let mut s = Surface::new(fmt);
|
||||
let n = fmt.channels();
|
||||
let empty = s.default_pixel();
|
||||
let mut data = Vec::new();
|
||||
// The composite separates back to CMYK through the document's own profile, the one its
|
||||
// pixels were read through.
|
||||
@@ -262,7 +268,11 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
|
||||
data.resize(band.px.len() * n, 0.0);
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, out);
|
||||
if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n {
|
||||
out.copy_from_slice(&empty);
|
||||
} else {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, out);
|
||||
}
|
||||
}
|
||||
});
|
||||
s.write_region(band.rect, &data);
|
||||
|
||||
@@ -45,7 +45,7 @@ impl<'a> PreparedPatterns<'a> {
|
||||
|
||||
pub(crate) fn get(&self, id: &str, name: &str) -> Option<Arc<Tile>> {
|
||||
let p = photocraft_doc::pattern::find(self.patterns, id, name)?;
|
||||
if p.is_empty() {
|
||||
if !p.is_paintable() {
|
||||
return None;
|
||||
}
|
||||
let bytes = usize::try_from(p.width)
|
||||
@@ -87,9 +87,13 @@ impl<'a> PreparedPatterns<'a> {
|
||||
/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the
|
||||
/// library and in the document), but in a Grayscale or CMYK document it paints that mode's
|
||||
/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the
|
||||
/// pattern in colour in its picker, filled in grey).
|
||||
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
|
||||
/// [paintable](Pattern::is_paintable).
|
||||
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; (p.width as usize).saturating_mul(p.height as usize)];
|
||||
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut px = vec![[0.0f32; 4]; n];
|
||||
p.surface.read_rgba_into(p.rect(), &mut px);
|
||||
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
|
||||
if paints_in && p.surface.format().mode != mode {
|
||||
@@ -117,8 +121,9 @@ impl Tile {
|
||||
}
|
||||
|
||||
/// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]).
|
||||
/// `None` when the pattern isn't [paintable](Pattern::is_paintable).
|
||||
pub fn new_in(p: &Pattern, mode: ColorMode) -> Option<Tile> {
|
||||
if p.is_empty() {
|
||||
if !p.is_paintable() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (p.width as usize, p.height as usize);
|
||||
@@ -255,6 +260,17 @@ mod tests {
|
||||
assert_eq!(px[8], [0.0, 0.0, 1.0, 1.0]); // x = 4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_too_large_to_paint_are_skipped_without_allocating() {
|
||||
// Only the size is declared (no tiles): a hostile file can claim any size.
|
||||
let p = Pattern { id: "big".into(), name: "Big".into(), width: 9000, height: 8000, surface: Surface::new(PixelFormat::RGBA8) };
|
||||
assert!(!p.is_paintable());
|
||||
assert!(Tile::new(&p).is_none());
|
||||
assert!(pattern_rgba(&p, ColorMode::Rgb).is_empty());
|
||||
let list = [p];
|
||||
assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_phase_scale_and_angle_move_the_origin() {
|
||||
let t = Tile::new(&checker()).unwrap();
|
||||
|
||||
@@ -604,6 +604,34 @@ fn cmyk_document_renders_via_rgb() {
|
||||
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
|
||||
}
|
||||
|
||||
/// Transparent pixels convert to colour in CMYK (K = 100%) and Lab (a/b = 0.5). A flattened
|
||||
/// surface must still treat them as empty, or its content spans the whole canvas.
|
||||
#[test]
|
||||
fn flatten_to_surface_leaves_transparent_pixels_empty_in_every_mode() {
|
||||
let modes = [
|
||||
(ColorMode::Rgb, [SampleType::U8, SampleType::U16, SampleType::F32].as_slice()),
|
||||
(ColorMode::Grayscale, &[SampleType::U8, SampleType::U16, SampleType::F32]),
|
||||
(ColorMode::Cmyk, &[SampleType::U8, SampleType::U16]),
|
||||
(ColorMode::Lab, &[SampleType::U8, SampleType::U16]),
|
||||
];
|
||||
for (mode, samples) in modes {
|
||||
for &sample in samples {
|
||||
let mut d = Document::new("t", Size::new(300, 200), mode, sample);
|
||||
let fmt = PixelFormat::new(mode, sample, true);
|
||||
let mut l = Layer::raster("dot", fmt);
|
||||
let opaque: Vec<f32> = photocraft_raster::from_rgba(&fmt, [0.9, 0.4, 0.1, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(120, 80, 140, 90), &opaque);
|
||||
d.layers.push(l);
|
||||
let s = flatten_to_surface(&d, fmt, None);
|
||||
assert_eq!(s.content_bounds(), Rect::new(120, 80, 140, 90), "{mode:?} {sample:?}");
|
||||
// Over a background everything is content, as before.
|
||||
let fmt_opaque = PixelFormat::new(mode, sample, false);
|
||||
let s = flatten_to_surface(&d, fmt_opaque, Some([1.0, 1.0, 1.0]));
|
||||
assert_eq!(s.rgba(5, 5)[3], 1.0, "{mode:?} {sample:?}: background stays opaque");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_and_float_layers_composite() {
|
||||
for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000).
|
||||
pub const MAX_EDGE: u32 = 30_000;
|
||||
|
||||
/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel
|
||||
/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing.
|
||||
pub const MAX_PAINT_PIXELS: u64 = 1 << 26;
|
||||
|
||||
/// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format
|
||||
/// (depth and colour model are the pattern's own, as in Photoshop).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -57,6 +64,16 @@ impl Pattern {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.width == 0 || self.height == 0
|
||||
}
|
||||
|
||||
/// Pixels in the tile (`width × height`, never overflowing).
|
||||
pub fn pixel_count(&self) -> u64 {
|
||||
u64::from(self.width) * u64::from(self.height)
|
||||
}
|
||||
|
||||
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]).
|
||||
pub fn is_paintable(&self) -> bool {
|
||||
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS
|
||||
}
|
||||
}
|
||||
|
||||
/// Display form of a stored pattern name (see [`Pattern::display_name`]).
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
//! has no menu item for them but lists them in Edit › Keyboard Shortcuts › Tools ("Decrease Brush
|
||||
//! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound,
|
||||
//! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush.
|
||||
//!
|
||||
//! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean
|
||||
//! other things with other tools, so the shell gives them to it only for the painting tools
|
||||
//! (`move_mods::arrow_keys`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always};
|
||||
use crate::{Result, Session};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Photoshop's largest brush diameter, in pixels.
|
||||
pub const MAX_SIZE: f32 = 5000.0;
|
||||
@@ -35,6 +39,23 @@ fn hardness(s: &mut Session, up: bool) -> Result<Value> {
|
||||
crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } }))
|
||||
}
|
||||
|
||||
/// The brush angle turned by `degrees` (positive is anticlockwise, as the angle field counts),
|
||||
/// wrapped into Photoshop's -180..=180.
|
||||
fn turned(angle: f32, degrees: f32) -> f32 {
|
||||
let a = (angle + degrees).rem_euclid(360.0);
|
||||
if a > 180.0 { a - 360.0 } else { a }
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let by = p
|
||||
.get("degrees")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|d| d.is_finite() && d.abs() <= 360.0)
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: "tools.rotateBrush".into(), msg: "`degrees` must be a number from -360 to 360".into() })?;
|
||||
let angle = turned(s.tools.brush.angle, by as f32);
|
||||
crate::brush_cmds::set_brush(s, &json!({ "brush": { "angle": angle } }))
|
||||
}
|
||||
|
||||
macro_rules! key_cmd {
|
||||
($id:literal, $label:literal, $sc:literal, $run:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: Some($sc), params: "{}", enabled: always, run: $run, journal: true }
|
||||
@@ -47,6 +68,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)),
|
||||
key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)),
|
||||
key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)),
|
||||
CommandSpec {
|
||||
id: "tools.rotateBrush",
|
||||
label: "Rotate Brush Tip",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"degrees":-360..360 (positive anticlockwise; ← / → while painting send 1, ⇧ 15)}"##,
|
||||
enabled: always,
|
||||
run: rotate,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -74,6 +105,21 @@ mod tests {
|
||||
assert_eq!(hardness_step(0.0, false), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_brush_turns_the_tip_and_wraps_at_180() {
|
||||
assert_eq!(turned(0.0, 1.0), 1.0);
|
||||
assert_eq!(turned(179.0, 15.0), -166.0);
|
||||
assert_eq!(turned(-175.0, -15.0), 170.0);
|
||||
assert_eq!(turned(180.0, 0.0), 180.0);
|
||||
let mut s = Session::new();
|
||||
s.execute("tools.rotateBrush", json!({"degrees": -15})).unwrap();
|
||||
assert_eq!(s.tools.brush.angle, -15.0);
|
||||
for bad in [json!({}), json!({"degrees": "x"}), json!({"degrees": 1e9}), json!(null)] {
|
||||
assert!(s.execute("tools.rotateBrush", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.tools.brush.angle, -15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_step_the_session_brush_and_journal() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1126,11 +1126,15 @@ fn delete_channel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "missing `channel`")),
|
||||
},
|
||||
};
|
||||
// `edit` validates the view against the shorter channel list before we can remap it.
|
||||
// Keep the original indices and eyes so a surviving final channel is not discarded.
|
||||
let view = s.active().ok_or(EngineError::NoDocument)?.channel_view.clone();
|
||||
s.edit("Delete Channel", |doc, _| {
|
||||
doc.channels.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.channel_view = view;
|
||||
let v = &mut st.channel_view;
|
||||
if i < v.alpha_visible.len() {
|
||||
v.alpha_visible.remove(i);
|
||||
|
||||
@@ -168,6 +168,56 @@ fn channel_management_and_view_state() {
|
||||
assert_eq!(doc(&s).channels.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_earlier_channel_keeps_the_last_channel_targeted() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session_depth(depth);
|
||||
s.execute("channel.new", json!({"name": "Remove", "fill": "black"})).unwrap();
|
||||
s.execute("channel.new", json!({"name": "Keep", "fill": "white"})).unwrap();
|
||||
let layer_before = px(&s, 5, 5);
|
||||
s.execute("channel.delete", json!({"channel": "Remove"})).unwrap();
|
||||
assert_eq!(doc(&s).channels[0].name, "Keep");
|
||||
let view = &s.active().unwrap().channel_view;
|
||||
assert_eq!(view.target, ChannelTarget::Alpha(0), "the surviving target follows its new index");
|
||||
assert!(view.alpha_shown(0));
|
||||
assert_eq!(view.visible_colors(3), 0, "keep the channel-only view");
|
||||
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
assert_eq!(chan(&s, 0, 5, 5), 0.0, "implicit pixel edits still target Keep");
|
||||
assert_eq!(px(&s, 5, 5), layer_before, "the layer must not be edited instead");
|
||||
assert!(s.undo());
|
||||
assert_eq!(chan(&s, 0, 5, 5), 1.0);
|
||||
assert_eq!(px(&s, 5, 5), layer_before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(chan(&s, 0, 5, 5), 0.0);
|
||||
assert_eq!(px(&s, 5, 5), layer_before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_channel_remaps_all_surviving_eyes_and_targets() {
|
||||
for target_index in 0..3 {
|
||||
let mut s = session();
|
||||
for name in ["First", "Middle", "Last"] {
|
||||
s.execute("channel.new", json!({"name": name})).unwrap();
|
||||
}
|
||||
s.execute("channel.target", json!({"channel": target_index})).unwrap();
|
||||
for (channel, visible) in [false, false, true].into_iter().enumerate() {
|
||||
s.execute("channel.setVisible", json!({"channel": channel, "visible": visible})).unwrap();
|
||||
}
|
||||
s.execute("channel.delete", json!({"channel": "Middle"})).unwrap();
|
||||
let view = &s.active().unwrap().channel_view;
|
||||
let expected = match target_index {
|
||||
0 => ChannelTarget::Alpha(0),
|
||||
1 => ChannelTarget::Composite,
|
||||
_ => ChannelTarget::Alpha(1),
|
||||
};
|
||||
assert_eq!(view.target, expected, "old target {target_index}");
|
||||
assert!(!view.alpha_shown(0));
|
||||
assert!(view.alpha_shown(1), "Last keeps its eye when Middle is removed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_colour_channel_can_hide_while_alpha_stays() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -276,18 +276,22 @@ impl ColorState {
|
||||
if !mismatch || linear_hdr {
|
||||
return merge(base, json!({"action": "kept"}));
|
||||
}
|
||||
// An input-only profile can't be the document's space: nothing could be
|
||||
// composited into it (#3876). Preserve converts it to the working space instead.
|
||||
let input_only = !emb.can_be_destination();
|
||||
let action = match policy {
|
||||
Policy::Preserve => "kept",
|
||||
Policy::Preserve if !input_only => "kept",
|
||||
Policy::Off => {
|
||||
doc.icc_profile = None;
|
||||
"discarded"
|
||||
}
|
||||
Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
|
||||
Policy::Preserve | Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
|
||||
Ok(()) => "converted",
|
||||
Err(_) => "kept",
|
||||
},
|
||||
};
|
||||
merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}))
|
||||
let report = merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}));
|
||||
if input_only { merge(report, json!({"inputOnly": true})) } else { report }
|
||||
}
|
||||
None => {
|
||||
// Untagged files are assumed to be in the working space; tag them when it is not
|
||||
@@ -1397,6 +1401,58 @@ mod settings_tests {
|
||||
assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3"));
|
||||
}
|
||||
|
||||
/// Display P3 as an input profile with only an AToB table, like Apple's Wide Color Sharing
|
||||
/// Profile embedded in iPhone photos.
|
||||
fn input_only_p3() -> Profile {
|
||||
let p3 = Builtin::DisplayP3.profile();
|
||||
let t = Transform::new(p3, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
|
||||
let clut = photocraft_cms::Clut::sample(vec![9; 3], 3, |inp, out| t.eval(inp, out));
|
||||
let ident = || photocraft_cms::pipeline::Stage::Curves(vec![photocraft_cms::Curve::Identity; 3]);
|
||||
let lut = photocraft_cms::profile::Lut {
|
||||
kind: photocraft_cms::profile::LutKind::Ab,
|
||||
inputs: 3,
|
||||
outputs: 3,
|
||||
stages: vec![ident(), photocraft_cms::pipeline::Stage::Clut(clut), ident()],
|
||||
};
|
||||
let mut p = p3.clone();
|
||||
p.class = photocraft_cms::ProfileClass::Input;
|
||||
p.pcs = photocraft_cms::Pcs::Lab;
|
||||
p.description = "Input-only P3".into();
|
||||
(p.matrix, p.trc) = (None, None);
|
||||
p.a2b = [Some(lut), None, None];
|
||||
p.b2a = [None, None, None];
|
||||
p.with_encoded_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_only_profiles_are_converted_on_open() {
|
||||
// #3876: an iPhone photo's input-only profile was kept as the document's space, so
|
||||
// compositing anything into it failed with "RGB profile without matrix/TRC or BToA tags".
|
||||
let input = input_only_p3();
|
||||
assert!(Builtin::DisplayP3.profile().can_be_destination());
|
||||
assert!(!input.can_be_destination());
|
||||
let mut s = Session::new();
|
||||
let mut d = tagged("srgb");
|
||||
d.icc_profile = Some(input.to_bytes());
|
||||
let (photo, r) = s.open_document(d, None);
|
||||
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool(), r["ask"].as_bool()), (Some("converted"), Some(true), Some(true)));
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert!(desc(doc).unwrap().starts_with("sRGB"));
|
||||
// Converted from P3, not reinterpreted as sRGB.
|
||||
assert!((doc.layers[0].surface().unwrap().pixel(1, 1)[0] - 0.2).abs() > 0.01);
|
||||
let (other, _) = s.open_document(tagged("srgb"), None);
|
||||
s.execute("layer.copyToDocument", json!({"document": photo, "source": other})).unwrap();
|
||||
// Off still discards the profile, and profiles that can be a destination are still kept.
|
||||
s.execute("edit.colorSettings", json!({"policyRgb": "off"})).unwrap();
|
||||
let mut d = tagged("srgb");
|
||||
d.icc_profile = Some(input.to_bytes());
|
||||
let (_, r) = s.open_document(d, None);
|
||||
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool()), (Some("discarded"), Some(true)));
|
||||
s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap();
|
||||
let (_, r) = s.open_document(tagged("display-p3"), None);
|
||||
assert_eq!((r["action"].as_str(), r.get("inputOnly")), (Some("kept"), None));
|
||||
}
|
||||
|
||||
/// A little-endian EXIF block whose Exif IFD holds ColorSpace = `space`.
|
||||
fn exif_color_space(space: u16) -> Vec<u8> {
|
||||
let mut b = b"Exif\0\0II\x2a\0\x08\0\0\0".to_vec();
|
||||
|
||||
@@ -785,6 +785,13 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("layer.mergeGroup", "Merge Group", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
if !s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) {
|
||||
return Err(EngineError::Other("Merge Group needs a group".into()));
|
||||
}
|
||||
merge_group(s, id)
|
||||
}),
|
||||
cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Flatten Image", |doc, active| {
|
||||
let fmt = doc.pixel_format();
|
||||
@@ -1431,6 +1438,37 @@ pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
|
||||
Ok(dup)
|
||||
}
|
||||
|
||||
/// Layer › Merge Group (⌘E with a group selected, #3832): the group's visible contents, as they
|
||||
/// show, become one pixel layer in its place, named after it. Like Photoshop, the layer keeps the
|
||||
/// group's opacity, blend mode (Pass Through becomes Normal), clipping and locks; the group's mask
|
||||
/// and style are applied to the pixels.
|
||||
fn merge_group(s: &mut Session, id: LayerId) -> Result<Value> {
|
||||
s.edit("Merge Group", |doc, active| {
|
||||
let group = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone();
|
||||
if !group.visible {
|
||||
return Err(EngineError::Other("the group is hidden".into()));
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
let fmt = photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let mut solo = group.clone();
|
||||
solo.opacity = 1.0;
|
||||
solo.blend = photocraft_doc::BlendMode::Normal;
|
||||
solo.clipped = false;
|
||||
let mut tmp = doc.clone();
|
||||
tmp.layers = vec![solo];
|
||||
let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None)));
|
||||
merged.id = group.id;
|
||||
merged.opacity = group.opacity;
|
||||
merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend };
|
||||
merged.clipped = group.clipped;
|
||||
merged.locks = group.locks;
|
||||
*doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = merged;
|
||||
*active = Some(id);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod registry_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"pattern" => {
|
||||
let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?;
|
||||
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?;
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty or too large to paint"))?;
|
||||
// Photoshop's Fill uses the canvas origin.
|
||||
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
|
||||
out["pattern"] = json!(pat.id);
|
||||
|
||||
@@ -1217,6 +1217,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn merge_layers(s: &mut Session) -> Result<Value> {
|
||||
let sel = selected(s);
|
||||
if sel.len() < 2 {
|
||||
// ⌘E on a lone group is Photoshop's Merge Group (#3832); on a layer, Merge Down.
|
||||
if let Some(&id) = sel.first()
|
||||
&& s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_)))
|
||||
{
|
||||
return s.execute("layer.mergeGroup", json!({ "layer": id.0 }));
|
||||
}
|
||||
return s.execute("layer.mergeDown", json!({}));
|
||||
}
|
||||
let mid = s.edit("Merge Layers", |doc, active| {
|
||||
|
||||
@@ -55,7 +55,7 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pattern = if source == "pattern" {
|
||||
let pat = crate::pattern_cmds::resolve_param(s, "paint.bucket", p)?;
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat)
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty".into() })?;
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty or too large to paint".into() })?;
|
||||
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
|
||||
Some((tile, scale, angle, phase))
|
||||
} else {
|
||||
|
||||
@@ -326,8 +326,12 @@ fn new_fill_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pat = resolve_param(s, "brush.texturePattern", p)?;
|
||||
let n = pat.is_paintable().then(|| usize::try_from(pat.pixel_count()).ok()).flatten();
|
||||
let Some(n) = n else {
|
||||
return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint"));
|
||||
};
|
||||
// Brush textures are grey: luminance of the composite over white.
|
||||
let mut px = vec![[0.0f32; 4]; (pat.width * pat.height) as usize];
|
||||
let mut px = vec![[0.0f32; 4]; n];
|
||||
pat.surface.read_rgba_into(pat.rect(), &mut px);
|
||||
let lum: Vec<f32> = px
|
||||
.iter()
|
||||
@@ -348,8 +352,11 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A swatch colour for UIs (average of the tile), straight RGB.
|
||||
pub fn average_color(p: &Pattern) -> Color {
|
||||
let mut px = vec![[0.0f32; 4]; (p.width * p.height) as usize];
|
||||
p.surface.read_rgba_into(p.rect(), &mut px);
|
||||
// A pattern too large to paint is averaged over its paintable top-left corner.
|
||||
let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32;
|
||||
let (w, h) = (p.width.min(side), p.height.min(side));
|
||||
let mut px = vec![[0.0f32; 4]; w as usize * h as usize];
|
||||
p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px);
|
||||
let n = px.len().max(1) as f32;
|
||||
let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]);
|
||||
Color::rgb(s[0] / n, s[1] / n, s[2] / n)
|
||||
@@ -560,4 +567,14 @@ mod tests {
|
||||
assert!(s.tools.brush.texture.enabled);
|
||||
assert!(matches!(s.tools.brush.texture.pattern, photocraft_paint::Pattern::Tile(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_too_large_to_paint_is_refused_not_allocated() {
|
||||
// width × height overflows u32: this panicked (debug) or wrapped (release).
|
||||
let mut s = session(8);
|
||||
let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) };
|
||||
s.patterns.items.push(huge);
|
||||
let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err();
|
||||
assert!(e.to_string().contains("too large"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(CMD, "`points` is empty"));
|
||||
};
|
||||
let phase = stamp_phase(p, CMD, aligned, first)?;
|
||||
let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty"))?;
|
||||
let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty or too large to paint"))?;
|
||||
reject_huge_stroke(CMD, &stroke)?;
|
||||
|
||||
let seed = p.get("seed").and_then(Value::as_u64).unwrap_or_else(|| {
|
||||
|
||||
@@ -1469,3 +1469,41 @@ fn delete_layer_preserves_an_unaffected_active_layer_and_last_layer_guard() {
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(other));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_down_on_a_group_merges_the_group() {
|
||||
// #3832: with a group selected, Merge Down is Photoshop's Merge Group (⌘E): the group's
|
||||
// contents become one layer in its place, named after it, and the layer below is untouched.
|
||||
let mut s = session_with_doc();
|
||||
let below = s.execute("layer.new.layer", json!({"name": "Below"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 48})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("select.rect", json!({"x": 20, "y": 0, "width": 10, "height": 48})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": a, "mode": "add"})).unwrap();
|
||||
let group = s.execute("layer.groupLayers", json!({"name": "Badge"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": group, "opacity": 0.5})).ok();
|
||||
let before = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)];
|
||||
let layers = s.active().unwrap().doc.layer_count();
|
||||
s.execute("layer.select", json!({"layer": group})).unwrap();
|
||||
s.execute("layer.mergeLayers", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let merged = st.doc.layer(st.active_layer.unwrap()).unwrap().clone();
|
||||
assert_eq!(merged.name, "Badge");
|
||||
assert!(matches!(merged.content, photocraft_doc::LayerContent::Raster(_)), "one pixel layer");
|
||||
assert!(st.doc.layer(photocraft_doc::LayerId(below)).is_some(), "the layer below stays");
|
||||
assert_eq!(st.doc.layer_count(), layers - 2, "group and its two layers became one");
|
||||
let after = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)];
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{before:?} -> {after:?}");
|
||||
}
|
||||
// Undo brings the group back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Merging small layers gives a layer as big as their content in every colour mode. In CMYK and
|
||||
//! Lab, transparent pixels used to keep the colour of converted transparent black, so Merge
|
||||
//! Layers, Merge Visible, Stamp Visible and Stamp Down made layers that spanned the whole canvas:
|
||||
//! Free Transform, the Move tool and alignment then worked on the canvas instead of the content.
|
||||
|
||||
use photocraft_doc::{Layer, LayerId};
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_engine::transform_cmds::transform_bounds;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const W: u32 = 300;
|
||||
const H: u32 = 400;
|
||||
/// Two small shapes far from the canvas edges, and the box around both.
|
||||
const A: [i32; 4] = [40, 300, 20, 30];
|
||||
const B: [i32; 4] = [90, 320, 25, 20];
|
||||
const BOTH: Rect = Rect { x0: 40, y0: 300, x1: 115, y1: 340 };
|
||||
|
||||
/// Every mode at the depths it supports (Photoshop has no 32-bit CMYK or Lab).
|
||||
const CASES: &[(&str, u32)] =
|
||||
&[("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8), ("gray", 16), ("gray", 32), ("cmyk", 8), ("cmyk", 16), ("lab", 8), ("lab", 16)];
|
||||
|
||||
fn id_of(r: &Value) -> LayerId {
|
||||
LayerId(r["layer"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A transparent document with the two shapes on their own layers.
|
||||
fn scene(mode: &str, depth: u32) -> (Session, LayerId, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth, "background": "transparent"})).unwrap();
|
||||
let a = id_of(&s.execute("shape.create", json!({"kind": "rect", "rect": A, "fill": "#e07020"})).unwrap());
|
||||
let b = id_of(&s.execute("shape.create", json!({"kind": "rect", "rect": B, "fill": "#3060c0"})).unwrap());
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[LayerId]) {
|
||||
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
|
||||
for id in &ids[1..] {
|
||||
s.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn active(s: &Session) -> &Layer {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The merged layer hugs the shapes (anti-aliased edges may add a pixel) and still shows them.
|
||||
fn assert_tight(s: &Session, what: &str) {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let layer = active(s);
|
||||
let b = transform_bounds(doc, layer);
|
||||
let grown = Rect::new(BOTH.x0 - 1, BOTH.y0 - 1, BOTH.x1 + 1, BOTH.y1 + 1);
|
||||
assert!(grown.contains_rect(&b) && b.contains_rect(&Rect::new(BOTH.x0 + 1, BOTH.y0 + 1, BOTH.x1 - 1, BOTH.y1 - 1)), "{what}: bounds {b:?}, want ~{BOTH:?}");
|
||||
let surf = layer.surface().unwrap();
|
||||
// Inside each shape: opaque. Between them and far away: transparent.
|
||||
assert!(surf.rgba(A[0] + 5, A[1] + 5)[3] > 0.99, "{what}: first shape kept");
|
||||
assert!(surf.rgba(B[0] + 5, B[1] + 5)[3] > 0.99, "{what}: second shape kept");
|
||||
assert!(surf.rgba(70, 310)[3] < 0.01, "{what}: gap stays transparent");
|
||||
assert!(surf.rgba(5, 5)[3] < 0.01, "{what}: corner stays transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_layers_keeps_content_bounds_in_every_mode() {
|
||||
for &(mode, depth) in CASES {
|
||||
let (mut s, a, b) = scene(mode, depth);
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("layer.mergeLayers", json!({})).unwrap();
|
||||
assert_tight(&s, &format!("Merge Layers {mode} {depth}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_visible_keeps_content_bounds_in_every_mode() {
|
||||
for &(mode, depth) in CASES {
|
||||
let (mut s, _, _) = scene(mode, depth);
|
||||
s.execute("layer.mergeVisible", json!({})).unwrap();
|
||||
assert_tight(&s, &format!("Merge Visible {mode} {depth}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamps_keep_content_bounds_in_every_mode() {
|
||||
for &(mode, depth) in CASES {
|
||||
let (mut s, _, _) = scene(mode, depth);
|
||||
s.execute("layer.stampVisible", json!({})).unwrap();
|
||||
assert_tight(&s, &format!("Stamp Visible {mode} {depth}"));
|
||||
|
||||
let (mut s, a, b) = scene(mode, depth);
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("layer.stampDown", json!({})).unwrap();
|
||||
assert_tight(&s, &format!("Stamp Down {mode} {depth}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill layer may draw anywhere, so merging one renders the whole canvas; its mask still
|
||||
/// limits the merged layer to the shapes.
|
||||
#[test]
|
||||
fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() {
|
||||
let masked_scene = |mode: &str, depth: u32| {
|
||||
let (mut s, a, _) = scene(mode, depth);
|
||||
s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap();
|
||||
let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap());
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
(s, a, fill)
|
||||
};
|
||||
for &(mode, depth) in CASES {
|
||||
let (mut s, a, fill) = masked_scene(mode, depth);
|
||||
select(&mut s, &[a, fill]);
|
||||
s.execute("layer.mergeLayers", json!({})).unwrap();
|
||||
assert_tight(&s, &format!("Merge Layers with a fill layer {mode} {depth}"));
|
||||
|
||||
let (mut s, _, _) = masked_scene(mode, depth);
|
||||
s.execute("layer.mergeVisible", json!({})).unwrap();
|
||||
assert_tight(&s, &format!("Merge Visible with a fill layer {mode} {depth}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Over an opaque Background, Merge Visible still makes an opaque, canvas-sized Background.
|
||||
#[test]
|
||||
fn merge_visible_onto_a_background_stays_opaque() {
|
||||
for &(mode, depth) in CASES {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth, "background": "white"})).unwrap();
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": A, "fill": "#e07020"})).unwrap();
|
||||
s.execute("layer.mergeVisible", json!({})).unwrap();
|
||||
let what = format!("{mode} {depth}");
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let layer = active(&s);
|
||||
assert_eq!(layer.name, "Background", "{what}");
|
||||
assert_eq!(transform_bounds(doc, layer), Rect::new(0, 0, W as i32, H as i32), "{what}");
|
||||
let white = layer.surface().unwrap().rgba(5, 5);
|
||||
assert!(white.iter().all(|c| *c > 0.98), "{what}: Background stays white, got {white:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo restores both layers after the fixed merge.
|
||||
#[test]
|
||||
fn merge_layers_in_cmyk_is_one_undo_step() {
|
||||
let (mut s, a, b) = scene("cmyk", 8);
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("layer.mergeLayers", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert!(doc.layer(a).is_some() && doc.layer(b).is_some());
|
||||
}
|
||||
@@ -2,3 +2,4 @@ target/
|
||||
corpus/
|
||||
artifacts/
|
||||
Cargo.lock
|
||||
coverage/
|
||||
|
||||
@@ -592,6 +592,15 @@ impl Loader<'_> {
|
||||
}
|
||||
let mut patterns = Vec::with_capacity(m.patterns.len());
|
||||
for p in &m.patterns {
|
||||
// Sizes come from the file: a pattern with no tiles can claim any size, and renderers
|
||||
// allocate per pixel.
|
||||
let max = photocraft_doc::pattern::MAX_EDGE;
|
||||
if p.width == 0 || p.height == 0 {
|
||||
return Err(FormatError::corrupt(format!("pattern `{}` is {}×{} px", p.name, p.width, p.height)));
|
||||
}
|
||||
if p.width > max || p.height > max {
|
||||
return Err(FormatError::LimitExceeded(format!("pattern `{}` is {}×{} px (max {max} per side)", p.name, p.width, p.height)));
|
||||
}
|
||||
patterns.push(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface: self.surface(&p.surface)? });
|
||||
}
|
||||
let layer_comps = m.layer_comps.iter().map(|c| self.comp(c)).collect::<Result<Vec<_>>>()?;
|
||||
|
||||
+32
-33
@@ -30,14 +30,24 @@ fn compress(data: &[u8]) -> Vec<u8> {
|
||||
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
|
||||
}
|
||||
|
||||
fn decompress(data: &[u8], expected: usize, what: &str) -> Result<Vec<u8>> {
|
||||
let mut dec = ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?;
|
||||
let mut out = Vec::with_capacity(expected.min(64 << 20));
|
||||
(&mut dec).take(expected as u64 + 1).read_to_end(&mut out).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?;
|
||||
if out.len() != expected {
|
||||
return Err(FormatError::corrupt(format!("{what}: expected {expected} bytes, got {}", out.len())));
|
||||
/// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the
|
||||
/// decoded length must be exactly that (`Corrupt` otherwise).
|
||||
fn decompress_bounded(data: &[u8], max: usize, exact: Option<usize>, what: &str) -> Result<Vec<u8>> {
|
||||
let zstd = |e: &dyn std::fmt::Display| FormatError::corrupt(format!("{what}: zstd: {e}"));
|
||||
let mut dec = ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| zstd(&e))?;
|
||||
let mut out = Vec::with_capacity(exact.unwrap_or(0).min(max).min(64 << 20));
|
||||
// `max` may be `usize::MAX`: one byte past it must not overflow.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
(&mut dec).take(limit).read_to_end(&mut out).map_err(|e| zstd(&e))?;
|
||||
match exact {
|
||||
Some(n) if out.len() != n => Err(FormatError::corrupt(format!("{what}: expected {n} bytes, got {}", out.len()))),
|
||||
None if out.len() > max => Err(FormatError::LimitExceeded(format!("{what} exceeds {max} bytes"))),
|
||||
_ => Ok(out),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decompress(data: &[u8], expected: usize, what: &str) -> Result<Vec<u8>> {
|
||||
decompress_bounded(data, expected, Some(expected), what)
|
||||
}
|
||||
|
||||
fn hash_bytes(b: &[u8]) -> Hash {
|
||||
@@ -228,12 +238,7 @@ impl Fetch for LoadFetch<'_> {
|
||||
let z = self.src.get(&path, self.opts.max_blob_bytes)?;
|
||||
// Blob length is not in the manifest; the frame header bounds it and
|
||||
// we cap at max_blob_bytes.
|
||||
let mut dec = ruzstd::decoding::StreamingDecoder::new(&z[..]).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?;
|
||||
let mut data = Vec::new();
|
||||
(&mut dec).take(self.opts.max_blob_bytes as u64 + 1).read_to_end(&mut data).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?;
|
||||
if data.len() > self.opts.max_blob_bytes {
|
||||
return Err(FormatError::LimitExceeded(format!("{path} exceeds max_blob_bytes")));
|
||||
}
|
||||
let data = decompress_bounded(&z, self.opts.max_blob_bytes, None, &path)?;
|
||||
self.account(data.len())?;
|
||||
if hash_bytes(&data) != hash {
|
||||
return Err(FormatError::corrupt(format!("{path}: content does not match its hash")));
|
||||
@@ -313,13 +318,7 @@ impl Sink for Collect<'_> {
|
||||
self.tiles.entry(h.clone()).or_insert_with(|| (tile.clone(), format.sample));
|
||||
return h;
|
||||
}
|
||||
let h = if cfg!(target_endian = "big") {
|
||||
let mut b = tile.bytes().to_vec();
|
||||
swap_to_le(&mut b, format.sample);
|
||||
hash_bytes(&b)
|
||||
} else {
|
||||
hash_bytes(tile.bytes())
|
||||
};
|
||||
let h = hash_bytes(&tile_le(tile, format.sample));
|
||||
if let Some(cache) = self.hash_cache.as_deref_mut() {
|
||||
cache.insert(key, (Arc::downgrade(tile), h.clone()));
|
||||
}
|
||||
@@ -414,21 +413,23 @@ enum Object {
|
||||
|
||||
impl Object {
|
||||
fn compressed(&self) -> Vec<u8> {
|
||||
match self {
|
||||
Object::Tile(t, s) => compress(&tile_le(t, *s)),
|
||||
Object::Blob(b) => compress(b),
|
||||
}
|
||||
compress(&self.content())
|
||||
}
|
||||
|
||||
fn matches_content_hash(&self, compressed: &[u8], what: &str) -> bool {
|
||||
let expected = match self {
|
||||
/// The bytes this object stores (tiles little-endian).
|
||||
fn content(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||
match self {
|
||||
Object::Tile(tile, sample) => tile_le(tile, *sample),
|
||||
Object::Blob(blob) => std::borrow::Cow::Borrowed(blob.as_slice()),
|
||||
};
|
||||
decompress(compressed, expected.len(), what).is_ok_and(|actual| hash_bytes(&actual) == hash_bytes(&expected))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `compressed` decodes to exactly `expected`.
|
||||
fn content_matches(expected: &[u8], compressed: &[u8], what: &str) -> bool {
|
||||
decompress(compressed, expected.len(), what).is_ok_and(|actual| hash_bytes(&actual) == hash_bytes(expected))
|
||||
}
|
||||
|
||||
fn file_signature(metadata: &std::fs::Metadata) -> Option<FileSignature> {
|
||||
#[cfg(unix)]
|
||||
let unix = {
|
||||
@@ -587,10 +588,8 @@ fn existing_object_is_valid(path: &Path, object: &Object, what: &str) -> Result<
|
||||
}
|
||||
let signature = file_signature(&metadata);
|
||||
|
||||
let expected_len = match object {
|
||||
Object::Tile(tile, sample) => tile_le(tile, *sample).len(),
|
||||
Object::Blob(blob) => blob.len(),
|
||||
};
|
||||
let expected = object.content();
|
||||
let expected_len = expected.len();
|
||||
// This is the same bound used by the tile loader. It comfortably includes
|
||||
// zstd framing overhead while keeping damaged files bounded when inspected.
|
||||
let max_compressed_len = expected_len.saturating_add(expected_len / 8).saturating_add(4096);
|
||||
@@ -604,7 +603,7 @@ fn existing_object_is_valid(path: &Path, object: &Object, what: &str) -> Result<
|
||||
if compressed.len() > max_compressed_len {
|
||||
return Ok((false, None));
|
||||
}
|
||||
Ok((object.matches_content_hash(&compressed, what), signature))
|
||||
Ok((content_matches(&expected, &compressed, what), signature))
|
||||
}
|
||||
|
||||
/// A bundle path and its compressed bytes.
|
||||
|
||||
@@ -201,7 +201,12 @@ impl<'a> ZipReader<'a> {
|
||||
if e.uncompressed > max {
|
||||
return Err(FormatError::LimitExceeded(format!("zip entry `{}` is {} bytes (max {max})", e.name, e.uncompressed)));
|
||||
}
|
||||
let raw = &self.bytes[e.data_offset..e.data_offset + e.compressed];
|
||||
// `Entry` is public and cloneable: one from another archive must not index out of range.
|
||||
let raw = e
|
||||
.data_offset
|
||||
.checked_add(e.compressed)
|
||||
.and_then(|end| self.bytes.get(e.data_offset..end))
|
||||
.ok_or_else(|| FormatError::corrupt(format!("zip entry `{}` out of range", e.name)))?;
|
||||
let data = match e.method {
|
||||
0 => raw.to_vec(),
|
||||
8 => {
|
||||
@@ -366,6 +371,19 @@ mod tests {
|
||||
assert!(matches!(archive.read_by_name("manifest.json", usize::MAX), Err(FormatError::Corrupt(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_from_another_archive_is_an_error_not_a_panic() {
|
||||
let mut big = ZipWriter::new();
|
||||
big.add("a", &[7; 4096]).unwrap();
|
||||
let big = big.finish().unwrap();
|
||||
let foreign = ZipReader::new(&big).unwrap().entries[0].clone();
|
||||
let mut small = ZipWriter::new();
|
||||
small.add("a", b"x").unwrap();
|
||||
let small = small.finish().unwrap();
|
||||
let r = ZipReader::new(&small).unwrap();
|
||||
assert!(matches!(r.read(&foreign, usize::MAX), Err(FormatError::Corrupt(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_access_rejects_overflow() {
|
||||
assert!(u16_at(&[], usize::MAX).is_err());
|
||||
|
||||
@@ -419,6 +419,40 @@ fn tile_coordinates_outside_the_i32_pixel_range_are_rejected() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlimited_blob_budget_still_loads_blobs() {
|
||||
// `max_blob_bytes + 1` overflowed (debug panic; release read zero bytes, failing every blob).
|
||||
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
|
||||
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
|
||||
let opts = LoadOptions { max_blob_bytes: usize::MAX, ..LoadOptions::default() };
|
||||
assert_eq!(load_from_bytes_with(&bytes, &opts).unwrap(), doc);
|
||||
}
|
||||
|
||||
/// Rewrites the first pattern's declared size (its tiles stay as saved).
|
||||
fn with_pattern_size(w: u64, h: u64) -> Vec<u8> {
|
||||
rebuild(sample_cached(), |n, d| {
|
||||
if n != "manifest.json" {
|
||||
return Some(d);
|
||||
}
|
||||
let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap();
|
||||
let p = &mut v["document"]["patterns"][0];
|
||||
assert!(p.is_object(), "the sample has a pattern");
|
||||
p["width"] = w.into();
|
||||
p["height"] = h.into();
|
||||
Some(serde_json::to_vec(&v).unwrap())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_sizes_from_the_file_are_checked() {
|
||||
// A pattern can claim any size without tiles; painting it allocated per pixel (abort).
|
||||
let huge = load_from_bytes(&with_pattern_size(65_535, 65_535));
|
||||
assert!(matches!(huge, Err(FormatError::LimitExceeded(_))), "{huge:?}");
|
||||
let empty = load_from_bytes(&with_pattern_size(0, 24));
|
||||
assert!(matches!(empty, Err(FormatError::Corrupt(_))), "{empty:?}");
|
||||
assert!(load_from_bytes(&with_pattern_size(32, 24)).is_ok());
|
||||
}
|
||||
|
||||
/// A canvas is a few bytes of manifest JSON; one past the budget is refused before any work (#2935).
|
||||
/// The default budget is the largest canvas the app creates (300000 × 300000, as `file.new`,
|
||||
/// Canvas Size and PSB import allow), so everything a user can save reopens.
|
||||
|
||||
@@ -389,7 +389,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
h = b.push(st);
|
||||
}
|
||||
if let Some(t) = &bv.texture
|
||||
&& let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| !p.is_empty())
|
||||
&& let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| p.is_paintable())
|
||||
{
|
||||
// Texture element (`effects::bevel_height`): luminance × depth / unit added.
|
||||
let unit = if bv.depth.abs() > 1e-6 { (g.depth / bv.depth).abs().max(1e-3) } else { g.width.max(1.0) };
|
||||
|
||||
@@ -803,7 +803,7 @@ impl<'a> Planner<'a> {
|
||||
// compose::render_fill: the pattern tiled from the layer's frame (transparent
|
||||
// when missing), then the layer's masks.
|
||||
let empty = self.clear();
|
||||
let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) else { return Ok(empty) };
|
||||
let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) else { return Ok(empty) };
|
||||
let frame = photocraft_compose::fill_frame(layer, self.cx.canvas);
|
||||
let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) };
|
||||
let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle));
|
||||
@@ -1097,7 +1097,7 @@ impl<'a> Planner<'a> {
|
||||
};
|
||||
for &(_, e) in &rev {
|
||||
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e
|
||||
&& let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty())
|
||||
&& let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable())
|
||||
{
|
||||
let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle));
|
||||
l = self.paint(l, content, Cov::One, &paint, common.blend, common.opacity, F_GATE, clip, sb);
|
||||
@@ -1312,7 +1312,7 @@ impl<'a> Planner<'a> {
|
||||
match p {
|
||||
FxPaint::Color(c) => Paint::Color(c.to_rgb()),
|
||||
FxPaint::Gradient(g) => Paint::Gradient(g),
|
||||
FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) {
|
||||
FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) {
|
||||
Some(pat) => Paint::Pattern(pat, placement(anchor, true, (0.0, 0.0), *scale, 0.0)),
|
||||
None => Paint::None,
|
||||
},
|
||||
|
||||
@@ -9,11 +9,11 @@ rust-version.workspace = true
|
||||
[dependencies]
|
||||
# Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files;
|
||||
# moving the pin is a reviewed change (re-run the fuzz target and the heif corpus tests).
|
||||
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
|
||||
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
|
||||
|
||||
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
|
||||
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -20,7 +20,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
return import_tiff_page(name, bytes, None);
|
||||
}
|
||||
let img = codecs::decode(bytes)?;
|
||||
// A palette BMP opens in Indexed Color mode with its colour table, as in Photoshop (#3802).
|
||||
let bmp_palette = if codecs::detect(bytes) == Some(Format::Bmp) { codecs::bmp_palette(bytes).ok().flatten() } else { None };
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
if let Some(colors) = bmp_palette.filter(|c| !c.is_empty())
|
||||
&& r.document.mode == ColorMode::Rgb
|
||||
{
|
||||
r.document.mode = ColorMode::Indexed;
|
||||
r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None });
|
||||
}
|
||||
r.document.file_encoding = file_encoding(bytes);
|
||||
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
|
||||
tga_alpha_to_channel(&mut r.document)?;
|
||||
|
||||
@@ -307,6 +307,7 @@ impl Ctx<'_> {
|
||||
});
|
||||
if let Some(txt2) = &self.txt2 {
|
||||
photocraft_text::psd::apply_txt2(&mut t, &data, txt2);
|
||||
photocraft_text::psd::apply_txt2_frame(&mut t, &data, txt2);
|
||||
}
|
||||
// Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and
|
||||
// other writers leave type layers without image data (Photoshop re-renders them on
|
||||
|
||||
@@ -54,6 +54,49 @@ codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0);
|
||||
codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0);
|
||||
codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0);
|
||||
|
||||
fn indexed_bmp_8() -> Vec<u8> {
|
||||
const FILE_HEADER_SIZE: usize = 14;
|
||||
const INFO_HEADER_SIZE: usize = 40;
|
||||
const PALETTE_ENTRY_SIZE: usize = 4;
|
||||
const PALETTE: [[u8; 3]; 2] = [[10, 20, 30], [200, 100, 50]];
|
||||
const PIXEL_ROW: [u8; 4] = [0, 1, 0, 0];
|
||||
let pixel_offset = FILE_HEADER_SIZE + INFO_HEADER_SIZE + PALETTE.len() * PALETTE_ENTRY_SIZE;
|
||||
let file_size = pixel_offset + PIXEL_ROW.len();
|
||||
let mut bytes = Vec::with_capacity(file_size);
|
||||
bytes.extend_from_slice(b"BM");
|
||||
bytes.extend_from_slice(&(file_size as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
bytes.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&(INFO_HEADER_SIZE as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&2i32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1i32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&8u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&(PIXEL_ROW.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&0i32.to_le_bytes());
|
||||
bytes.extend_from_slice(&0i32.to_le_bytes());
|
||||
bytes.extend_from_slice(&(PALETTE.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
for [r, g, b] in PALETTE {
|
||||
bytes.extend_from_slice(&[b, g, r, 0]);
|
||||
}
|
||||
bytes.extend_from_slice(&PIXEL_ROW);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_bmp_opens_with_its_color_table() {
|
||||
let doc = import("indexed.bmp", &indexed_bmp_8()).unwrap().document;
|
||||
assert_eq!(doc.mode, ColorMode::Indexed);
|
||||
assert_eq!(doc.depth, SampleType::U8);
|
||||
assert_eq!(doc.color_table.as_ref().unwrap().colors.get(..2), Some(&[[10, 20, 30], [200, 100, 50]][..]));
|
||||
let px = doc.layers[0].surface().unwrap().rgba(1, 0);
|
||||
assert!((px[0] - 200.0 / 255.0).abs() < 1e-6);
|
||||
assert!((px[1] - 100.0 / 255.0).abs() < 1e-6);
|
||||
assert!((px[2] - 50.0 / 255.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// FITS header cards travel as the document's text metadata, so an astro frame keeps its
|
||||
/// OBJECT, EXPTIME and the rest through an edit.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! #3795: a PSD type layer's alignment and character attributes can come from its style sheets
|
||||
//! rather than its runs. A run's `Properties` / `StyleSheetData` hold only its overrides; the rest
|
||||
//! comes from the paragraph style its `Name` names in `ParagraphSheetSet`, the character style in
|
||||
//! `StyleSheetSet` (by name, or the paragraph's `DefaultStyleSheet`), then the Normal sheets.
|
||||
//! Reading only the Normal sheets left a centred paragraph style read as Left, so starting an
|
||||
//! edit re-laid the text out half a line to the side.
|
||||
|
||||
use photocraft_doc::text::TextAlign;
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D};
|
||||
use photocraft_text::engine_data::Value as E;
|
||||
use photocraft_text::psd::{TySh, text_layer_from_tysh, write_tysh};
|
||||
|
||||
fn real(v: f64) -> E {
|
||||
E::Real(v)
|
||||
}
|
||||
|
||||
fn d(kv: Vec<(&str, E)>) -> E {
|
||||
E::Dict(kv.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
|
||||
}
|
||||
|
||||
/// Photoshop-shaped EngineData for one paragraph of point text. `para` is the paragraph run's
|
||||
/// `ParagraphSheet`, `style` the character run's `StyleSheet`; the sheet sets are the
|
||||
/// `ResourceDict`'s, with Normal at index 0.
|
||||
fn engine(text: &str, para: E, style: E, para_set: Vec<E>, style_set: Vec<E>) -> E {
|
||||
let ed_text = format!("{text}\r");
|
||||
let n = ed_text.encode_utf16().count() as i64;
|
||||
let res = d(vec![
|
||||
(
|
||||
"FontSet",
|
||||
E::Array(vec![d(vec![("Name", E::String("Inter-Regular".into())), ("Script", E::Int(0)), ("FontType", E::Int(1)), ("Synthetic", E::Int(0))])]),
|
||||
),
|
||||
("StyleSheetSet", E::Array(style_set)),
|
||||
("ParagraphSheetSet", E::Array(para_set)),
|
||||
("TheNormalStyleSheet", E::Int(0)),
|
||||
("TheNormalParagraphSheet", E::Int(0)),
|
||||
]);
|
||||
d(vec![
|
||||
(
|
||||
"EngineDict",
|
||||
d(vec![
|
||||
("Editor", d(vec![("Text", E::String(ed_text))])),
|
||||
("ParagraphRun", d(vec![("RunArray", E::Array(vec![d(vec![("ParagraphSheet", para)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])),
|
||||
("StyleRun", d(vec![("RunArray", E::Array(vec![d(vec![("StyleSheet", style)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])),
|
||||
(
|
||||
"Rendered",
|
||||
d(vec![(
|
||||
"Shapes",
|
||||
d(vec![(
|
||||
"Children",
|
||||
E::Array(vec![d(vec![
|
||||
("ShapeType", E::Int(0)),
|
||||
("Cookie", d(vec![("Photoshop", d(vec![("ShapeType", E::Int(0)), ("PointBase", E::Array(vec![real(0.0), real(0.0)]))]))])),
|
||||
])]),
|
||||
)]),
|
||||
)]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
("ResourceDict", res),
|
||||
])
|
||||
}
|
||||
|
||||
fn normal_char(size: f64) -> E {
|
||||
d(vec![
|
||||
("Name", E::String("Normal RGB".into())),
|
||||
("StyleSheetData", d(vec![("Font", E::Int(0)), ("FontSize", real(size)), ("AutoLeading", E::Bool(true))])),
|
||||
])
|
||||
}
|
||||
|
||||
fn para_sheet(name: &str, justification: i64, default_style: i64) -> E {
|
||||
d(vec![("Name", E::String(name.into())), ("DefaultStyleSheet", E::Int(default_style)), ("Properties", d(vec![("Justification", E::Int(justification))]))])
|
||||
}
|
||||
|
||||
fn layer(text: &str, ed: &E) -> photocraft_doc::TextLayer {
|
||||
let desc = Descriptor::new("TxLr")
|
||||
.with("Txt ", D::Text(UnicodeString::new_nul(text)))
|
||||
.with("Ornt", D::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Hrzn") })
|
||||
.with("TextIndex", D::Integer(0))
|
||||
.with("EngineData", D::RawData(photocraft_text::engine_data::write(ed)));
|
||||
let data = write_tysh(&TySh { transform: Affine::translate(450.0, 300.0), text: desc, warp: None, bounds: [0.0; 4] });
|
||||
let mut t = text_layer_from_tysh(&data, 72.0).expect("parses");
|
||||
// Import keeps the block, as for a layer read from a file.
|
||||
t.psd_raw = Some(std::sync::Arc::new(data));
|
||||
t
|
||||
}
|
||||
|
||||
/// The paragraph run names a centred (or right-aligned) paragraph style and overrides nothing.
|
||||
#[test]
|
||||
fn alignment_from_the_paragraph_style_the_run_names() {
|
||||
for (j, want) in [(2, TextAlign::Center), (1, TextAlign::Right)] {
|
||||
let ed = engine(
|
||||
"LOREM IPSUM",
|
||||
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]),
|
||||
d(vec![("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", j, 0)],
|
||||
vec![normal_char(48.0)],
|
||||
);
|
||||
let t = layer("LOREM IPSUM", &ed);
|
||||
assert_eq!(t.paragraphs[0].style.align, want, "Justification {j} from the paragraph style");
|
||||
// Laid out around the anchor like Photoshop draws it.
|
||||
let b = photocraft_text::TextEngine::new().layout(&t, 72.0).bounds().unwrap();
|
||||
match want {
|
||||
TextAlign::Center => assert!((b[0] + b[2]).abs() < 1.0, "centred on the anchor: {b:?}"),
|
||||
_ => assert!(b[2].abs() < 1.0, "ends at the anchor: {b:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's own value still wins over its paragraph style, and an unknown style name or an
|
||||
/// out-of-range sheet index falls back to Normal.
|
||||
#[test]
|
||||
fn run_overrides_win_and_bad_references_fall_back_to_normal() {
|
||||
let ed = engine(
|
||||
"Text",
|
||||
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![("Justification", E::Int(1))]))]),
|
||||
d(vec![("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 0)],
|
||||
vec![normal_char(48.0)],
|
||||
);
|
||||
assert_eq!(layer("Text", &ed).paragraphs[0].style.align, TextAlign::Right);
|
||||
let ed = engine(
|
||||
"Text",
|
||||
d(vec![("Name", E::String("Missing".into())), ("DefaultStyleSheet", E::Int(9)), ("Properties", d(vec![]))]),
|
||||
d(vec![("Name", E::String("Missing".into())), ("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 2, 0)],
|
||||
vec![normal_char(30.0)],
|
||||
);
|
||||
let t = layer("Text", &ed);
|
||||
assert_eq!(t.paragraphs[0].style.align, TextAlign::Center, "Normal's alignment");
|
||||
assert!((t.runs[0].style.size_pt - 30.0).abs() < 0.01, "Normal's size: {}", t.runs[0].style.size_pt);
|
||||
}
|
||||
|
||||
/// Character attributes from the paragraph's default character style (`DefaultStyleSheet`) and
|
||||
/// from a character style the run names.
|
||||
#[test]
|
||||
fn character_attributes_from_the_style_sheets() {
|
||||
let big = d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(72.0))]))]);
|
||||
let ed = engine(
|
||||
"Text",
|
||||
d(vec![("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]),
|
||||
d(vec![("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 0, 0)],
|
||||
vec![normal_char(12.0), big.clone()],
|
||||
);
|
||||
assert!((layer("Text", &ed).runs[0].style.size_pt - 72.0).abs() < 0.01, "the paragraph's default character style");
|
||||
let ed = engine(
|
||||
"Text",
|
||||
d(vec![("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]),
|
||||
d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 0, 0)],
|
||||
vec![normal_char(12.0), big],
|
||||
);
|
||||
let t = layer("Text", &ed);
|
||||
assert!((t.runs[0].style.size_pt - 72.0).abs() < 0.01, "the named character style: {}", t.runs[0].style.size_pt);
|
||||
// Other attributes still come from Normal.
|
||||
assert_eq!(
|
||||
t.runs[0].style.font_family,
|
||||
layer(
|
||||
"Text",
|
||||
&engine(
|
||||
"Text",
|
||||
d(vec![("Properties", d(vec![]))]),
|
||||
d(vec![("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 0, 0)],
|
||||
vec![normal_char(12.0)]
|
||||
)
|
||||
)
|
||||
.runs[0]
|
||||
.style
|
||||
.font_family
|
||||
);
|
||||
}
|
||||
|
||||
/// Export writes every run fully resolved and keeps the sheet sets: a round trip reads the same
|
||||
/// alignment and size, with one entry per sheet.
|
||||
#[test]
|
||||
fn styles_from_sheets_survive_a_round_trip() {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
let ed = engine(
|
||||
"LOREM IPSUM",
|
||||
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]),
|
||||
d(vec![("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)],
|
||||
vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])],
|
||||
);
|
||||
let mut t = layer("LOREM IPSUM", &ed);
|
||||
let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8);
|
||||
doc.resolution_dpi = 72.0;
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format());
|
||||
doc.layers.push(Layer::new("t", LayerContent::Text(t)));
|
||||
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") };
|
||||
assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center);
|
||||
assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt);
|
||||
}
|
||||
|
||||
/// A layer that inherits its alignment and size from sheets it no longer links to (a document
|
||||
/// without those styles) keeps them through export: runs are written with what they inherited.
|
||||
#[test]
|
||||
fn unlinked_runs_keep_their_inherited_style_on_export() {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
let ed = engine(
|
||||
"LOREM IPSUM",
|
||||
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]),
|
||||
d(vec![("StyleSheetData", d(vec![]))]),
|
||||
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)],
|
||||
vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])],
|
||||
);
|
||||
let mut t = layer("LOREM IPSUM", &ed);
|
||||
assert!(t.psd_raw.is_some());
|
||||
let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8);
|
||||
doc.resolution_dpi = 72.0;
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format());
|
||||
doc.layers.push(Layer::new("t", LayerContent::Text(t)));
|
||||
// No `text_styles_map::import`: the document has no styles and the runs link to none.
|
||||
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") };
|
||||
assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center);
|
||||
assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt);
|
||||
}
|
||||
+168
-3
@@ -5,8 +5,8 @@
|
||||
//! stores whole-document snapshots per transaction, which is simple, obviously correct, and the
|
||||
//! same approach Photoshop's History panel exposes to users (one state per step).
|
||||
//!
|
||||
//! Memory is bounded by `max_states` plus an approximate byte budget. Only tiles *not shared*
|
||||
//! with the current document count against it.
|
||||
//! Memory is bounded by `max_states` plus an approximate byte budget. The current document's
|
||||
//! tiles and tiles only history holds count against it, including selection masks.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -295,7 +295,7 @@ impl History {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels) not already in `seen`.
|
||||
/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels, selection) not already in `seen`.
|
||||
fn tile_bytes(doc: &Document, seen: &mut HashSet<usize>) -> usize {
|
||||
let mut n = 0usize;
|
||||
tiles::surfaces(doc, |surface| {
|
||||
@@ -544,6 +544,85 @@ mod tests {
|
||||
assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_tiles_count_current_and_history_without_double_counting() {
|
||||
use photocraft_doc::{PixelFormat, Surface};
|
||||
for depth in SampleType::ALL {
|
||||
let mut doc = Document::new("selection", Size::new(300, 64), ColorMode::Rgb, depth);
|
||||
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
|
||||
selection.write_pixel(1, 1, &[1.0]);
|
||||
selection.write_pixel(257, 1, &[1.0]);
|
||||
doc.selection = Some(selection);
|
||||
let mut cur = Arc::new(doc);
|
||||
let tile = match depth {
|
||||
SampleType::U8 => 65_536,
|
||||
SampleType::U16 => 131_072,
|
||||
SampleType::F32 => 262_144,
|
||||
};
|
||||
let mut h = History { max_bytes: usize::MAX, ..History::default() };
|
||||
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "current selection counts without any history");
|
||||
assert_eq!(h.unique_bytes(&cur), 0);
|
||||
edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into());
|
||||
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "the shared selection tiles count once");
|
||||
edit(&mut h, &mut cur, "Selection", |doc| {
|
||||
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[0.5]);
|
||||
});
|
||||
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "only the changed selection tile is copied");
|
||||
assert_eq!(h.unique_bytes(&cur), tile);
|
||||
h.trim(&cur);
|
||||
assert_counter(&h, &cur);
|
||||
let selected_value = cur.selection.as_ref().unwrap().sample_channel(1, 1, 0);
|
||||
edit(&mut h, &mut cur, "Deselect", |doc| doc.selection = None);
|
||||
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "deselecting leaves the previous masks in history");
|
||||
assert_eq!(h.unique_bytes(&cur), 3 * tile);
|
||||
assert_counter(&h, &cur);
|
||||
cur = h.undo(cur).unwrap().0;
|
||||
assert_eq!(cur.selection.as_ref().unwrap().sample_channel(1, 1, 0), selected_value);
|
||||
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
|
||||
assert_eq!(h.unique_bytes(&cur), tile);
|
||||
assert_counter(&h, &cur);
|
||||
// Coalescing can change the current mask without recording another state.
|
||||
Arc::make_mut(&mut cur).selection.as_mut().unwrap().write_pixel(257, 1, &[0.25]);
|
||||
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
|
||||
assert_eq!(h.trim(&cur), 0);
|
||||
assert_counter(&h, &cur);
|
||||
cur = h.redo(cur).unwrap().0;
|
||||
assert!(cur.selection.is_none());
|
||||
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
|
||||
assert_counter(&h, &cur);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_byte_budget_drops_old_masks_and_keeps_the_last_undo() {
|
||||
use photocraft_doc::{PixelFormat, Surface};
|
||||
for depth in SampleType::ALL {
|
||||
let mut doc = Document::new("selection", Size::new(64, 64), ColorMode::Rgb, depth);
|
||||
doc.selection = Some(Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)));
|
||||
let mut cur = Arc::new(doc);
|
||||
let tile = 256 * 256 * depth.bytes();
|
||||
let mut h = History::default();
|
||||
for i in 1..=6 {
|
||||
edit(&mut h, &mut cur, "Selection", |doc| {
|
||||
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0]);
|
||||
});
|
||||
}
|
||||
assert_eq!(h.pixel_bytes(&cur), 6 * tile);
|
||||
h.max_bytes = 3 * tile;
|
||||
assert_eq!(h.trim(&cur), 4);
|
||||
assert_eq!(h.past_len(), 2);
|
||||
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
|
||||
assert_eq!(h.unique_bytes(&cur), 2 * tile);
|
||||
assert_counter(&h, &cur);
|
||||
h.max_bytes = 1;
|
||||
assert_eq!(h.trim(&cur), 1);
|
||||
assert_eq!(h.past_len(), 1, "the most recent undo survives even below one mask's size");
|
||||
assert_eq!(h.pixel_bytes(&cur), 2 * tile);
|
||||
cur = h.undo(cur).unwrap().0;
|
||||
assert_counter(&h, &cur);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_counter(h: &History, current: &Document) {
|
||||
let counter = h.tiles.as_ref().expect("budget initializes the counter");
|
||||
let total = tile_bytes(current, &mut HashSet::new()).saturating_add(h.unique_bytes(current));
|
||||
@@ -555,15 +634,100 @@ mod tests {
|
||||
assert_eq!(counter.bytes_with(&empty), retained as u128);
|
||||
}
|
||||
|
||||
fn video_document(depth: SampleType, frames: usize) -> Document {
|
||||
let mut doc = Document::new("video", Size::new(64, 64), ColorMode::Rgb, depth);
|
||||
let mut layer = Layer::raster("Video", doc.pixel_format());
|
||||
let footage: Vec<_> = (0..frames)
|
||||
.map(|i| {
|
||||
let mut surface = photocraft_doc::Surface::new(doc.pixel_format());
|
||||
surface.write_pixel(1, 1, &[i as f32 / frames as f32, 0.0, 0.0, 1.0]);
|
||||
surface
|
||||
})
|
||||
.collect();
|
||||
layer.content = photocraft_doc::LayerContent::Raster(footage[0].clone());
|
||||
layer.video = Some(photocraft_doc::VideoData::new(footage, 24.0));
|
||||
doc.layers.push(layer);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_memory_counts_originals_and_deduplicates_displayed_frames() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut doc = video_document(depth, 3);
|
||||
let tile = doc.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len();
|
||||
let video = doc.layers[0].video.as_mut().unwrap();
|
||||
video.original_frames = Some(video.frames.clone());
|
||||
// Original footage and the displayed frame alias the stack, except this painted frame.
|
||||
video.frames[2].write_pixel(1, 1, &[0.0, 1.0, 0.0, 1.0]);
|
||||
let mut cur = Arc::new(doc);
|
||||
let mut h = History { max_bytes: usize::MAX, ..History::default() };
|
||||
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
|
||||
for name in ["A", "B"] {
|
||||
edit(&mut h, &mut cur, "Rename", |doc| doc.name = name.into());
|
||||
h.trim(&cur);
|
||||
}
|
||||
assert_counter(&h, &cur);
|
||||
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
|
||||
assert_eq!(h.unique_bytes(&cur), 0);
|
||||
// Rasterizing keeps the displayed frame; the rest of the clip survives only in undo.
|
||||
edit(&mut h, &mut cur, "Rasterize", |doc| doc.layers[0].video = None);
|
||||
assert_eq!(h.unique_bytes(&cur), 3 * tile);
|
||||
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
|
||||
assert_counter(&h, &cur);
|
||||
cur = h.undo(cur).unwrap().0;
|
||||
assert_eq!(h.unique_bytes(&cur), 0);
|
||||
assert_counter(&h, &cur);
|
||||
cur = h.redo(cur).unwrap().0;
|
||||
assert_eq!(h.unique_bytes(&cur), 3 * tile);
|
||||
assert_counter(&h, &cur);
|
||||
h.clear();
|
||||
assert_eq!(h.pixel_bytes(&cur), tile);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_memory_budget_evicts_deleted_off_playhead_frames() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut cur = Arc::new(video_document(depth, 6));
|
||||
let tile = cur.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len();
|
||||
let mut h = History::default();
|
||||
for _ in 0..4 {
|
||||
edit(&mut h, &mut cur, "Delete Frame", |doc| {
|
||||
doc.layers[0].video.as_mut().unwrap().frames.remove(1);
|
||||
});
|
||||
}
|
||||
assert_eq!(h.pixel_bytes(&cur), 6 * tile);
|
||||
assert_eq!(h.unique_bytes(&cur), 4 * tile);
|
||||
h.max_bytes = 3 * tile;
|
||||
assert_eq!(h.trim(&cur), 3);
|
||||
assert_eq!(h.past_len(), 1);
|
||||
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
|
||||
assert_eq!(h.unique_bytes(&cur), tile);
|
||||
assert_counter(&h, &cur);
|
||||
cur = h.undo(cur).unwrap().0;
|
||||
assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 3);
|
||||
assert_counter(&h, &cur);
|
||||
cur = h.redo(cur).unwrap().0;
|
||||
assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 2);
|
||||
assert_counter(&h, &cur);
|
||||
// A budget below the remaining clip still keeps the latest step undoable.
|
||||
h.max_bytes = 1;
|
||||
assert_eq!(h.trim(&cur), 0);
|
||||
assert!(h.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_tracks_eviction_undo_redo_branching_purge_and_clones() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut cur = Arc::new(Document::with_background("h", Size::new(300, 64), ColorMode::Rgb, depth, Color::WHITE));
|
||||
Arc::make_mut(&mut cur).selection = Some(photocraft_doc::Surface::new(photocraft_doc::PixelFormat::GRAY8));
|
||||
let mut h = History::new(4);
|
||||
h.max_bytes = usize::MAX;
|
||||
for i in 0..8 {
|
||||
edit(&mut h, &mut cur, "Paint", |doc| {
|
||||
doc.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0, 0.0, 0.0, 1.0]);
|
||||
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0]);
|
||||
});
|
||||
h.trim(&cur);
|
||||
if i >= 1 {
|
||||
@@ -600,6 +764,7 @@ mod tests {
|
||||
gray.write_pixel(1, 1, &[1.0]);
|
||||
doc.layers[0].mask = Some(LayerMask { surface: gray.clone(), ..LayerMask::reveal_all() });
|
||||
doc.channels.push(AlphaChannel::new("alpha", gray.clone()));
|
||||
doc.selection = Some(gray.clone());
|
||||
doc.quick_mask = Some(AlphaChannel::new("quick", gray));
|
||||
doc.layers.push(doc.layers[0].clone());
|
||||
let mut cur = Arc::new(doc);
|
||||
|
||||
@@ -19,10 +19,20 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) {
|
||||
if let Some(mask) = &layer.mask {
|
||||
visit(&mask.surface);
|
||||
}
|
||||
if let Some(video) = &layer.video {
|
||||
// Undo retains the whole clip, not only the displayed frame. Visit aliases too:
|
||||
// Tiles tracks every owner and counts their shared allocations only once.
|
||||
for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) {
|
||||
visit(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
for channel in doc.channels.iter().chain(&doc.quick_mask) {
|
||||
visit(&channel.surface);
|
||||
}
|
||||
if let Some(selection) = &doc.selection {
|
||||
visit(selection);
|
||||
}
|
||||
}
|
||||
|
||||
impl Tiles {
|
||||
|
||||
@@ -2,3 +2,4 @@ target
|
||||
corpus
|
||||
artifacts
|
||||
coverage
|
||||
Cargo.lock
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
corpus/
|
||||
artifacts/
|
||||
coverage/
|
||||
Cargo.lock
|
||||
@@ -702,7 +702,11 @@ impl Layouter {
|
||||
Some(b) => b + leading + pending_space,
|
||||
};
|
||||
pending_space = 0.0;
|
||||
let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline + descent > box_rect.1 + box_rect.3 + 0.5 };
|
||||
// Photoshop keeps a horizontal line whose baseline is inside the box and lets
|
||||
// its descenders hang below the bottom edge (#1317: three 35 pt lines in a
|
||||
// 91.3 pt box, the last baseline at 88). Requiring the descent to fit dropped
|
||||
// that line.
|
||||
let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline > box_rect.1 + box_rect.3 + 0.5 };
|
||||
if is_box && overflow {
|
||||
stop = true;
|
||||
break;
|
||||
|
||||
+173
-30
@@ -12,7 +12,7 @@
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
use photocraft_doc::TextLayer;
|
||||
use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_geom::{Affine, Point};
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor};
|
||||
|
||||
use crate::engine_data::{self as ed, Value as E};
|
||||
@@ -212,6 +212,83 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
|
||||
layer.sync_summary();
|
||||
}
|
||||
|
||||
/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it.
|
||||
fn txt2_frame(txt2: &E, index: usize) -> Option<&E> {
|
||||
let object = txt2.path(&["1", "1"]).and_then(E::as_array)?.get(index)?;
|
||||
let frame =
|
||||
object.path(&["1", "2"]).and_then(E::as_array)?.first()?.get("6").and_then(E::as_array)?.iter().find_map(|f| f.get("10").and_then(E::as_i64))?;
|
||||
txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(usize::try_from(frame).ok()?)?.get("0")
|
||||
}
|
||||
|
||||
/// Places `layer` where Photoshop draws it when the text object's frame in `Txt2` is moved
|
||||
/// (#1317). Recent Photoshop keeps a type layer's position partly in its `Txt2` frame: a matrix
|
||||
/// (`/2 /2`) from the frame's space to the `TySh` text space and, for point text, the anchor
|
||||
/// (`/0`), while EngineData's `PointBase` stays at the origin and `BoxBounds` stays in the
|
||||
/// frame's space. Ignoring them put the text far from Photoshop's pixels (off the canvas, so
|
||||
/// it "disappeared" as soon as an edit started), and point text a line too high and off its
|
||||
/// alignment anchor. Only when the layer's `TextIndex` entry still holds the layer's text, as
|
||||
/// for [`apply_txt2`], and the frame agrees with the `TySh` `bounds`.
|
||||
pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
|
||||
let Some(t) = parse_tysh(tysh) else { return };
|
||||
let Some(index) = (match t.text.get("TextIndex") {
|
||||
Some(D::Integer(i)) => usize::try_from(*i).ok(),
|
||||
_ => None,
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str);
|
||||
if text.is_none_or(|t| t.strip_suffix('\r').unwrap_or(t).replace('\r', "\n") != layer.text) {
|
||||
return;
|
||||
}
|
||||
let Some(frame) = txt2_frame(txt2, index) else { return };
|
||||
let m = arr_f(frame.path(&["2", "2"]));
|
||||
let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY };
|
||||
let at = match layer.shape {
|
||||
TextShape::Point => {
|
||||
let p = arr_f(frame.get("0"));
|
||||
if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY }
|
||||
}
|
||||
TextShape::Box { .. } => Affine::IDENTITY,
|
||||
};
|
||||
let to_text = matrix.mul(&at);
|
||||
if to_text == Affine::IDENTITY || to_text.determinant().abs() <= 1e-9 {
|
||||
return;
|
||||
}
|
||||
// The frame's origin (point text: the anchor; box text: the box's top left) must land in
|
||||
// the text-space `bounds` Photoshop wrote beside it, and not already be there without it. A `TySh` PhotoCraft rewrote (an edited
|
||||
// or moved layer whose text is unchanged keeps its old `Txt2` object and frame) already has
|
||||
// the frame folded into its transform and its `bounds` near the origin: applying the frame
|
||||
// again would move it twice.
|
||||
let origin = match layer.shape {
|
||||
TextShape::Point => Point::new(0.0, 0.0),
|
||||
TextShape::Box { .. } => {
|
||||
let b = engine_data(&t.text)
|
||||
.map(|e| {
|
||||
arr_f(
|
||||
e.path(&["EngineDict", "Rendered", "Shapes", "Children"])
|
||||
.and_then(E::as_array)
|
||||
.and_then(|c| c.first())
|
||||
.and_then(|s| s.path(&["Cookie", "Photoshop", "BoxBounds"])),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let [x, y, ..] = b[..] else { return };
|
||||
Point::new(x, y)
|
||||
}
|
||||
};
|
||||
let Some([l, top, r, bottom]) = descriptor_rect(&t.text, "bounds") else { return };
|
||||
let inside = |p: Point| p.x >= l - 1.0 && p.x <= r + 1.0 && p.y >= top - 1.0 && p.y <= bottom + 1.0;
|
||||
if inside(origin) || !inside(to_text.apply(origin)) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
// From the file's own transform: the frame's anchor is exact, so it replaces the
|
||||
// ink-box estimate `legacy_point_origin` made for such point text (#1469), which put
|
||||
// the baseline at the ink's top and the anchor at the ink's left whatever the alignment.
|
||||
layer.transform = t.transform.mul(&to_text);
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenType settings of a `Txt2` style that are on/off flags, by style key, as Photoshop
|
||||
/// writes them (each turned on alone in the Character panel's OpenType settings and read back):
|
||||
/// `/20` Contextual Alternates, `/23` Fractions, `/24` Ordinals, `/25` Swash, `/26` Titling
|
||||
@@ -380,46 +457,97 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
.and_then(E::as_array)
|
||||
.map(|a| a.iter().map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
let default_sheet = res.and_then(|r| r.get("TheNormalStyleSheet")).and_then(E::as_i64).unwrap_or(0) as usize;
|
||||
let base_style =
|
||||
res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).and_then(|a| a.get(default_sheet).or(a.first())).and_then(|s| s.get("StyleSheetData"));
|
||||
let default_para_sheet = res.and_then(|r| r.get("TheNormalParagraphSheet")).and_then(E::as_i64).unwrap_or(0) as usize;
|
||||
let base_para = res
|
||||
.and_then(|r| r.get("ParagraphSheetSet"))
|
||||
.and_then(E::as_array)
|
||||
.and_then(|a| a.get(default_para_sheet).or(a.first()))
|
||||
.and_then(|s| s.get("Properties"));
|
||||
// Style resolution (#3795). A run's own `StyleSheetData` / `Properties` hold only what it
|
||||
// overrides; the rest comes from its sheets, as in Photoshop:
|
||||
// - a paragraph run: its `Properties` → the `ParagraphSheetSet` entry its `Name` names (a
|
||||
// paragraph style; or its `Parent` index) → `TheNormalParagraphSheet`;
|
||||
// - a character run: its `StyleSheetData` → the `StyleSheetSet` entry its `Parent` or `Name`
|
||||
// names (a character style) → the default character style of its paragraph (the paragraph run's
|
||||
// `DefaultStyleSheet`, else its paragraph style's) → `TheNormalStyleSheet`.
|
||||
// Missing or out-of-range indices fall through to the next level, then to the defaults.
|
||||
let style_set: &[E] = res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).unwrap_or(&[]);
|
||||
let para_set: &[E] = res.and_then(|r| r.get("ParagraphSheetSet")).and_then(E::as_array).unwrap_or(&[]);
|
||||
let index = |key: &str| res.and_then(|r| r.get(key)).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok());
|
||||
let normal_style = index("TheNormalStyleSheet").and_then(|i| style_set.get(i)).or(style_set.first());
|
||||
let normal_para = index("TheNormalParagraphSheet").and_then(|i| para_set.get(i)).or(para_set.first());
|
||||
// A run names its sheet by a `Parent` index or by `Name` (see `psd_styles::reference`).
|
||||
let named = |set: &'_ [E], sheet: &E| -> Option<usize> {
|
||||
if let Some(i) = sheet.get("Parent").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < set.len()) {
|
||||
return Some(i);
|
||||
}
|
||||
let name = sheet.get("Name").and_then(E::as_str)?;
|
||||
set.iter().position(|s| s.get("Name").and_then(E::as_str) == Some(name))
|
||||
};
|
||||
|
||||
// Paragraph runs first: a character run's defaults depend on its paragraph.
|
||||
let prun = e.path(&["EngineDict", "ParagraphRun"]);
|
||||
let psheets: Vec<&E> =
|
||||
prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default();
|
||||
let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray")));
|
||||
let plens = utf16_to_byte_lengths(&ed_text, &plens16);
|
||||
let empty = E::Dict(Vec::new());
|
||||
// Per paragraph run: its merged base properties and its default character style.
|
||||
let para_bases: Vec<(E, Option<&E>)> = psheets
|
||||
.iter()
|
||||
.map(|sheet| {
|
||||
let style = named(para_set, sheet).and_then(|i| para_set.get(i));
|
||||
let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]);
|
||||
let char_default = [Some(*sheet), style]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|s| s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| style_set.get(i)));
|
||||
(base, char_default)
|
||||
})
|
||||
.collect();
|
||||
layer.paragraphs = psheets
|
||||
.iter()
|
||||
.zip(¶_bases)
|
||||
.zip(plens)
|
||||
.map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) })
|
||||
.collect();
|
||||
|
||||
// Character runs.
|
||||
let srun = e.path(&["EngineDict", "StyleRun"]);
|
||||
let sdata: Vec<&E> = srun
|
||||
.and_then(|r| r.get("RunArray"))
|
||||
.and_then(E::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.path(&["StyleSheet", "StyleSheetData"])).collect())
|
||||
.unwrap_or_default();
|
||||
let slens = utf16_to_byte_lengths(&ed_text, &arr_f(srun.and_then(|r| r.get("RunLengthArray"))));
|
||||
let ssheets: Vec<&E> =
|
||||
srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default();
|
||||
let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray")));
|
||||
let slens = utf16_to_byte_lengths(&ed_text, &slens16);
|
||||
// The paragraph run each character run starts in (UTF-16 offsets).
|
||||
let para_of = |at: f64| -> Option<usize> {
|
||||
let mut end = 0.0;
|
||||
for (i, n) in plens16.iter().enumerate() {
|
||||
end += n;
|
||||
if at < end {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
plens16.len().checked_sub(1)
|
||||
};
|
||||
let mut at16 = 0.0;
|
||||
let bases: Vec<E> = ssheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, sheet)| {
|
||||
let own = named(style_set, sheet).and_then(|i| style_set.get(i));
|
||||
let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c);
|
||||
at16 += slens16.get(i).copied().unwrap_or(0.0);
|
||||
merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData"))))
|
||||
})
|
||||
.collect();
|
||||
let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect();
|
||||
let incoming: Vec<(usize, bool, f32)> = sdata
|
||||
.iter()
|
||||
.zip(&bases)
|
||||
.zip(&slens)
|
||||
.map(|(d, len)| {
|
||||
let auto = lookup(d, base_style, "AutoKerning").and_then(E::as_bool) != Some(false);
|
||||
let kern = lookup(d, base_style, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
.map(|((d, base), len)| {
|
||||
let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false);
|
||||
let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
(*len, auto, kern)
|
||||
})
|
||||
.collect();
|
||||
layer.runs = sdata.iter().zip(slens).map(|(d, len)| TextRun { len, style: char_style(base_style, d, &fonts, k) }).collect();
|
||||
layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect();
|
||||
layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming);
|
||||
|
||||
// Paragraph runs.
|
||||
let prun = e.path(&["EngineDict", "ParagraphRun"]);
|
||||
let pdata: Vec<&E> = prun
|
||||
.and_then(|r| r.get("RunArray"))
|
||||
.and_then(E::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.path(&["ParagraphSheet", "Properties"])).collect())
|
||||
.unwrap_or_default();
|
||||
let plens = utf16_to_byte_lengths(&ed_text, &arr_f(prun.and_then(|r| r.get("RunLengthArray"))));
|
||||
layer.paragraphs = pdata.iter().zip(plens).map(|(d, len)| ParagraphRun { len, style: para_style(base_para, d, k) }).collect();
|
||||
|
||||
// Shape.
|
||||
let shape = e.path(&["EngineDict", "Rendered", "Shapes", "Children"]).and_then(E::as_array).and_then(|a| a.first());
|
||||
if let Some(sh) = shape
|
||||
@@ -432,6 +560,21 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dictionaries in `levels` as one: a key comes from the first level that has it.
|
||||
fn merged(levels: &[Option<&E>]) -> E {
|
||||
let mut out: Vec<(String, E)> = Vec::new();
|
||||
for level in levels.iter().flatten() {
|
||||
if let E::Dict(kv) = level {
|
||||
for (k, v) in kv {
|
||||
if !out.iter().any(|(o, _)| o == k) {
|
||||
out.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
E::Dict(out)
|
||||
}
|
||||
|
||||
fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> {
|
||||
run.get(key).or_else(|| base.and_then(|b| b.get(key)))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,20 @@ fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option<u
|
||||
(i != normal && i < set_names.len()).then_some(i)
|
||||
}
|
||||
|
||||
/// Adds to `sheet[key]` (a dictionary) every entry of `resolved` it lacks; existing entries win.
|
||||
fn fill_missing(sheet: &mut E, key: &str, resolved: E) {
|
||||
let E::Dict(all) = resolved else { return };
|
||||
if sheet.get(key).is_none() {
|
||||
sheet.set(key, E::dict());
|
||||
}
|
||||
let Some(E::Dict(have)) = sheet.get_mut(key) else { return };
|
||||
for (k, v) in all {
|
||||
if !have.iter().any(|(h, _)| *h == k) {
|
||||
have.push((k, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the style sheets of a `TySh` block. None when it has no engine data.
|
||||
pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
let t = parse_tysh(tysh)?;
|
||||
@@ -170,6 +184,7 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
|
||||
rd.set("TheNormalParagraphSheet", E::Int(0));
|
||||
rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect()));
|
||||
}
|
||||
let char_fonts = e.path(&["ResourceDict", "FontSet"]).or_else(|| e.path(&["DocumentResources", "FontSet"])).map(|f| names(Some(f))).unwrap_or_default();
|
||||
let char_ix = |id: Option<u32>| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
let para_ix = |id: Option<u32>| id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
let runs = layer.char_runs();
|
||||
@@ -193,9 +208,16 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
|
||||
if let Some(sheet) = r.get_mut("StyleSheet") {
|
||||
remove_key(sheet, "Parent");
|
||||
remove_key(sheet, "Name");
|
||||
if let Some(ix) = char_ix(run_at(start).and_then(|r| r.style.style_sheet)) {
|
||||
let run = run_at(start);
|
||||
if let Some(ix) = char_ix(run.and_then(|r| r.style.style_sheet)) {
|
||||
sheet.set("Parent", E::Int(ix));
|
||||
}
|
||||
// The run's reference may be dropped or renumbered: write what it inherited
|
||||
// too, so it keeps its look whatever sheet it now points to (#3795).
|
||||
if let Some(run) = run {
|
||||
let fi = char_fonts.iter().position(|f| *f == postscript_for(&run.style)).unwrap_or(0);
|
||||
fill_missing(sheet, "StyleSheetData", style_sheet_data(&run.style, fi, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +230,9 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
|
||||
if let Some(ix) = para_ix(paras.get(i).and_then(|p| p.style.style_sheet)) {
|
||||
sheet.set("Parent", E::Int(ix));
|
||||
}
|
||||
if let Some(p) = paras.get(i) {
|
||||
fill_missing(sheet, "Properties", paragraph_properties(&p.style, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1580,6 +1580,104 @@ fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() {
|
||||
assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// #1317: Photoshop keeps the last line of a text box whose baseline is inside the box even
|
||||
/// when its descenders hang below the bottom edge (three 35 pt lines of 25 pt type in a
|
||||
/// 91.3 pt box). A line whose baseline falls outside the box is still dropped.
|
||||
#[test]
|
||||
fn box_lines_whose_descent_hangs_below_the_box_are_kept() {
|
||||
let mut e = TextEngine::new();
|
||||
let mut t = styled("one\ntwo\nthree", CharStyle { size_pt: 25.0, leading_pt: Some(35.0), ..Default::default() });
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 91.27 };
|
||||
let l = e.layout(&t, 72.0);
|
||||
assert_eq!(l.lines.len(), 3, "{:?}", l.lines.iter().map(|l| l.baseline).collect::<Vec<_>>());
|
||||
assert!(l.lines[2].baseline <= 91.27 + 0.5);
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 80.0 };
|
||||
assert_eq!(e.layout(&t, 72.0).lines.len(), 2, "the third baseline (about 88) is below an 80 pt box");
|
||||
}
|
||||
|
||||
/// `Txt2` with one text object for `text` whose rendered frame is frame 0, and that frame:
|
||||
/// for point text an anchor `point`, and a frame-to-text matrix `matrix` (recent Photoshop,
|
||||
/// #1317).
|
||||
fn txt2_with_frame(text: &str, point: Option<[f64; 2]>, matrix: [f64; 6]) -> Vec<u8> {
|
||||
let mut v = txt2_with_modes(text, &[(text.encode_utf16().count() + 1, 1)]);
|
||||
// Close the object's model early: `… >> >> ] >>` → `… >> /1 << /2 [ frame ] >> >> ] >>`.
|
||||
let tail = b"] >> >> >> ] >>";
|
||||
assert!(v.ends_with(tail));
|
||||
v.truncate(v.len() - tail.len());
|
||||
v.extend_from_slice(b"] >> >> /1 << /2 [ << /6 [ << /10 0 >> ] >> ] >> >> ] >>");
|
||||
let pt = point.map(|[x, y]| format!("/0 [ {x:.5} {y:.5} ] ")).unwrap_or_default();
|
||||
let m = matrix.map(|x| format!("{x:.5}")).join(" ");
|
||||
let frames = format!("/0 << /8 << /0 [ << /0 << {pt}/2 << /2 [ {m} ] >> >> >> ] >> >>");
|
||||
let at = v.windows(9).position(|w| w == b"/0 << >> ").unwrap();
|
||||
v.splice(at..at + 9, frames.into_bytes().into_iter().chain(*b" "));
|
||||
v
|
||||
}
|
||||
|
||||
/// #1317: point text whose anchor Photoshop keeps only in its `Txt2` frame (EngineData's
|
||||
/// `PointBase` at the origin, the transform far away) is placed at that anchor, not at the
|
||||
/// ink's top left. A rewritten `TySh` (bounds near the origin) is left alone.
|
||||
#[test]
|
||||
fn txt2_frame_places_point_text_at_its_anchor() {
|
||||
let mut t = point("2", 100.0);
|
||||
t.transform = Affine::translate(-1000.0, -2000.0);
|
||||
// Photoshop's text space: anchor at (1100, 2050), ink above its baseline.
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0);
|
||||
let txt2 = crate::psd::parse_txt2(&txt2_with_frame("2", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap();
|
||||
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2);
|
||||
let o = l.transform.apply(photocraft_geom::Point::new(0.0, 0.0));
|
||||
assert!((o.x - 100.0).abs() < 1e-3 && (o.y - 50.0).abs() < 1e-3, "anchor at {o:?}");
|
||||
// Already folded in (bounds near the origin): unchanged; another text: unchanged.
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([0.0, -70.0, 60.0, 0.0])), 0);
|
||||
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
let before = l.transform;
|
||||
crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2);
|
||||
assert_eq!(l.transform, before);
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0);
|
||||
let other = crate::psd::parse_txt2(&txt2_with_frame("3", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap();
|
||||
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
let before = l.transform;
|
||||
crate::psd::apply_txt2_frame(&mut l, &tysh, &other);
|
||||
assert_eq!(l.transform, before);
|
||||
}
|
||||
|
||||
/// `tysh` with its text-space `bounds` set to `r` (Photoshop's, where it drew the text).
|
||||
fn with_bounds(tysh: &[u8], r: [f64; 4]) -> Vec<u8> {
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, Value};
|
||||
let mut t = crate::psd::parse_tysh(tysh).unwrap();
|
||||
let mut d = Descriptor::default();
|
||||
for (k, v) in ["Left", "Top ", "Rght", "Btom"].iter().zip(r) {
|
||||
d.items.push((Id::new(k), Value::Double(v)));
|
||||
}
|
||||
t.text.items.retain(|(k, _)| !k.is("bounds"));
|
||||
t.text.items.push((Id::new("bounds"), Value::Descriptor(d)));
|
||||
crate::psd::write_tysh(&t)
|
||||
}
|
||||
|
||||
/// #1317: a text box whose `BoxBounds` are in its `Txt2` frame's space is moved by the frame's
|
||||
/// matrix onto the `bounds` Photoshop drew it at (it used to land off the canvas, so its text
|
||||
/// "disappeared" when an edit started).
|
||||
#[test]
|
||||
fn txt2_frame_moves_box_text_onto_its_bounds() {
|
||||
let mut t = styled("Casemiro 45", CharStyle { size_pt: 25.0, ..Default::default() });
|
||||
t.shape = TextShape::Box { x: 500.0, y: 600.0, width: 300.0, height: 90.0 };
|
||||
t.transform = Affine::translate(-700.0, -300.0);
|
||||
let built = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
|
||||
let tysh = with_bounds(&built, [960.0, 300.0, 1260.0, 390.0]);
|
||||
let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Casemiro 45", None, [1.0, 0.0, 0.0, 1.0, 460.0, -300.0])).unwrap();
|
||||
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
assert!(matches!(l.shape, TextShape::Box { .. }));
|
||||
crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2);
|
||||
let TextShape::Box { x, y, .. } = l.shape else { panic!("not a box") };
|
||||
let o = l.transform.apply(photocraft_geom::Point::new(f64::from(x), f64::from(y)));
|
||||
assert!((o.x - 260.0).abs() < 1e-3 && (o.y - 0.0).abs() < 1e-3, "box top left at {o:?}");
|
||||
// PhotoCraft's own `TySh` (bounds = the box, frame already folded in): unchanged.
|
||||
let mut l = crate::psd::text_layer_from_tysh(&built, 72.0).unwrap();
|
||||
let before = l.transform;
|
||||
crate::psd::apply_txt2_frame(&mut l, &built, &txt2);
|
||||
assert_eq!(l.transform, before);
|
||||
}
|
||||
|
||||
/// Preferences ▸ Type ▸ Missing Glyph Protection: off, only the default family is in the
|
||||
/// fallback stack, so a face without the glyph shows the missing-glyph box instead of a
|
||||
/// fallback's glyph (#1909).
|
||||
|
||||
@@ -30,7 +30,7 @@ photocraft-format = { workspace = true }
|
||||
photocraft-codecs = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -49,6 +49,9 @@ sys-locale = "=0.3.2"
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with
|
||||
# `Context::default()` (no install_fonts) can lay out text.
|
||||
egui = { workspace = true, features = ["default_fonts"] }
|
||||
# Synthetic raw files for the open-time Camera Raw dialog tests.
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] }
|
||||
@@ -58,7 +61,9 @@ photocraft-testkit = { workspace = true }
|
||||
photocraft-psd = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Deflates the translation catalogs (build.rs); already linked at run time to inflate them.
|
||||
# Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them.
|
||||
flate2 = { workspace = true }
|
||||
# egui's default fonts, deflated by build.rs for egui_fonts.rs (the same version egui uses).
|
||||
epaint_default_fonts = "0.36"
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
||||
+111
-18
@@ -8,10 +8,10 @@
|
||||
//!
|
||||
//! A missing or malformed file gives empty tables (or no names) and a warning.
|
||||
//!
|
||||
//! Also deflates the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/<code>.tsv.deflate`
|
||||
//! for `i18n::LANGUAGES`: stored compressed they take ~0.65 MB of the binary instead of ~1.9 MB,
|
||||
//! which keeps the web build under its 24 MiB size gate (packaging/web/package.sh). A catalog is
|
||||
//! inflated the first time its language is used.
|
||||
//! Also packs the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/` for
|
||||
//! `i18n::LANGUAGES` (see [`pack_catalogs`]): stored that way they take ~0.5 MB of the binary
|
||||
//! instead of ~2.2 MB, which keeps the web build under its 24 MiB size gate
|
||||
//! (packaging/web/package.sh). A catalog is unpacked the first time its language is used.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
@@ -27,30 +27,123 @@ fn main() {
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
deflate_catalogs();
|
||||
pack_catalogs();
|
||||
deflate_egui_fonts();
|
||||
}
|
||||
|
||||
/// Deflate every `src/i18n/*.tsv` into `OUT_DIR/i18n/`. A catalog that can't be written is a
|
||||
/// build error (`include_bytes!` would fail on it anyway, with a less helpful message).
|
||||
fn deflate_catalogs() {
|
||||
/// Deflate egui's default fonts (the `epaint_default_fonts` crate) into `OUT_DIR/egui_fonts/` for
|
||||
/// `egui_fonts.rs`. The app builds egui without `default_fonts`, which would embed the same 1.4 MB
|
||||
/// uncompressed; deflated they take 0.78 MB (the web build's 24 MiB size gate).
|
||||
fn deflate_egui_fonts() {
|
||||
use std::io::Write as _;
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n");
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n");
|
||||
println!("cargo::rerun-if-changed={}", src.display());
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("egui_fonts");
|
||||
let fonts = [
|
||||
("Hack-Regular", epaint_default_fonts::HACK_REGULAR),
|
||||
("NotoEmoji-Regular", epaint_default_fonts::NOTO_EMOJI_REGULAR),
|
||||
("Ubuntu-Light", epaint_default_fonts::UBUNTU_LIGHT),
|
||||
("emoji-icon-font", epaint_default_fonts::EMOJI_ICON),
|
||||
];
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&out)?;
|
||||
for entry in std::fs::read_dir(&src)? {
|
||||
let path = entry?.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()).filter(|n| n.ends_with(".tsv")) else { continue };
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
for (name, bytes) in fonts {
|
||||
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
z.write_all(&std::fs::read(&path)?)?;
|
||||
std::fs::write(out.join(format!("{name}.deflate")), z.finish()?)?;
|
||||
z.write_all(bytes)?;
|
||||
std::fs::write(out.join(format!("{name}.ttf.deflate")), z.finish()?)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
println!("cargo::error=deflating the translation catalogs in {}: {e}", src.display());
|
||||
println!("cargo::error=deflating egui's default fonts into {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a catalog line is an entry (`context<TAB>source<TAB>translation`, source and
|
||||
/// translation non-empty, as `i18n::catalog::parse_entries` reads it), and its key and translation.
|
||||
fn catalog_entry(line: &str) -> Option<(&str, &str)> {
|
||||
if line.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let mut cols = line.splitn(3, '\t');
|
||||
let (ctx, src, tr) = (cols.next()?, cols.next()?, cols.next()?);
|
||||
let key = line.get(..ctx.len() + 1 + src.len())?;
|
||||
(!src.is_empty() && !tr.is_empty() && !tr.contains('\t')).then_some((key, tr))
|
||||
}
|
||||
|
||||
/// Pack every `src/i18n/*.tsv` into `OUT_DIR/i18n/`, losslessly (`i18n::unpack` restores each file
|
||||
/// byte for byte). Every catalog repeats the same English sources, so they are stored once:
|
||||
///
|
||||
/// - `keys.deflate`: every entry's `context<TAB>source`, one per line, in order of first
|
||||
/// appearance (the largest catalog first).
|
||||
/// - `<code>.tsv.deflate`: the catalog, each entry line replaced by `\x01<translation>` when its key
|
||||
/// follows the previous entry's in `keys`, else `\x02<offset><TAB><translation>` (the offset from
|
||||
/// there). Other lines are kept; one that starts with \x01, \x02 or \x03 gets a \x03 prefix.
|
||||
///
|
||||
/// Both are deflated. A catalog that can't be written is a build error (`include_bytes!` would
|
||||
/// fail on it anyway, with a less helpful message).
|
||||
fn pack_catalogs() {
|
||||
use std::io::Write as _;
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n");
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n");
|
||||
println!("cargo::rerun-if-changed={}", src.display());
|
||||
let deflate = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
|
||||
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
z.write_all(bytes)?;
|
||||
z.finish()
|
||||
};
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&out)?;
|
||||
let mut catalogs = Vec::new();
|
||||
for entry in std::fs::read_dir(&src)? {
|
||||
let path = entry?.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()).filter(|n| n.ends_with(".tsv")) else { continue };
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
catalogs.push((name.to_owned(), std::fs::read_to_string(&path)?));
|
||||
}
|
||||
// Largest first, then by name: the key order (and so the output) is reproducible.
|
||||
catalogs.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
|
||||
let mut keys: Vec<&str> = Vec::new();
|
||||
let mut index = std::collections::HashMap::new();
|
||||
for line in catalogs.iter().flat_map(|(_, text)| text.split('\n')) {
|
||||
if let Some((key, _)) = catalog_entry(line) {
|
||||
index.entry(key).or_insert_with(|| {
|
||||
keys.push(key);
|
||||
keys.len() - 1
|
||||
});
|
||||
}
|
||||
}
|
||||
std::fs::write(out.join("keys.deflate"), deflate(keys.join("\n").as_bytes())?)?;
|
||||
for (name, text) in &catalogs {
|
||||
let mut packed = String::with_capacity(text.len());
|
||||
let mut next = 0usize;
|
||||
for (n, line) in text.split('\n').enumerate() {
|
||||
if n > 0 {
|
||||
packed.push('\n');
|
||||
}
|
||||
match catalog_entry(line) {
|
||||
Some((key, tr)) => {
|
||||
let i = index.get(key).copied().unwrap_or_default();
|
||||
if i == next {
|
||||
packed.push('\x01');
|
||||
} else {
|
||||
let _ = write!(packed, "\x02{}\t", i as i64 - next as i64);
|
||||
}
|
||||
packed.push_str(tr);
|
||||
next = i + 1;
|
||||
}
|
||||
None => {
|
||||
if line.starts_with(['\x01', '\x02', '\x03']) {
|
||||
packed.push('\x03');
|
||||
}
|
||||
packed.push_str(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::write(out.join(format!("{name}.deflate")), deflate(packed.as_bytes())?)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
println!("cargo::error=packing the translation catalogs in {}: {e}", src.display());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,19 +134,33 @@ fn dialog_mem(kind: &str) -> egui::Id {
|
||||
egui::Id::new(("adjust-dialog", kind))
|
||||
}
|
||||
|
||||
/// Whether holding Alt turns the dialog's Cancel into Reset. Measured in Photoshop 25.4 for Color
|
||||
/// Balance; other kinds keep Cancel until checked against Photoshop.
|
||||
/// Whether holding Alt turns Cancel into Reset for this adjustment dialog (every kind).
|
||||
pub fn resets(fields: &Map<String, Value>) -> bool {
|
||||
fields.get("__adjust").and_then(Value::as_str) == Some("colorBalance")
|
||||
owns(fields)
|
||||
}
|
||||
|
||||
/// Alt+Cancel (Reset): back to the defaults Photoshop's Reset restores, dialog stays open.
|
||||
pub fn reset(ctx: &egui::Context, fields: &mut Map<String, Value>) {
|
||||
if !resets(fields) {
|
||||
/// Alt+Cancel (Reset): engine defaults for the kind; Color Balance keeps Preserve Luminosity and
|
||||
/// shows Midtones (Photoshop 25.4). Dialog stays open.
|
||||
pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map<String, Value>) {
|
||||
if !owns(fields) {
|
||||
return;
|
||||
}
|
||||
let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode);
|
||||
// Color Balance: Photoshop keeps the user's Preserve Luminosity across Reset.
|
||||
let preserve = if kind == "colorBalance" { fields.get("preserveLuminosity").cloned() } else { None };
|
||||
let Ok(defaults) = photocraft_engine::adjust_params::default_for(&kind, mode) else { return };
|
||||
let values = photocraft_engine::adjust_params::to_params(&defaults);
|
||||
// Replace parameter keys; keep private `__` chrome (preview, command, …).
|
||||
fields.retain(|k, _| k.starts_with("__"));
|
||||
if let Value::Object(v) = values {
|
||||
fields.extend(v);
|
||||
}
|
||||
if let Some(p) = preserve {
|
||||
fields.insert("preserveLuminosity".into(), p);
|
||||
}
|
||||
let mut values = crate::filter_dialog::params_of(fields);
|
||||
adjust_editors::reset_color_balance(ctx, dialog_mem("colorBalance"), &mut values);
|
||||
adjust_editors::reset_adjustment_editor(ctx, &kind, dialog_mem(&kind), &mut values);
|
||||
if let Value::Object(v) = values {
|
||||
fields.extend(v);
|
||||
}
|
||||
@@ -390,16 +404,29 @@ mod tests {
|
||||
assert!(h.state().ui.dialogs.is_empty(), "Cancel closes");
|
||||
}
|
||||
|
||||
/// Alt+Cancel is Reset only where it was checked against Photoshop.
|
||||
/// Every adjustment kind opts into Alt+Reset; Levels restores engine defaults and stays open.
|
||||
#[test]
|
||||
fn other_adjustment_dialogs_keep_cancel_under_alt() {
|
||||
fn levels_alt_reset_restores_defaults() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
open(h.state_mut(), "image.adjustments.levels").unwrap();
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
|
||||
h.run_steps(3);
|
||||
let id = open(h.state_mut(), "image.adjustments.levels").unwrap();
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("inBlack".into(), json!(30));
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("gamma".into(), json!(1.3));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Cancel").is_some() && h.query_by_label("Reset").is_none());
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
|
||||
h.run_steps(2);
|
||||
let reset = h.get_by_label("Reset").rect();
|
||||
assert!(h.query_by_label("Cancel").is_none());
|
||||
let click =
|
||||
|pressed| egui::Event::PointerButton { pos: reset.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::ALT };
|
||||
input(&mut h, egui::Modifiers::ALT, &[egui::Event::PointerMoved(reset.center()), click(true), click(false)]);
|
||||
assert!(h.state().ui.dialogs.iter().any(|d| d.id == id), "Reset keeps the dialog open");
|
||||
let defaults =
|
||||
photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap());
|
||||
assert_eq!(field(&h, id, "inBlack"), defaults["inBlack"]);
|
||||
assert_eq!(field(&h, id, "gamma"), defaults["gamma"]);
|
||||
}
|
||||
|
||||
fn sample_params(kind: &str) -> Value {
|
||||
|
||||
@@ -1019,6 +1019,14 @@ pub fn reset_color_balance(ctx: &egui::Context, mem: egui::Id, v: &mut Value) {
|
||||
ctx.data_mut(|d| d.insert_temp(color_balance_tone_id(mem), 1usize));
|
||||
}
|
||||
|
||||
/// Kind-specific editor memory after Alt+Reset (or Properties Reset to defaults). Color Balance
|
||||
/// shows Midtones; other kinds have no dialog-local temp to clear yet.
|
||||
pub fn reset_adjustment_editor(ctx: &egui::Context, kind: &str, mem: egui::Id, v: &mut Value) {
|
||||
if kind == "colorBalance" {
|
||||
reset_color_balance(ctx, mem, v);
|
||||
}
|
||||
}
|
||||
|
||||
fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
||||
let tone_id = color_balance_tone_id(cx.mem);
|
||||
let mut tone_ix: usize = ui.data(|d| d.get_temp(tone_id)).unwrap_or(1);
|
||||
|
||||
@@ -123,6 +123,20 @@ const ENDS_TYPE_EDIT: [&str; 5] =
|
||||
/// The pasteboard menu's Select Custom Color… row.
|
||||
pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor";
|
||||
|
||||
/// The pasteboard colours in the menu's order, which Space+F steps through.
|
||||
const CANVAS_COLORS: [CanvasColor; 6] =
|
||||
[CanvasColor::Default, CanvasColor::Black, CanvasColor::DarkGray, CanvasColor::MediumGray, CanvasColor::LightGray, CanvasColor::Custom];
|
||||
|
||||
/// Photoshop's Space+F (⇧Space+F backwards): the next pasteboard colour, round and round. Saved
|
||||
/// like a choice in the pasteboard menu (`interface.canvasColor`).
|
||||
pub fn cycle_canvas_color(app: &mut PhotocraftApp, backward: bool) -> Result<(), String> {
|
||||
let n = CANVAS_COLORS.len();
|
||||
let at = CANVAS_COLORS.iter().position(|c| *c == app.session.prefs().interface.canvas_color).unwrap_or(0);
|
||||
let next = if backward { (at + n - 1) % n } else { (at + 1) % n };
|
||||
let name = CANVAS_COLORS.get(next).map_or("default", |c| c.name());
|
||||
app.run("prefs.set", json!({"values": {"interface.canvasColor": name}})).map(|_| ())
|
||||
}
|
||||
|
||||
/// The Zoom and Hand tools' context menu: the zoom steps and fixed zooms of the View menu, plus
|
||||
/// the Hand tool's Fill Screen.
|
||||
pub const VIEW_MENU: &[Row] = &[
|
||||
|
||||
@@ -298,6 +298,15 @@ pub fn owns(f: &Map<String, Value>) -> bool {
|
||||
f.contains_key("__colorPicker")
|
||||
}
|
||||
|
||||
/// Alt+Cancel (Reset): restore the colour from when the picker opened (same as clicking Current).
|
||||
pub fn reset(f: &mut Map<String, Value>) {
|
||||
if !owns(f) {
|
||||
return;
|
||||
}
|
||||
let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]);
|
||||
set_rgb(f, orig, Keep::Nothing);
|
||||
}
|
||||
|
||||
/// The Color Picker, when it is the top dialog (another dialog opened over it takes the input).
|
||||
pub fn top(app: &PhotocraftApp) -> Option<u64> {
|
||||
app.ui.dialogs.last().filter(|d| owns(&d.fields)).map(|d| d.id)
|
||||
@@ -833,6 +842,18 @@ mod tests {
|
||||
assert_eq!(hex([h.state().session.tools.foreground[0], h.state().session.tools.foreground[1], h.state().session.tools.foreground[2]]), "#ff8800");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_reset_restores_orig_color() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
|
||||
let id = open(&mut app, "foreground");
|
||||
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
|
||||
f.insert("color".into(), json!("#ff0000"));
|
||||
reset(f);
|
||||
assert_eq!(f["color"], json!(hex([0.2, 0.4, 0.6])));
|
||||
assert_eq!(f["__orig"], json!(hex([0.2, 0.4, 0.6])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_and_cmyk_fields_take_multi_digit_values() {
|
||||
let base = parse_hex("#336699").unwrap();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Alt+Cancel → Reset for command dialogs that opt in.
|
||||
//!
|
||||
//! Holding Alt in the dialog chrome relabels Cancel as Reset and, on click, restores the dialog's
|
||||
//! fields without closing it (Photoshop). Each marker implements its own `reset`; this module only
|
||||
//! dispatches. Esc stays Cancel (`Modifiers::NONE` in the frame). Opt out with `"__altReset": false`.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Whether holding Alt turns Cancel into Reset for this dialog's fields.
|
||||
pub fn supported(fields: &Map<String, Value>) -> bool {
|
||||
if fields.get("__altReset") == Some(&Value::Bool(false)) {
|
||||
return false;
|
||||
}
|
||||
match_arm(fields).is_some()
|
||||
}
|
||||
|
||||
/// Apply Reset in place. No-op when the dialog has not opted in.
|
||||
pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, fields: &mut Map<String, Value>) {
|
||||
if fields.get("__altReset") == Some(&Value::Bool(false)) {
|
||||
return;
|
||||
}
|
||||
// Dispatch order matches `dialogs::show` so `__adjust` wins over `__filter`.
|
||||
if crate::guide_layout_ui::owns(fields) {
|
||||
crate::guide_layout_ui::reset(app, fields);
|
||||
} else if crate::fill_ui::owns(fields) {
|
||||
crate::fill_ui::reset(app, fields);
|
||||
} else if crate::variables_ui::owns(fields) {
|
||||
crate::variables_ui::reset(fields);
|
||||
} else if crate::color_picker_ui::owns(fields) {
|
||||
crate::color_picker_ui::reset(fields);
|
||||
} else if fields.contains_key("__sizing") {
|
||||
crate::sizing::reset(fields);
|
||||
} else if crate::adjust_dialog::owns(fields) {
|
||||
crate::adjust_dialog::reset(app, ctx, fields);
|
||||
} else if fields.contains_key("__filter") {
|
||||
crate::filter_dialog::reset_fields(app, fields);
|
||||
} else if fields.contains_key("__form") {
|
||||
crate::view_cmds::reset_form(fields);
|
||||
}
|
||||
}
|
||||
|
||||
/// First matching opt-in arm, if any (same order as [`apply`]).
|
||||
fn match_arm(fields: &Map<String, Value>) -> Option<&'static str> {
|
||||
if crate::guide_layout_ui::owns(fields) {
|
||||
Some("guideLayout")
|
||||
} else if crate::fill_ui::owns(fields) {
|
||||
Some("fill")
|
||||
} else if crate::variables_ui::owns(fields) && fields.contains_key("__variablesOrig") {
|
||||
Some("variables")
|
||||
} else if crate::color_picker_ui::owns(fields) {
|
||||
Some("colorPicker")
|
||||
} else if fields.contains_key("__sizing") {
|
||||
Some("sizing")
|
||||
} else if crate::adjust_dialog::owns(fields) {
|
||||
Some("adjust")
|
||||
} else if fields.contains_key("__filter") {
|
||||
Some("filter")
|
||||
} else if fields.contains_key("__form") && fields.contains_key("__formOrig") {
|
||||
Some("form")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn alt_reset_false_opts_out() {
|
||||
let mut f = Map::new();
|
||||
f.insert("__adjust".into(), json!("levels"));
|
||||
f.insert("__altReset".into(), json!(false));
|
||||
assert!(!supported(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_marker_opts_in() {
|
||||
let mut f = Map::new();
|
||||
f.insert("__adjust".into(), json!("levels"));
|
||||
assert!(supported(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_confirm_does_not_opt_in() {
|
||||
let mut f = Map::new();
|
||||
f.insert("__command".into(), json!("edit.purge"));
|
||||
assert!(!supported(&f));
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let can_ok = crate::crop_presets::can_confirm(app, &fields);
|
||||
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok);
|
||||
// Photoshop: holding Alt turns Cancel into Reset (the dialog stays open).
|
||||
let reset = ui.input(|i| i.modifiers.alt) && crate::adjust_dialog::resets(&fields);
|
||||
// Dialogs opt in via `dialog_alt_reset::supported` (per marker).
|
||||
let reset = ui.input(|i| i.modifiers.alt) && crate::dialog_alt_reset::supported(&fields);
|
||||
let cancel_label = if reset {
|
||||
tl!("Reset")
|
||||
} else if d.kind == DialogKind::NewDocument {
|
||||
@@ -337,7 +338,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
dialog_buttons(ui, &[ok, cancel])
|
||||
};
|
||||
match clicked {
|
||||
Some(ButtonRole::Cancel) if reset => crate::adjust_dialog::reset(ui.ctx(), &mut fields),
|
||||
Some(ButtonRole::Cancel) if reset => crate::dialog_alt_reset::apply(app, ui.ctx(), &mut fields),
|
||||
Some(ButtonRole::Cancel) => outcome = Some(false),
|
||||
Some(ButtonRole::Apply) => apply_requested = true,
|
||||
Some(_) => outcome = Some(true),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! egui's default fonts (Ubuntu Light, Hack, Noto Emoji and emoji-icon-font: the fallback stack
|
||||
//! behind Inter and JetBrains Mono, for symbols and emoji), stored deflated.
|
||||
//!
|
||||
//! egui's `default_fonts` feature embeds them uncompressed (1.4 MB), which the web build's 24 MiB
|
||||
//! size gate (packaging/web/package.sh) can't afford. The app builds egui without it; build.rs
|
||||
//! deflates the same files from the `epaint_default_fonts` crate (0.78 MB) and they are inflated
|
||||
//! once, the first time the fonts are installed. Test builds keep the feature (dev-dependencies),
|
||||
//! so egui's own definitions are the oracle for [`definitions`].
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use egui::{FontData, FontDefinitions, FontFamily, FontTweak};
|
||||
|
||||
/// A font deflated by build.rs.
|
||||
macro_rules! deflated {
|
||||
($name:literal) => {
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/egui_fonts/", $name, ".ttf.deflate"))
|
||||
};
|
||||
}
|
||||
|
||||
static HACK: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Hack-Regular")));
|
||||
static NOTO_EMOJI: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("NotoEmoji-Regular")));
|
||||
static UBUNTU_LIGHT: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Ubuntu-Light")));
|
||||
static EMOJI_ICON: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("emoji-icon-font")));
|
||||
|
||||
/// Inflated fonts are bounded (the largest is 0.42 MB), so corrupt data can't exhaust memory.
|
||||
const MAX_FONT_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// Inflate a font. Data that doesn't inflate gives empty bytes (the font is left out), never a panic.
|
||||
fn inflate(deflated: &[u8]) -> Vec<u8> {
|
||||
use std::io::Read as _;
|
||||
let mut out = Vec::new();
|
||||
match flate2::read::DeflateDecoder::new(deflated).take(MAX_FONT_BYTES + 1).read_to_end(&mut out) {
|
||||
Ok(_) if out.len() as u64 <= MAX_FONT_BYTES => out,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `FontDefinitions::default()` with egui's default fonts, whether or not egui was built with
|
||||
/// `default_fonts`: the same faces, tweaks and family order.
|
||||
pub fn definitions() -> FontDefinitions {
|
||||
let fonts = FontDefinitions::default();
|
||||
if fonts.font_data.is_empty() { from_deflated() } else { fonts }
|
||||
}
|
||||
|
||||
/// egui's default definitions, built from the deflated copies.
|
||||
fn from_deflated() -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::empty();
|
||||
let faces: [(&str, &'static [u8], f32); 4] = [
|
||||
("Hack", HACK.as_slice(), 1.0),
|
||||
// egui draws its emoji fonts a little smaller.
|
||||
("NotoEmoji-Regular", NOTO_EMOJI.as_slice(), 0.81),
|
||||
("Ubuntu-Light", UBUNTU_LIGHT.as_slice(), 1.0),
|
||||
("emoji-icon-font", EMOJI_ICON.as_slice(), 0.90),
|
||||
];
|
||||
for (name, bytes, scale) in faces {
|
||||
if bytes.is_empty() {
|
||||
log::error!("egui default font {name} is unavailable");
|
||||
continue;
|
||||
}
|
||||
let data = FontData::from_static(bytes).tweak(FontTweak { scale, ..Default::default() });
|
||||
fonts.font_data.insert(name.to_owned(), Arc::new(data));
|
||||
}
|
||||
let stack = |names: &[&str]| -> Vec<String> { names.iter().filter(|n| fonts.font_data.contains_key(**n)).map(|n| (*n).to_owned()).collect() };
|
||||
let mono = stack(&["Hack", "Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]);
|
||||
let proportional = stack(&["Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]);
|
||||
fonts.families.insert(FontFamily::Monospace, mono);
|
||||
fonts.families.insert(FontFamily::Proportional, proportional);
|
||||
fonts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The definitions built from the deflated copies are egui's own: the same bytes, tweaks and
|
||||
/// family order (test builds enable `default_fonts`, so `FontDefinitions::default()` is egui's).
|
||||
#[test]
|
||||
fn deflated_fonts_match_egui_defaults() {
|
||||
let egui = FontDefinitions::default();
|
||||
assert!(!egui.font_data.is_empty(), "test builds enable egui's default_fonts (dev-dependencies)");
|
||||
assert_eq!(from_deflated(), egui);
|
||||
assert_eq!(definitions(), egui);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_font_data_is_left_out_not_a_panic() {
|
||||
assert!(inflate(b"not deflate data").is_empty());
|
||||
assert!(inflate(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -719,7 +719,9 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() {
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
assert_eq!(written.borrow().len(), 2);
|
||||
assert_eq!(written.borrow()[0], "/out/boards.jpg");
|
||||
assert!(written.borrow()[1].starts_with("/out/boards-2-") && written.borrow()[1].ends_with(".jpg"), "{:?}", written.borrow());
|
||||
// The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows).
|
||||
let second = written.borrow()[1].replace('\\', "/");
|
||||
assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow());
|
||||
let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::<Vec<_>>();
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %");
|
||||
|
||||
@@ -53,8 +53,8 @@ fn valid(app: &PhotocraftApp, key: &str, v: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
|
||||
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
|
||||
/// Photoshop's Fill defaults (no last-used prefs). Used by open and Alt+Reset.
|
||||
pub fn default_fields(app: &PhotocraftApp) -> Map<String, Value> {
|
||||
let mut f = Map::new();
|
||||
f.insert(MARK.into(), json!(true));
|
||||
f.insert("__command".into(), json!(COMMAND));
|
||||
@@ -68,6 +68,12 @@ pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
|
||||
f.insert("mode".into(), json!("normal"));
|
||||
f.insert("opacity".into(), json!(100.0));
|
||||
f.insert("preserveTransparency".into(), json!(false));
|
||||
f
|
||||
}
|
||||
|
||||
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
|
||||
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
|
||||
let mut f = default_fields(app);
|
||||
if let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) {
|
||||
for k in KEYS {
|
||||
if let Some(v) = saved.get(k).filter(|v| valid(app, k, v)) {
|
||||
@@ -78,6 +84,19 @@ pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
|
||||
f
|
||||
}
|
||||
|
||||
/// Alt+Cancel (Reset): Photoshop Fill defaults, not last-used prefs. Dialog stays open.
|
||||
pub fn reset(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
|
||||
if !owns(fields) {
|
||||
return;
|
||||
}
|
||||
let defaults = default_fields(app);
|
||||
for k in KEYS {
|
||||
if let Some(v) = defaults.get(k) {
|
||||
fields.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Fill dialog; returns its id.
|
||||
pub fn open(app: &mut PhotocraftApp) -> u64 {
|
||||
let f = fields(app);
|
||||
|
||||
@@ -74,6 +74,22 @@ fn mode_opacity_and_preserve_transparency() {
|
||||
assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_reset_restores_photoshop_defaults_not_last_used() {
|
||||
let mut app = app();
|
||||
// Remember a non-default choice, then open (which restores it) and Reset.
|
||||
fill(&mut app, json!({"contents": "white", "opacity": 40})).unwrap();
|
||||
let id = open(&mut app);
|
||||
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["opacity"].as_f64(), Some(40.0));
|
||||
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
|
||||
reset(&app, &mut fields);
|
||||
app.ui.dialog_mut(id).unwrap().fields = fields;
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!(f["opacity"].as_f64(), Some(100.0));
|
||||
assert_eq!(f["contents"], json!("foreground"));
|
||||
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choices_persist_across_restarts() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -327,6 +327,70 @@ pub(crate) fn remember(app: &mut PhotocraftApp, command: &str, fields: &Map<Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema-default values for one param of `command` (open and Alt+Reset share this).
|
||||
fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option<Value> {
|
||||
Some(match &p.kind {
|
||||
Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32),
|
||||
Kind::Range { default, .. } => json!(default),
|
||||
Kind::Choice { options, default } => {
|
||||
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
|
||||
json!(initial)
|
||||
}
|
||||
Kind::Bool(default) => json!(default),
|
||||
Kind::Int { default } => json!(default),
|
||||
Kind::Text => json!(""),
|
||||
Kind::Document => json!(-1),
|
||||
// Identity kernel: 1 in the centre.
|
||||
Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::<Vec<_>>()),
|
||||
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
|
||||
Kind::Color(None) => return None,
|
||||
// Colour inputs come from the current swatches, so the proxy preview matches the result.
|
||||
Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground),
|
||||
Kind::Json if p.key == "background" => json!(app.session.tools.background),
|
||||
Kind::Json => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs.
|
||||
fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map<String, Value>) {
|
||||
if command == "image.rotation.arbitrary" {
|
||||
straighten_defaults(app, fields);
|
||||
}
|
||||
if command == "type.warpText" {
|
||||
warp_defaults(app, fields);
|
||||
}
|
||||
// Photoshop's Pattern Name dialog starts from the name the pattern would get anyway.
|
||||
if command == "edit.definePattern" {
|
||||
fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session)));
|
||||
}
|
||||
if command == "layer.layerStyle.globalLight" {
|
||||
global_light_defaults(app, fields);
|
||||
}
|
||||
if command == "view.proofSetup" {
|
||||
proof_setup_defaults(app, fields);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt+Cancel (Reset): schema defaults + command hooks, without last-used remembered prefs.
|
||||
pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
|
||||
if !fields.contains_key("__filter") || fields.contains_key("__adjust") {
|
||||
return;
|
||||
}
|
||||
let Some(command) = fields.get("__command").and_then(Value::as_str).map(str::to_owned) else { return };
|
||||
let spec =
|
||||
fields.get("__spec").and_then(Value::as_str).map(str::to_owned).or_else(|| photocraft_engine::commands::find(&command).map(|c| c.params.to_owned()));
|
||||
let Some(spec) = spec else { return };
|
||||
// Drop user params; keep private `__` chrome (and fixed keys outside the schema for `__spec`).
|
||||
let schema_keys: std::collections::HashSet<String> = parse_spec(&spec).into_iter().map(|p| p.key).collect();
|
||||
fields.retain(|k, _| k.starts_with("__") || !schema_keys.contains(k));
|
||||
for p in parse_spec(&spec) {
|
||||
if let Some(v) = default_value(app, &command, &p) {
|
||||
fields.insert(p.key, v);
|
||||
}
|
||||
}
|
||||
apply_command_defaults(app, &command, fields);
|
||||
}
|
||||
|
||||
pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
|
||||
let spec = photocraft_engine::commands::find(command)?;
|
||||
let mut fields = Map::new();
|
||||
@@ -338,45 +402,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
|
||||
fields.insert("__preview".into(), json!(true));
|
||||
}
|
||||
for p in parse_spec(spec.params) {
|
||||
let v = match &p.kind {
|
||||
Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32),
|
||||
Kind::Range { default, .. } => json!(default),
|
||||
Kind::Choice { options, default } => {
|
||||
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
|
||||
json!(initial)
|
||||
}
|
||||
Kind::Bool(default) => json!(default),
|
||||
Kind::Int { default } => json!(default),
|
||||
Kind::Text => json!(""),
|
||||
Kind::Document => json!(-1),
|
||||
// Identity kernel: 1 in the centre.
|
||||
Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::<Vec<_>>()),
|
||||
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
|
||||
Kind::Color(None) => continue,
|
||||
// Colour inputs come from the current swatches, so the proxy preview matches the result.
|
||||
Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground),
|
||||
Kind::Json if p.key == "background" => json!(app.session.tools.background),
|
||||
Kind::Json => continue,
|
||||
};
|
||||
fields.insert(p.key, v);
|
||||
if let Some(v) = default_value(app, command, &p) {
|
||||
fields.insert(p.key, v);
|
||||
}
|
||||
}
|
||||
restore_remembered(app, command, spec.params, &mut fields);
|
||||
if command == "image.rotation.arbitrary" {
|
||||
straighten_defaults(app, &mut fields);
|
||||
}
|
||||
if command == "type.warpText" {
|
||||
warp_defaults(app, &mut fields);
|
||||
}
|
||||
// Photoshop's Pattern Name dialog starts from the name the pattern would get anyway.
|
||||
if command == "edit.definePattern" {
|
||||
fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session)));
|
||||
}
|
||||
if command == "layer.layerStyle.globalLight" {
|
||||
global_light_defaults(app, &mut fields);
|
||||
}
|
||||
if command == "view.proofSetup" {
|
||||
proof_setup_defaults(app, &mut fields);
|
||||
}
|
||||
apply_command_defaults(app, command, &mut fields);
|
||||
if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) {
|
||||
// The document picker lists every open document (params refer to them by index).
|
||||
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
|
||||
@@ -818,6 +849,24 @@ mod tests {
|
||||
assert_eq!(fields["radius"], json!(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_reset_restores_schema_defaults_not_remembered() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.prefs.edit(|prefs| {
|
||||
prefs.dialogs.insert("filter.blur.gaussianBlur".into(), json!({"radius": 11.0}));
|
||||
});
|
||||
let id = open(&mut app, "filter.blur.gaussianBlur").unwrap();
|
||||
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["radius"], json!(11.0));
|
||||
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
|
||||
reset_fields(&app, &mut fields);
|
||||
app.ui.dialog_mut(id).unwrap().fields = fields;
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
// Registry default for gaussian blur radius is 1.
|
||||
assert_eq!(f["radius"], json!(1.0));
|
||||
assert!(f.contains_key("__filter"));
|
||||
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembering_dialogs_never_persists_paths_or_document_indices() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -30,7 +30,7 @@ const CENTER_Y_FIELD: &str = "__pvCy";
|
||||
/// Draw the filter dialog's in-dialog preview window below its parameters (its `fields` `f`). A
|
||||
/// no-op for dialogs without a preview and for Select and Mask, which has its own view.
|
||||
pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
if !f.contains_key("__preview") || crate::select_mask_view::owns(f) {
|
||||
if !shows(f) {
|
||||
return;
|
||||
}
|
||||
let Some(cmd) = f.get("__command").and_then(Value::as_str).map(str::to_owned) else { return };
|
||||
@@ -145,7 +145,14 @@ const ZOOM_STEPS: [f32; 7] = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0];
|
||||
/// The vertical space the preview window takes below the parameters: the image, the zoom row and
|
||||
/// their spacing. `0` when the dialog has no preview (so the caller reserves nothing).
|
||||
pub(crate) fn reserved_height(f: &Map<String, Value>) -> f32 {
|
||||
if f.contains_key("__preview") && !crate::select_mask_view::owns(f) { PREVIEW_H + 32.0 } else { 0.0 }
|
||||
if shows(f) { PREVIEW_H + 32.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Whether the dialog with fields `f` gets the preview window: Filter menu dialogs with a Preview
|
||||
/// checkbox, as in Photoshop. Image › Rotation › Arbitrary and the adjustment dialogs keep only
|
||||
/// the on-canvas preview, and Select and Mask has its own view.
|
||||
fn shows(f: &Map<String, Value>) -> bool {
|
||||
f.contains_key("__preview") && !crate::select_mask_view::owns(f) && f.get("__command").and_then(Value::as_str).is_some_and(|c| c.starts_with("filter."))
|
||||
}
|
||||
|
||||
/// The next zoom level up or down from `zoom`, clamped to [`ZOOM_STEPS`]' ends.
|
||||
@@ -194,12 +201,29 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 {
|
||||
|
||||
/// A composite as monitor pixels, matching the canvas (`canvas::display_image`).
|
||||
fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle {
|
||||
// Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution
|
||||
// previews off the proxy is the full document. The view maps through UV fractions, so a reduced
|
||||
// texture still lines up.
|
||||
let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side));
|
||||
let reduced;
|
||||
let doc = if k > 1 {
|
||||
reduced = crate::proxy::proxy_document(doc, k);
|
||||
&reduced
|
||||
} else {
|
||||
doc
|
||||
};
|
||||
let buf = photocraft_compose::flatten(doc);
|
||||
let (display, _) = crate::canvas::canvas_display(app, doc, None);
|
||||
let img = crate::canvas::display_image(display.as_deref(), &buf);
|
||||
ctx.load_texture(name, img, egui::TextureOptions::LINEAR)
|
||||
}
|
||||
|
||||
/// The reduction that fits a `w`×`h` image inside a `max_side` texture (1: it already fits).
|
||||
fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 {
|
||||
let max_side = u32::try_from(max_side).unwrap_or(u32::MAX).max(1);
|
||||
w.max(h).div_ceil(max_side).max(1)
|
||||
}
|
||||
|
||||
/// What a cached texture was built from, so it is rebuilt only when its source changes.
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum TexKey {
|
||||
@@ -238,6 +262,30 @@ mod tests {
|
||||
assert_eq!(step_zoom(f32::NAN, false), MIN_ZOOM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_filter_dialogs_get_the_preview_window() {
|
||||
let fields = |cmd: &str| json!({"__preview": true, "__command": cmd}).as_object().cloned().unwrap();
|
||||
assert!(shows(&fields("filter.blur.gaussianBlur")));
|
||||
assert!(reserved_height(&fields("filter.blur.gaussianBlur")) > 0.0);
|
||||
// Photoshop's Rotate Canvas and adjustment dialogs have no preview window.
|
||||
assert!(!shows(&fields("image.rotation.arbitrary")));
|
||||
assert_eq!(reserved_height(&fields("image.rotation.arbitrary")), 0.0);
|
||||
assert!(!shows(&fields("adjust.levels")));
|
||||
// No Preview checkbox or no command: no window.
|
||||
assert!(!shows(json!({"__command": "filter.blur.gaussianBlur"}).as_object().unwrap()));
|
||||
assert!(!shows(json!({"__preview": true}).as_object().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textures_are_reduced_to_fit_the_gpu() {
|
||||
assert_eq!(texture_factor(3000, 2000, 2048), 2);
|
||||
assert_eq!(texture_factor(2048, 100, 2048), 1);
|
||||
assert_eq!(texture_factor(9000, 9000, 2048), 5);
|
||||
// A zero or absurd limit never divides by zero.
|
||||
assert_eq!(texture_factor(3000, 2000, 0), 3000);
|
||||
assert_eq!(texture_factor(0, 0, 2048), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_is_clamped_to_the_document() {
|
||||
assert_eq!(clamp_center(-5.0, 99.0, 100.0, 50.0), (0.0, 50.0));
|
||||
|
||||
@@ -31,6 +31,33 @@ pub fn owns(f: &Map<String, Value>) -> bool {
|
||||
f.contains_key(MARK)
|
||||
}
|
||||
|
||||
/// Alt+Cancel (Reset): factory New Guide Layout values, not the last-used remembered layout.
|
||||
pub fn reset(app: &PhotocraftApp, f: &mut Map<String, Value>) {
|
||||
if !owns(f) {
|
||||
return;
|
||||
}
|
||||
let Value::Object(defaults) = crate::view_cmds::default_guide_layout() else { return };
|
||||
// Drop layout params and interactive toggles; keep dialog chrome (`__command`, …).
|
||||
f.retain(|k, _| {
|
||||
k.starts_with("__")
|
||||
&& !matches!(
|
||||
k.as_str(),
|
||||
"__preset" | "__columnsEnabled" | "__rowsEnabled" | "__marginEnabled" | "__previewGuides" | "__previewParams" | "__previewSource"
|
||||
)
|
||||
});
|
||||
f.extend(defaults);
|
||||
f.insert("color".into(), json!(app.session.prefs().guides_grid_and_slices.guide_color));
|
||||
f.insert("target".into(), json!("document"));
|
||||
f.insert("__preset".into(), json!("custom"));
|
||||
f.insert("width".into(), Value::Null);
|
||||
f.insert("height".into(), Value::Null);
|
||||
let margin = f.get("margin").cloned().unwrap_or(json!(0));
|
||||
if !margin.is_array() {
|
||||
f.insert("margin".into(), json!([margin.clone(), margin.clone(), margin.clone(), margin]));
|
||||
}
|
||||
f.insert("__preview".into(), json!(true));
|
||||
}
|
||||
|
||||
fn on(f: &Map<String, Value>, key: &str, fallback: bool) -> bool {
|
||||
f.get(key).and_then(Value::as_bool).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,23 @@ fn app() -> PhotocraftApp {
|
||||
PhotocraftApp::new(session, crate::Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_reset_restores_factory_layout() {
|
||||
let mut app = app();
|
||||
let id = open(&mut app);
|
||||
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
|
||||
fields.insert("columns".into(), json!(3));
|
||||
fields.insert("gutter".into(), json!(99));
|
||||
fields.insert("__preset".into(), json!("twoColumn"));
|
||||
reset(&app, &mut fields);
|
||||
app.ui.dialog_mut(id).unwrap().fields = fields;
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!(f["columns"], json!(8));
|
||||
assert_eq!(f["gutter"], json!(20));
|
||||
assert_eq!(f["__preset"], json!("custom"));
|
||||
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
|
||||
}
|
||||
|
||||
fn draw(app: &mut PhotocraftApp, id: u64) {
|
||||
let mut f = app.ui.dialog_mut(id).unwrap().fields.clone();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch and Czech UI translation catalogs are
|
||||
licensed under MIT OR Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT
|
||||
and ../../../../LICENSE-APACHE.
|
||||
PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish and
|
||||
Hungarian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full
|
||||
license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
|
||||
|
||||
Korean translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
@@ -22,3 +22,9 @@ PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
Czech translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
Turkish translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
Hungarian translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
@@ -398,6 +398,7 @@
|
||||
Select Linked Layers Vybrat propojené vrstvy
|
||||
Merge Layers Sloučit vrstvy
|
||||
Merge Down Sloučit dolů
|
||||
Merge Group Sloučit skupinu
|
||||
Merge Visible Sloučit viditelné
|
||||
Stamp Visible Otisknout viditelné
|
||||
Select Layer Above Vybrat vrstvu nad
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -394,6 +394,7 @@
|
||||
Select Linked Layers Verknüpfte Ebenen auswählen
|
||||
Merge Layers Ebenen zusammenfügen
|
||||
Merge Down Nach unten zusammenfügen
|
||||
Merge Group Gruppe zusammenfügen
|
||||
Merge Visible Sichtbare zusammenfügen
|
||||
Stamp Visible Sichtbare Ebenen stempeln
|
||||
Select Layer Above Ebene darüber auswählen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1838,6 +1838,7 @@
|
||||
New Guide Layout… Νέα διάταξη οδηγών…
|
||||
New Guides From Shape Νέοι οδηγοί από σχήμα
|
||||
Merge Down Συγχώνευση με το υποκείμενο επίπεδο
|
||||
Merge Group Συγχώνευση ομάδας
|
||||
Stamp Visible Συνένωση ορατών σε νέο επίπεδο
|
||||
Select Layer Above Επιλογή επάνω επιπέδου
|
||||
Select Layer Below Επιλογή κάτω επιπέδου
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1000 and column 18.
|
@@ -391,6 +391,7 @@
|
||||
Select Linked Layers Seleccionar capas vinculadas
|
||||
Merge Layers Combinar capas
|
||||
Merge Down Combinar hacia abajo
|
||||
Merge Group Combinar grupo
|
||||
Merge Visible Combinar visibles
|
||||
Stamp Visible Sellar visibles
|
||||
Select Layer Above Seleccionar la capa de arriba
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -387,6 +387,7 @@
|
||||
Select Linked Layers Sélectionner les calques liés
|
||||
Merge Layers Fusionner les calques
|
||||
Merge Down Fusionner avec le calque inférieur
|
||||
Merge Group Fusionner le groupe
|
||||
Merge Visible Fusionner les calques visibles
|
||||
Stamp Visible Tamponner les calques visibles
|
||||
Stamp Down Tamponner sur le calque inférieur
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
File diff suppressed because it is too large
Load Diff
@@ -380,6 +380,7 @@
|
||||
Select Linked Layers Pilih Layer Tertaut
|
||||
Merge Layers Gabungkan Layer
|
||||
Merge Down Gabung ke Bawah
|
||||
Merge Group Gabungkan Grup
|
||||
Merge Visible Gabung yang Terlihat
|
||||
Stamp Visible Stamp yang Terlihat
|
||||
Stamp Down Stamp ke Bawah
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -390,6 +390,7 @@
|
||||
Select Linked Layers Seleziona livelli collegati
|
||||
Merge Layers Unisci livelli
|
||||
Merge Down Unisci sotto
|
||||
Merge Group Unisci gruppo
|
||||
Merge Visible Unisci visibili
|
||||
Stamp Visible Unisci visibili in nuovo
|
||||
Select Layer Above Seleziona livello sopra
|
||||
|
||||
|
@@ -391,6 +391,7 @@
|
||||
Select Linked Layers リンクしたレイヤーを選択
|
||||
Merge Layers レイヤーを結合
|
||||
Merge Down 下のレイヤーと結合
|
||||
Merge Group グループを結合
|
||||
Merge Visible 表示レイヤーを結合
|
||||
Stamp Visible 表示レイヤーをスタンプ
|
||||
Select Layer Above 上のレイヤーを選択
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -375,6 +375,7 @@
|
||||
Select Linked Layers 연결된 레이어 선택
|
||||
Merge Layers 레이어 병합
|
||||
Merge Down 아래 레이어와 병합
|
||||
Merge Group 그룹 병합
|
||||
Merge Visible 보이는 레이어 병합
|
||||
Stamp Visible 보이는 레이어의 병합 사본 만들기
|
||||
Select Layer Above 위 레이어 선택
|
||||
|
||||
|
@@ -39,7 +39,7 @@ pub struct LangInfo {
|
||||
/// Catalog file contents, raw-deflated by build.rs (empty for the built-in English); see
|
||||
/// [`LangInfo::text`].
|
||||
pub source: &'static [u8],
|
||||
/// Plural form index for a count (English: 0 = one, 1 = other; Japanese and Chinese: always 0;
|
||||
/// Plural form index for a count (English: 0 = one, 1 = other; Japanese, Chinese and Hungarian: always 0;
|
||||
/// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per
|
||||
/// index.
|
||||
pub plural: fn(u64) -> usize,
|
||||
@@ -115,8 +115,8 @@ fn plural_polish(n: u64) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// A catalog file as deflated by build.rs (the binary would otherwise carry ~1.9 MB of
|
||||
/// translations, which the web build's size gate can't afford).
|
||||
/// A catalog file as packed by build.rs (`pack_catalogs`; the binary would otherwise carry ~2.2 MB
|
||||
/// of translations, which the web build's size gate can't afford). See [`unpack`].
|
||||
macro_rules! catalog {
|
||||
($code:literal) => {
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/i18n/", $code, ".tsv.deflate"))
|
||||
@@ -140,8 +140,54 @@ fn inflate(source: &[u8]) -> Result<String, String> {
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Every catalog entry's `context<TAB>source`, stored once for all catalogs (build.rs).
|
||||
static KEYS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/i18n/keys.deflate"));
|
||||
|
||||
/// Restore a catalog file from its packed form (build.rs `pack_catalogs`): entry lines refer to
|
||||
/// [`KEYS`] instead of repeating the English source. Bad data gives an error (and the language
|
||||
/// falls back to English), never a panic.
|
||||
fn unpack(source: &[u8]) -> Result<String, String> {
|
||||
static KEY_TEXT: OnceLock<Result<String, String>> = OnceLock::new();
|
||||
if source.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let keys: Vec<&str> = KEY_TEXT.get_or_init(|| inflate(KEYS)).as_ref().map_err(|e| format!("catalog keys: {e}"))?.split('\n').collect();
|
||||
expand(&inflate(source)?, &keys)
|
||||
}
|
||||
|
||||
/// [`unpack`]'s second step: replace each packed entry line with its key and translation.
|
||||
fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
|
||||
let mut text = String::with_capacity(packed.len().saturating_mul(2));
|
||||
let mut next = 0usize;
|
||||
for (n, line) in packed.split('\n').enumerate() {
|
||||
if n > 0 {
|
||||
text.push('\n');
|
||||
}
|
||||
let bad = || format!("packed line {}: bad key reference", n + 1);
|
||||
let (index, tr) = if let Some(tr) = line.strip_prefix('\x01') {
|
||||
(next, tr)
|
||||
} else if let Some(rest) = line.strip_prefix('\x02') {
|
||||
let (offset, tr) = rest.split_once('\t').ok_or_else(bad)?;
|
||||
let offset: i64 = offset.parse().map_err(|_| bad())?;
|
||||
let index = i64::try_from(next).ok().and_then(|n| n.checked_add(offset)).and_then(|i| usize::try_from(i).ok()).ok_or_else(bad)?;
|
||||
(index, tr)
|
||||
} else {
|
||||
text.push_str(line.strip_prefix('\x03').unwrap_or(line));
|
||||
continue;
|
||||
};
|
||||
text.push_str(keys.get(index).ok_or_else(bad)?);
|
||||
text.push('\t');
|
||||
text.push_str(tr);
|
||||
next = index.saturating_add(1);
|
||||
if text.len() as u64 > MAX_CATALOG_BYTES {
|
||||
return Err(format!("over {MAX_CATALOG_BYTES} bytes"));
|
||||
}
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 18] = [
|
||||
pub static LANGUAGES: [LangInfo; 20] = [
|
||||
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -164,6 +210,10 @@ pub static LANGUAGES: [LangInfo; 18] = [
|
||||
// Dutch; `nl-NL` and `nl-BE` locales both resolve here.
|
||||
LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "it", name: "Italiano", source: catalog!("it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Hungarian: a count takes the singular noun ("3 réteg"), so one plural form.
|
||||
LangInfo { code: "hu", name: "Magyar", source: catalog!("hu"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Turkish; `tr`, `tr-TR` and `tr_TR` locales resolve here.
|
||||
LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Vietnamese; `vi-VN` resolves here.
|
||||
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
];
|
||||
@@ -171,7 +221,7 @@ pub static LANGUAGES: [LangInfo; 18] = [
|
||||
impl LangInfo {
|
||||
/// The catalog file's text (empty for English, or if the bundled data can't be inflated).
|
||||
pub fn text(&self) -> String {
|
||||
inflate(self.source).unwrap_or_else(|e| {
|
||||
unpack(self.source).unwrap_or_else(|e| {
|
||||
log::error!("translation catalog {}: {e}; showing English", self.code);
|
||||
String::new()
|
||||
})
|
||||
@@ -418,6 +468,11 @@ mod tests {
|
||||
assert_eq!(lang_from_tag("it-CH"), Lang::from_code("it"));
|
||||
assert_eq!(lang_from_tag("nl_NL.UTF-8"), Lang::from_code("nl"));
|
||||
assert_eq!(lang_from_tag("nl-BE"), Lang::from_code("nl"));
|
||||
assert_eq!(lang_from_tag("hu_HU.UTF-8"), Lang::from_code("hu"));
|
||||
assert_eq!(lang_from_tag("hu-HU"), Lang::from_code("hu"));
|
||||
assert_eq!(lang_from_tag("tr_TR.UTF-8"), Lang::from_code("tr"));
|
||||
assert_eq!(lang_from_tag("tr-TR"), Lang::from_code("tr"));
|
||||
assert_eq!(lang_from_tag("tr"), Lang::from_code("tr"));
|
||||
// Traditional Chinese: by region, by script, and with a region after the script.
|
||||
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
|
||||
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
|
||||
@@ -513,6 +568,22 @@ mod tests {
|
||||
assert_eq!(trn(es, 3, "{n} item", "{n} items"), "3 elementos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hungarian_resolves_and_keeps_the_singular_after_counts() {
|
||||
let hu = Lang::from_code("hu").expect("hu registered");
|
||||
for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(hu), "{tag}");
|
||||
}
|
||||
assert!(hu.complete_menus());
|
||||
assert_eq!(hu.name(), "Magyar");
|
||||
assert_eq!(tr(hu, "Layer"), "Réteg");
|
||||
for (n, expected) in [(0, "0 réteg"), (1, "1 réteg"), (3, "3 réteg"), (21, "21 réteg")] {
|
||||
assert_eq!(trn(hu, n, "{n} layer", "{n} layers"), expected);
|
||||
}
|
||||
assert_eq!(tr_ctx(hu, "layerLabel", "Red"), "Piros");
|
||||
assert_eq!(tr(hu, "Red"), "Vörös");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brazilian_portuguese_resolves_and_pluralises() {
|
||||
let pt = Lang::from_code("pt-br").expect("pt-br registered");
|
||||
@@ -677,13 +748,13 @@ mod tests {
|
||||
assert_eq!(tr(CS(), "All"), "Vše");
|
||||
}
|
||||
|
||||
/// The deflated catalogs in the binary inflate to exactly the `.tsv` files.
|
||||
/// The packed catalogs in the binary unpack to exactly the `.tsv` files.
|
||||
#[test]
|
||||
fn deflated_catalogs_match_their_files() {
|
||||
fn packed_catalogs_match_their_files() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n");
|
||||
for l in LANGUAGES.iter().skip(1) {
|
||||
let file = std::fs::read_to_string(dir.join(format!("{}.tsv", l.code))).unwrap();
|
||||
assert_eq!(inflate(l.source).unwrap(), file, "{}", l.code);
|
||||
assert_eq!(unpack(l.source).unwrap(), file, "{}", l.code);
|
||||
assert!(l.source.len() < file.len() / 2, "{}: stored deflated", l.code);
|
||||
}
|
||||
assert_eq!(LANGUAGES[0].text(), "", "English has no catalog");
|
||||
@@ -707,6 +778,18 @@ mod tests {
|
||||
assert_eq!(tr(Lang(info), "Layer"), "Layer", "falls back to English");
|
||||
}
|
||||
|
||||
/// Packed lines unpack to their keys; references to keys that don't exist are an error, not a
|
||||
/// panic.
|
||||
#[test]
|
||||
fn packed_lines_unpack_and_bad_references_are_errors() {
|
||||
let keys = ["\tOpen", "@id\tfile.open", "\tClose"];
|
||||
let packed = "# c\n\x01Ouvrir\n\x02-1\tOuvrir\n\x02+1\tFermer\n\x03\x01x\n";
|
||||
assert_eq!(expand(packed, &keys).unwrap(), "# c\n\tOpen\tOuvrir\n\tOpen\tOuvrir\n\tClose\tFermer\n\x01x\n");
|
||||
for bad in ["\x02-1\tx", "\x02 3\tx", "\x023", "\x029223372036854775807\tx", "\x02-9223372036854775808\tx", "\x01a\n\x01b\n\x01c\n\x01d"] {
|
||||
assert!(expand(bad, &keys).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every bundled catalog is well-formed and consistent with its sources.
|
||||
#[test]
|
||||
fn bundled_catalogs_are_consistent() {
|
||||
|
||||
@@ -383,6 +383,7 @@
|
||||
Select Linked Layers Gekoppelde lagen selecteren
|
||||
Merge Layers Lagen samenvoegen
|
||||
Merge Down Omlaag samenvoegen
|
||||
Merge Group Groep samenvoegen
|
||||
Merge Visible Zichtbare samenvoegen
|
||||
Stamp Visible Zichtbare stempelen
|
||||
Select Layer Above Laag erboven selecteren
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -389,6 +389,7 @@
|
||||
Select Linked Layers Zaznacz połączone warstwy
|
||||
Merge Layers Scal warstwy
|
||||
Merge Down Scal w dół
|
||||
Merge Group Scal grupę
|
||||
Merge Visible Scal widoczne
|
||||
Stamp Visible Odbij widoczne
|
||||
Select Layer Above Zaznacz warstwę powyżej
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -383,6 +383,7 @@
|
||||
Select Linked Layers Selecionar camadas vinculadas
|
||||
Merge Layers Mesclar camadas
|
||||
Merge Down Mesclar para baixo
|
||||
Merge Group Mesclar grupo
|
||||
Merge Visible Mesclar visíveis
|
||||
Stamp Visible Carimbar visíveis
|
||||
Stamp Down Carimbar para baixo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1897,6 +1897,7 @@
|
||||
Lock Layers… Заблокировать слои…
|
||||
Mean Среднее
|
||||
Merge Down Выполнить сведение
|
||||
Merge Group Объединить группу
|
||||
Merge Layers Объединить слои
|
||||
Merge Shape Components Объединить компоненты фигуры
|
||||
Merge Visible Свести видимые
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -96,6 +96,8 @@ mod tests {
|
||||
(vec!["pt-PT"], "pt-br"),
|
||||
(vec!["it-IT", "en-US"], "it"),
|
||||
(vec!["nl-BE", "fr-BE"], "nl"),
|
||||
(vec!["hu-HU", "en-US"], "hu"),
|
||||
(vec!["tr-TR", "en-US"], "tr"),
|
||||
(vec![], "en"),
|
||||
] {
|
||||
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1876,6 +1876,7 @@
|
||||
Lock Layers… Заблокувати шари…
|
||||
Mean Середнє значення
|
||||
Merge Down Об'єднати з нижнім
|
||||
Merge Group Об'єднати групу
|
||||
Merge Layers Об'єднати шари
|
||||
Merge Shape Components Об'єднати компоненти фігури
|
||||
Merge Visible Об'єднати видимі
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1356 and column 18.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user