diff --git a/ASSETS.md b/ASSETS.md index 17ab680ff4..34d75235c6 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -159,6 +159,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-free-transform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-fx.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-gamut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-grad-conic.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-grad-diamond.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/Cargo.lock b/Cargo.lock index d018aa027f..02e109b6b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,6 +967,17 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-affinity" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=228f6d0c50f1fdee4c5124603602993fc79d4c9d#228f6d0c50f1fdee4c5124603602993fc79d4c9d" +dependencies = [ + "crc32fast", + "flate2", + "image", + "ruzstd", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -4879,9 +4890,6 @@ name = "ruzstd" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" -dependencies = [ - "twox-hash", -] [[package]] name = "ryu" @@ -6066,12 +6074,6 @@ dependencies = [ "core_maths", ] -[[package]] -name = "twox-hash" -version = "2.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" - [[package]] name = "type-map" version = "0.5.1" @@ -6313,7 +6315,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.24.0" +version = "0.24.1" dependencies = [ "arboard", "block2 0.6.2", @@ -6353,31 +6355,31 @@ dependencies = [ "winit", "winresource", "winsafe", + "wl-clipboard-rs", "x11rb", "zbus", ] [[package]] name = "vectorcraft-affinity" -version = "0.24.0" +version = "0.24.1" dependencies = [ + "craft-affinity", "crc32fast", - "flate2", "image", "proptest", - "ruzstd", ] [[package]] name = "vectorcraft-apischema" -version = "0.24.0" +version = "0.24.1" dependencies = [ "serde_json", ] [[package]] name = "vectorcraft-brush" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "serde", @@ -6389,7 +6391,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6405,7 +6407,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.24.0" +version = "0.24.1" dependencies = [ "log", "objc2-core-foundation", @@ -6428,7 +6430,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6440,7 +6442,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.24.0" +version = "0.24.1" dependencies = [ "chrono", "hayro-ccitt", @@ -6459,7 +6461,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6477,7 +6479,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.24.0" +version = "0.24.1" dependencies = [ "cfb", "chrono", @@ -6498,6 +6500,7 @@ dependencies = [ "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", + "same-file", "serde", "serde_json", "skrifa 0.47.0", @@ -6530,7 +6533,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-pdf", "encoding_rs", @@ -6558,7 +6561,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-vectorcraft-format", "flate2", @@ -6574,7 +6577,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6586,17 +6589,17 @@ dependencies = [ [[package]] name = "vectorcraft-js" -version = "0.24.0" +version = "0.24.1" dependencies = [ "chrono", "getrandom 0.4.3", "proptest", - "unicode-general-category", + "unicode-properties", ] [[package]] name = "vectorcraft-mcp" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "log", @@ -6615,7 +6618,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6629,7 +6632,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.24.0" +version = "0.24.1" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6641,7 +6644,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.24.0" +version = "0.24.1" dependencies = [ "aes", "chrono", @@ -6675,7 +6678,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-vectorcraft-format", "image", @@ -6694,7 +6697,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.24.0" +version = "0.24.1" dependencies = [ "flate2", "gif 0.14.2", @@ -6724,15 +6727,15 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.24.0" +version = "0.24.1" dependencies = [ "proptest", - "unicode-general-category", + "unicode-properties", ] [[package]] name = "vectorcraft-svg" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-docpath", "flate2", @@ -6759,7 +6762,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.24.0" +version = "0.24.1" dependencies = [ "fax", "image", @@ -6777,7 +6780,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.24.0" +version = "0.24.1" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6798,7 +6801,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.24.0" +version = "0.24.1" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6806,7 +6809,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6823,7 +6826,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.24.0" +version = "0.24.1" dependencies = [ "image", "kurbo 0.13.1", @@ -6835,7 +6838,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.24.0" +version = "0.24.1" dependencies = [ "bytemuck", "craft-keymap", @@ -6873,7 +6876,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.24.0" +version = "0.24.1" dependencies = [ "craft-web-api", "eframe", @@ -8005,7 +8008,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.24.0" +version = "0.24.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 606c34f8b7..29f43866c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.24.0" +version = "0.24.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -23,6 +23,8 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63 # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } # craft-update: app-specific pretend versions apply to startup and manual checks. +# Native Affinity documents (craft-libs#914). +craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/README.md b/README.md index dc7a1cfa5f..7e49943a31 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu (92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out, PSD/PSB opened with their layers and placed as their merged image, JPEG XL in, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index 5bdd2bf631..0b1c560901 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -3,8 +3,6 @@ use std::process::{Command, Stdio}; use serde_json::{Value, json}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - /// Returns the path of `name` in `vectorcraft-cli-test-` in the temp folder. The first call removes /// that folder and creates it again empty. fn tmp(name: &str) -> std::path::PathBuf { @@ -27,7 +25,12 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) { std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap(); let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id()))); let run = |extra: &[&str]| { - Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap() + Command::new(crate::bin()) + .env("VECTORCRAFT_CONFIG_DIR", &config) + .args(["run", "--commands", steps.to_str().unwrap()]) + .args(extra) + .output() + .unwrap() }; let out = run(&[]); assert!(!out.status.success()); @@ -38,7 +41,7 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) { let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id()))); let mcp_install = |extra: &[&str]| -> Value { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .env("VECTORCRAFT_CONFIG_DIR", &mcp_config) .args(["mcp", "--headless"]) .args(extra) @@ -77,22 +80,23 @@ fn plugin_grants_need_the_start_up_flag() { approval_param_needs_the_start_up_flag("grant", json!(["doc.read"])); approval_param_needs_the_start_up_flag("allowHeavy", json!(true)); - let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); assert!(!out.status.success()); assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server")); let root = std::env::temp_dir(); for flag in ["--automation-read-root", "--automation-write-root"] { - let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); + let out = + Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); assert!(!out.status.success(), "{flag}"); assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}"); } - let usage = Command::new(BIN).arg("help").output().unwrap(); + let usage = Command::new(crate::bin()).arg("help").output().unwrap(); assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect")); } #[test] fn commands_prints_catalogue() { - let out = Command::new(BIN).arg("commands").output().unwrap(); + let out = Command::new(crate::bin()).arg("commands").output().unwrap(); assert!(out.status.success()); let v: Value = serde_json::from_slice(&out.stdout).unwrap(); let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); @@ -104,7 +108,7 @@ fn run_batch_exports() { let svg = tmp("batch.svg"); let png = tmp("batch.png"); let dc = tmp("batch.vectorcraft"); - let out = Command::new(BIN) + let out = Command::new(crate::bin()) .args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#]) .args(["--cmd", "shape.ellipse", "--params", r#"{"x":10,"y":10,"width":80,"height":60}"#]) .args(["--cmd", "paint.setFill", "--params", r##"{"color":"#3366ff"}"##]) @@ -121,7 +125,7 @@ fn run_batch_exports() { assert_eq!(u32::from_be_bytes(png_bytes[16..20].try_into().unwrap()), 400); // Re-open the native file and inspect. - let out = Command::new(BIN).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); let last: Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().lines().last().unwrap()).unwrap(); assert_eq!(last["result"]["artboards"][0]["width"], 200.0); @@ -137,7 +141,7 @@ fn scale_sizes_raster_exports_only() { let svg = svg.to_str().unwrap(); let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png")); let ok = |args: &[&str]| { - let out = Command::new(BIN).args(args).output().unwrap(); + let out = Command::new(crate::bin()).args(args).output().unwrap(); assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); }; ok(&["convert", svg, plain.to_str().unwrap()]); @@ -151,11 +155,11 @@ fn scale_sizes_raster_exports_only() { #[test] fn run_reports_errors() { - let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); assert!(!out.status.success()); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}"); - let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--params", "{}"]).output().unwrap(); assert!(!out.status.success()); } @@ -184,7 +188,7 @@ fn run_reads_steps_from_a_commands_file() { {"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}}, ]); std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap(); - let out = Command::new(BIN) + let out = Command::new(crate::bin()) .args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()]) .args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()]) .output() @@ -204,7 +208,7 @@ fn run_reads_steps_from_a_commands_file() { #[test] fn run_reads_json_lines_from_standard_input() { let svg = tmp("stdin.svg"); - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["run", "--commands", "-", "--export", svg.to_str().unwrap()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -227,7 +231,7 @@ fn run_reads_json_lines_from_standard_input() { #[test] fn run_refuses_malformed_commands_naming_the_step() { let fails = |args: &[&str]| { - let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap(); assert!(!out.status.success(), "{args:?}"); (String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len()) }; @@ -275,11 +279,11 @@ fn run_refuses_malformed_commands_naming_the_step() { /// `--help` and `-h` right after a subcommand print the usage that `vectorcraft-cli help` prints. #[test] fn help_after_a_subcommand_prints_the_usage() { - let usage = Command::new(BIN).arg("help").output().unwrap(); + let usage = Command::new(crate::bin()).arg("help").output().unwrap(); assert!(usage.status.success() && String::from_utf8_lossy(&usage.stdout).contains("USAGE:")); for sub in ["mcp", "run", "commands", "convert", "info", "bench", "perf"] { for flag in ["--help", "-h"] { - let out = Command::new(BIN).args([sub, flag]).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args([sub, flag]).stdin(Stdio::null()).output().unwrap(); assert!(out.status.success(), "{sub} {flag}: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.stdout == usage.stdout, "{sub} {flag}: {}", String::from_utf8_lossy(&out.stdout)); } @@ -290,7 +294,7 @@ fn help_after_a_subcommand_prints_the_usage() { /// with them. #[test] fn help_later_in_the_line_prints_the_usage() { - let usage = Command::new(BIN).arg("help").output().unwrap(); + let usage = Command::new(crate::bin()).arg("help").output().unwrap(); let mut wrong = vec![]; for args in [ &["mcp", "--headless", "--help"][..], @@ -311,7 +315,7 @@ fn help_later_in_the_line_prints_the_usage() { &["perf", "--paths", "many", "-h"], &["convert", "--decimals", "many", "--help"], ] { - let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap(); if !out.status.success() || out.stdout != usage.stdout { wrong.push(format!("{args:?}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout))); } @@ -322,17 +326,18 @@ fn help_later_in_the_line_prints_the_usage() { /// Further along the line, `-h` is the value of the option before it. #[test] fn help_as_an_option_value_goes_to_the_option() { - let out = Command::new(BIN).args(["run", "--cmd", "-h"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--cmd", "-h"]).output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success() && err.contains("vectorcraft-cli: step 0 (-h):") && out.stdout.is_empty(), "{err}"); - let out = Command::new(BIN).args(["convert", "in.svg", "--range", "-h"]).output().unwrap(); + let out = Command::new(crate::bin()).args(["convert", "in.svg", "--range", "-h"]).output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success() && err.contains("convert needs IN and OUT") && out.stdout.is_empty(), "{err}"); } #[test] fn mcp_headless_over_stdio() { - let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut child = + Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); { let mut stdin = child.stdin.take().unwrap(); for m in [ @@ -359,13 +364,13 @@ fn mcp_headless_over_stdio() { /// plug-ins. #[test] fn safe_mode_mcp_does_not_connect_to_a_running_app() { - let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap(); + let out = Command::new(crate::bin()).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}"); // Something listening on the default address, as a running app does; a running app or another // program already holding it serves as well. let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR); - let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}"); } @@ -402,7 +407,7 @@ fn mcp_automation_roots_take_several_folders() { } let dir = |d: &str| base.join(d).to_string_lossy().into_owned(); let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned(); - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))]) .args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")]) .stdin(Stdio::piped()) @@ -457,7 +462,7 @@ fn automation_roots(base: &std::path::Path) { } let dir = |d: &str| base.join(d).to_string_lossy().into_owned(); let fails = |args: &[&str], why: &str| { - let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success() && err.contains(why), "{args:?}: {err}"); }; @@ -469,7 +474,7 @@ fn automation_roots(base: &std::path::Path) { "a running app enforces its own roots: start it with `vectorcraft --control ", ); - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -530,7 +535,7 @@ fn automation_roots(base: &std::path::Path) { fn mcp_protocol_over_stdio() { use std::io::{BufRead, BufReader}; let mut child = - Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); let mut stdin = child.stdin.take().unwrap(); let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); let mut notes = vec![]; @@ -711,7 +716,7 @@ fn run_command(id: u64, command: &str, params: Value) -> Value { #[test] fn closed_stdout_ends_quietly() { use std::io::Read; - let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut child = Command::new(crate::bin()).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); let mut first = [0u8; 16]; child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap(); drop(child.stdout.take()); @@ -728,13 +733,13 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { let _ = std::fs::remove_file(&svg); let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#; let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()]; - let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut child = Command::new(crate::bin()).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); drop(child.stdout.take()); let out = child.wait_with_output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status); assert!(svg.is_file(), "the export after the closed reader still ran"); - let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); + let out = Command::new(crate::bin()).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); assert!(!out.status.success()); } @@ -749,7 +754,7 @@ fn config_dir(name: &str) -> std::path::PathBuf { /// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`; /// the result of each step. fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec { - let mut cmd = Command::new(BIN); + let mut cmd = Command::new(crate::bin()); cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS"); for (id, params) in steps { cmd.args(["--cmd", id, "--params", ¶ms.to_string()]); @@ -820,7 +825,7 @@ fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() { #[test] fn runs_without_preferences_remember_nothing() { let config = config_dir("noprefs"); - let out = Command::new(BIN) + let out = Command::new(crate::bin()) .args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#]) .env("VECTORCRAFT_CONFIG_DIR", &config) .env("VECTORCRAFT_NO_PREFS", "1") diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs index c60357b5f8..71044be4a5 100644 --- a/apps/vectorcraft-cli/tests/it/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -4,8 +4,6 @@ use std::process::Command; use serde_json::Value; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - /// Returns the path of `name` in `vectorcraft-cli-fileio-` in the temp folder. The first call /// removes that folder and creates it again empty. fn tmp(name: &str) -> String { @@ -20,7 +18,7 @@ fn tmp(name: &str) -> String { } fn ok(args: &[&str]) -> String { - let out = Command::new(BIN).args(args).output().unwrap(); + let out = Command::new(crate::bin()).args(args).output().unwrap(); assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8(out.stdout).unwrap() } @@ -130,7 +128,7 @@ fn raster_exports_use_document_resolution_unless_overridden() { fn raster_export_rejects_invalid_ppi() { for ppi in ["0", "-150", "NaN", "inf", "high"] { for args in [vec!["run", "--export", "unused.png", "--ppi", ppi], vec!["convert", "unused.svg", "unused.png", "--ppi", ppi]] { - let out = Command::new(BIN).args(&args).output().unwrap(); + let out = Command::new(crate::bin()).args(&args).output().unwrap(); assert!(!out.status.success(), "{args:?}"); assert!(String::from_utf8_lossy(&out.stderr).contains("positive finite number"), "{args:?}: {out:?}"); } @@ -228,7 +226,7 @@ fn convert_writes_legacy_ai_with_its_options() { assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}"); assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0")); // A value the engine doesn't take is an error. - let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap(); + let bad = Command::new(crate::bin()).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap(); assert!(!bad.status.success()); assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr)); } @@ -253,7 +251,7 @@ fn convert_and_run_write_ait_templates() { assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}"); } // A template holds every artboard: one can't be picked. - let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap(); + let one = Command::new(crate::bin()).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap(); assert!(!one.status.success()); assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr)); } diff --git a/apps/vectorcraft-cli/tests/it/main.rs b/apps/vectorcraft-cli/tests/it/main.rs index 07e5a3a70d..d898dda20a 100644 --- a/apps/vectorcraft-cli/tests/it/main.rs +++ b/apps/vectorcraft-cli/tests/it/main.rs @@ -6,3 +6,11 @@ mod cli; mod fileio; mod mcp_e2e; mod script; + +/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner. +fn bin() -> std::path::PathBuf { + std::env::var_os("NEXTEST_BIN_EXE_vectorcraft_cli") + .or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft-cli")) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft-cli").into()) +} diff --git a/apps/vectorcraft-cli/tests/it/mcp_e2e.rs b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs index 5a353070ae..4931602ec8 100644 --- a/apps/vectorcraft-cli/tests/it/mcp_e2e.rs +++ b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs @@ -6,8 +6,6 @@ use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use serde_json::{Value, json}; use vectorcraft_testkit::raster::Image; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - struct Mcp { child: Child, stdin: Option, @@ -17,7 +15,7 @@ struct Mcp { impl Mcp { fn spawn() -> Self { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(["mcp", "--headless"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/apps/vectorcraft-cli/tests/it/script.rs b/apps/vectorcraft-cli/tests/it/script.rs index d68716f7b3..3652e585d0 100644 --- a/apps/vectorcraft-cli/tests/it/script.rs +++ b/apps/vectorcraft-cli/tests/it/script.rs @@ -2,11 +2,9 @@ use std::process::Command; use serde_json::{Value, json}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); - /// `run` with `script.run` after `file.new`, with `params`. fn run_script(params: &Value) -> std::process::Output { - Command::new(BIN).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", ¶ms.to_string()]).output().unwrap() + Command::new(crate::bin()).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", ¶ms.to_string()]).output().unwrap() } /// `run` runs a script headless: the reply holds the report, with what the script logged and the diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index dfd9b16719..047c4530c7 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -113,6 +113,10 @@ wayland-backend = { version = "0.3", features = ["client_system"] } # exactly: review the diff before bumping. [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies] zbus = "=5.19.0" +# Tells whether arboard reads through the compositor's data-control protocol or through X11, the +# same check arboard makes (src/clipboard.rs). MIT OR Apache-2.0, already in the build through +# arboard. +wl-clipboard-rs = "0.9" [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index b9be306898..5756600281 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -12,7 +12,9 @@ //! copied), falling back to X11 where the compositor lacks the protocol, and where there is no //! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection, //! text only. A clipboard that can't be opened is tried again after a wait that grows to a -//! minute, not each time Paste asks. +//! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app +//! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at +//! once; a paste then waits for that read's answer off the UI thread. //! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, //! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). @@ -332,11 +334,290 @@ mod win { } } +/// Reads made on a worker thread and waited for a bounded time: a clipboard owner that never sends +/// its data holds the worker, not the caller. On Linux and the BSDs arboard's reads go through one, +/// and on Linux the Wayland core protocol's reads through another. Built everywhere so it is +/// tested everywhere. +#[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), + allow(dead_code) +)] +mod loads { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex, PoisonError, Weak, mpsc}; + use std::time::{Duration, Instant}; + + /// The longest a read waits for the clipboard's owner to send its data. + pub(super) const LOAD_WAIT: Duration = Duration::from_secs(2); + /// What a read says once its worker has stopped. + pub(super) const GONE: &str = "the clipboard's reader stopped"; + + /// Requests of type `R` answered with `A` on a worker thread. + pub(super) struct Loads { + /// The worker's requests; the worker stops once this is dropped. + line: Arc>, + /// Disconnected once the worker has let go of what its `load` holds. + done: mpsc::Receiver<()>, + /// How long after it was sent dropping `Loads` waits for an unanswered read to end. + finish: Duration, + } + + /// Where requests go to a [`Loads`] worker. + struct Line { + /// Each request goes with the channel its answer is sent on. + requests: mpsc::Sender<(R, mpsc::Sender)>, + /// Requests sent and not answered yet, counted down by the worker: while one is open, + /// reads don't queue behind it. + open: Arc, + /// When the last request was sent, and whether the `Loads` has stopped taking requests: + /// requests are sent with this locked. + sent: Mutex, + } + + #[derive(Default)] + struct Sent { + at: Option, + closed: bool, + } + + impl Line { + /// A line whose worker has stopped. + fn dead() -> Arc { + Arc::new(Self { requests: mpsc::channel().0, open: Arc::default(), sent: Mutex::default() }) + } + + /// `request` sent to the worker ([`Loads::ask`]). + fn ask(&self, request: R) -> Result, String> { + let mut sent = self.sent.lock().unwrap_or_else(PoisonError::into_inner); + if sent.closed { + return Err(GONE.into()); + } + if self.open.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst).is_err() { + return Err("the clipboard's owner hasn't answered an earlier read".into()); + } + let (reply, answer) = mpsc::channel(); + sent.at = Some(Instant::now()); + if self.requests.send((request, reply)).is_err() { + self.open.fetch_sub(1, Ordering::SeqCst); + return Err(GONE.into()); + } + Ok(answer) + } + + /// Stops taking requests → when the one still unanswered was sent. + fn close(&self) -> Option { + let mut sent = self.sent.lock().unwrap_or_else(PoisonError::into_inner); + sent.closed = true; + sent.at.filter(|_| self.open.load(Ordering::SeqCst) > 0) + } + } + + /// Sends requests to a [`Loads`] worker from another thread while the `Loads` is open. + pub(super) struct Asker(Weak>); + + impl Asker { + /// [`Loads::ask`]; Err also once the `Loads` has been dropped. + pub(super) fn ask(&self, request: R) -> Result, String> { + self.0.upgrade().ok_or_else(|| GONE.to_string())?.ask(request) + } + } + + /// When dropping `Loads` stops waiting for its worker: `finish` after the unanswered read + /// `pending` was sent (at once if that time has passed), and with no read unanswered + /// [`LOAD_WAIT`] from `now` at most (an idle worker ends at once). + fn stop_by(pending: Option, now: Instant, finish: Duration) -> Instant { + pending.map_or(now + LOAD_WAIT, |sent| sent + finish) + } + + impl Loads { + /// A worker thread named `name` that answers each request with `load`; dropping the result + /// waits up to `finish` after an unanswered read was sent for that read to end. None when the + /// worker can't start. + pub(super) fn spawn(name: &str, finish: Duration, mut load: impl FnMut(R) -> A + Send + 'static) -> Option { + let (requests, asked) = mpsc::channel::<(R, mpsc::Sender)>(); + let (alive, done) = mpsc::channel::<()>(); + let open = Arc::new(AtomicUsize::new(0)); + let answered = open.clone(); + let work = move || { + while let Ok((request, reply)) = asked.recv() { + let answer = load(request); + // Counted down before the answer goes, so its reader can ask again at once. + answered.fetch_sub(1, Ordering::SeqCst); + // A read that timed out no longer waits: its late answer is dropped. + let _ = reply.send(answer); + } + // `load` and the clipboard it holds go first: dropping `Loads` waits for `alive`. + drop(load); + drop(alive); + }; + std::thread::Builder::new().name(name.into()).spawn(work).ok()?; + Some(Self { line: Arc::new(Line { requests, open, sent: Mutex::default() }), done, finish }) + } + + /// `request` sent to the worker; its answer comes on the channel returned, for + /// [`wait_for_answer`] here or on another thread. Err when an earlier request is still + /// unanswered or the worker has stopped. + pub(super) fn ask(&mut self, request: R) -> Result, String> { + self.line.ask(request) + } + + /// Whether a request sent is still unanswered. + pub(super) fn waiting(&self) -> bool { + self.line.open.load(Ordering::SeqCst) > 0 + } + + /// A handle that sends requests from another thread until this is dropped. + pub(super) fn asker(&self) -> Asker { + Asker(Arc::downgrade(&self.line)) + } + + /// The answer to `request`, or why there is none, after `wait` at most. + #[cfg(test)] + pub(super) fn get(&mut self, request: R, wait: Duration) -> Result { + wait_for_answer(&self.ask(request)?, wait) + } + } + + /// The answer that comes on `answer` ([`Loads::ask`]), or why there is none, after `wait` at most. + pub(super) fn wait_for_answer(answer: &mpsc::Receiver, wait: Duration) -> Result { + match answer.recv_timeout(wait) { + Ok(answer) => Ok(answer), + Err(mpsc::RecvTimeoutError::Timeout) => Err(format!("the clipboard's owner didn't send its data within {wait:?}")), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), + } + } + + impl Drop for Loads { + /// Stops the worker and waits until it has let go of what its `load` holds, until + /// [`stop_by`] at most: on X11 the last arboard handle dropped hands what the app copied to + /// the clipboard manager, and the app's own handle must be that one. + fn drop(&mut self) { + // No `Asker` sends after this, and one that holds the line lets go once it has sent. + let pending = self.line.close(); + drop(std::mem::replace(&mut self.line, Line::dead())); + let end = stop_by(pending, Instant::now(), self.finish); + // Nothing is sent on `done`: this ends when the worker drops `alive`, or at `end`. + let _ = self.done.recv_timeout(end.saturating_duration_since(Instant::now())); + } + } + + #[cfg(test)] + mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn a_read_that_answers_in_time_gives_the_text() { + let mut loads = Loads::spawn("test", Duration::ZERO, |()| "".to_string()).unwrap(); + assert_eq!(loads.get((), Duration::from_secs(5)), Ok("".to_string())); + assert_eq!(loads.get((), Duration::from_secs(5)), Ok("".to_string())); + } + + #[test] + fn an_owner_that_never_answers_holds_the_worker_not_the_caller() { + let (release, gate) = mpsc::channel::<()>(); + let mut calls = 0; + let mut loads = Loads::spawn("test", Duration::ZERO, move |()| { + calls += 1; + if calls == 1 { + // The first owner sends nothing until released. + let _ = gate.recv(); + return "stale".to_string(); + } + "fresh".to_string() + }) + .unwrap(); + assert!(loads.get((), Duration::ZERO).is_err(), "the first read times out"); + // While it is unanswered, a read doesn't queue behind it or wait at all. + assert!(loads.get((), Duration::from_secs(600)).is_err()); + release.send(()).unwrap(); + // Once the late answer is in, it is dropped and a new read is made. + let text = loop { + match loads.get((), Duration::from_secs(600)) { + Ok(text) => break text, + Err(why) => { + // The late answer isn't in yet. + assert_ne!(why, GONE); + std::thread::yield_now(); + } + } + }; + assert_eq!(text, "fresh"); + } + + #[test] + fn a_read_after_the_worker_stops_says_so() { + let mut loads = Loads::spawn("test", Duration::ZERO, |()| String::new()).unwrap(); + // The worker ends when its requests stop: cut them off by hand. + drop(std::mem::replace(&mut loads.line, Line::dead())); + assert_eq!(loads.get((), Duration::from_millis(50)), Err(GONE.to_string())); + } + + #[test] + fn another_threads_reads_stop_with_the_reads() { + let loads = Loads::spawn("test", Duration::ZERO, |()| 1).unwrap(); + let asker = loads.asker(); + assert_eq!(asker.ask(()).and_then(|a| wait_for_answer(&a, Duration::from_secs(600))), Ok(1)); + drop(loads); + assert_eq!(asker.ask(()).err(), Some(GONE.to_string())); + // Closed while an asker still holds the line: nothing more is sent. + let line = Line::<(), ()>::dead(); + assert_eq!(line.close(), None); + assert_eq!(line.ask(()).err(), Some(GONE.to_string())); + } + + #[test] + fn an_answer_waited_for_on_another_thread_holds_back_the_next_read() { + let (release, gate) = mpsc::channel::<()>(); + let mut loads = Loads::spawn("test", Duration::ZERO, move |n: u32| { + if n == 1 { + let _ = gate.recv(); + } + n + }) + .unwrap(); + let answer = loads.ask(1).unwrap(); + // The caller doesn't wait: the answer is waited for wherever the channel goes. + let waiter = std::thread::spawn(move || wait_for_answer(&answer, Duration::from_secs(600))); + // Until it comes, a read fails at once instead of queuing behind it. + assert!(loads.get(2, Duration::from_secs(600)).is_err()); + release.send(()).unwrap(); + assert_eq!(waiter.join().ok(), Some(Ok(1))); + assert_eq!(loads.get(3, Duration::from_secs(600)), Ok(3)); + } + + #[test] + fn dropping_the_reads_waits_until_the_worker_lets_go_of_its_clipboard() { + let clipboard = Arc::new(()); + let held = clipboard.clone(); + let mut loads = Loads::spawn("test", Duration::ZERO, move |()| Arc::strong_count(&held)).unwrap(); + assert_eq!(loads.get((), Duration::from_secs(600)), Ok(2)); + drop(loads); + assert_eq!(Arc::strong_count(&clipboard), 1, "the worker's handle is gone before the app's"); + } + + #[test] + fn dropping_the_reads_waits_for_an_unanswered_read_up_to_its_limit() { + let now = Instant::now(); + let limit = Duration::from_secs(25); + // No read unanswered: the idle worker ends at once, LOAD_WAIT is only the bound. + assert_eq!(stop_by(None, now, limit), now + LOAD_WAIT); + // A read sent 2 s ago that timed out: wait for it until 25 s after it was sent. + assert_eq!(stop_by(Some(now - Duration::from_secs(2)), now, limit), now + Duration::from_secs(23)); + // Its limit already over, or no limit (data-control, the core protocol): no wait. + assert!(stop_by(Some(now - Duration::from_secs(30)), now, limit) <= now); + assert_eq!(stop_by(Some(now), now, Duration::ZERO), now); + } + } +} + /// arboard, or on a Wayland compositor without its data-control protocol the core protocol's text /// clipboard: one format at a time. Built everywhere so it is checked everywhere. #[cfg_attr(windows, allow(dead_code))] mod portable { use std::borrow::Cow; + use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -344,14 +625,342 @@ mod portable { use super::{BITMAP, ClipboardRead, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of}; /// What our last write put on the clipboard. + #[derive(Clone)] enum Ours { Text(String), Image(usize, usize), } + /// A read of arboard's clipboard. + #[derive(Clone, Copy, PartialEq, Eq)] + enum Request { + Text, + Image, + Files, + } + + /// What a [`Request`] read. + enum Answer { + Text(Result), + Image(Result, String>), + Files(Vec), + } + + /// What a read says when it gets the answer to another request. + const MIXED: &str = "the clipboard answered another read"; + + impl Answer { + /// The answer to `request` when it couldn't be read: `why`, or no files. + fn failed(request: Request, why: String) -> Self { + match request { + Request::Text => Self::Text(Err(why)), + Request::Image => Self::Image(Err(why)), + Request::Files => Self::Files(vec![]), + } + } + + /// The request this answers. + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), + allow(dead_code) + )] + fn request(&self) -> Request { + match self { + Self::Text(_) => Request::Text, + Self::Image(_) => Request::Image, + Self::Files(_) => Request::Files, + } + } + + fn text(self) -> Result { + match self { + Self::Text(text) => text, + _ => Err(MIXED.into()), + } + } + + fn image(self) -> Result, String> { + match self { + Self::Image(image) => image, + _ => Err(MIXED.into()), + } + } + + fn files(self) -> Vec { + match self { + Self::Files(files) => files, + _ => vec![], + } + } + } + + /// `request` read through `cb`. + fn answer(cb: &mut arboard::Clipboard, request: Request) -> Answer { + match request { + Request::Text => Answer::Text(cb.get_text().map_err(|e| e.to_string())), + Request::Image => Answer::Image(cb.get_image().map_err(|e| e.to_string())), + Request::Files => Answer::Files(cb.get().file_list().unwrap_or_default()), + } + } + + /// `request` read on `reads`' worker, waiting `wait` at most. + #[cfg(test)] + fn read_within(reads: &mut super::loads::Loads, request: Request, wait: Duration) -> Answer { + reads.get(request, wait).unwrap_or_else(|why| Answer::failed(request, why)) + } + + /// The longest a paste's bitmap read of arboard's waits for the app that copied, off the UI + /// thread: the bitmap's transfer and its PNG decoding happen within the read. A paste waits as + /// long, from when it was sent, for a read that got no answer in its wait, the core protocol's + /// text read included. Text, file lists and the UI thread's bitmap check wait + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + const IMAGE_WAIT: Duration = Duration::from_secs(10); + + /// How long a read of `request` waits for the app that copied. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn wait_for(request: Request) -> Duration { + match request { + Request::Image => IMAGE_WAIT, + Request::Text | Request::Files => super::loads::LOAD_WAIT, + } + } + + /// How long a paste's read of `request` after a late answer waits at `now`: as long as any read + /// of `request`, or until [`IMAGE_WAIT`] after the unanswered read was `sent` when that is later. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn follow_up_wait(request: Request, sent: Instant, now: Instant) -> Duration { + wait_for(request).max((sent + IMAGE_WAIT).saturating_duration_since(now)) + } + + /// The longest an X11 read of arboard's lasts while the owner sends nothing: a text read tries + /// six formats and waits up to 4 s for each, and a second more covers the rest of the read. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + const X11_READ_LIMIT: Duration = Duration::from_secs(25); + + /// Whether arboard's handles use the compositor's data-control protocol rather than X11. arboard + /// 3.7 makes the same check when it opens: data-control where `WAYLAND_DISPLAY` is set and + /// wl-clipboard-rs finds the protocol, X11 otherwise. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn on_data_control() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() && wl_clipboard_rs::utils::is_primary_selection_supported().is_ok() + } + + /// How long after an unanswered read was sent dropping the reads waits for it to end: on X11 + /// the last handle dropped hands what the app copied to a clipboard manager, and on + /// data-control, where a read may never end, nothing is handed over. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn finish(data_control: bool) -> Duration { + if data_control { Duration::ZERO } else { X11_READ_LIMIT } + } + + /// arboard's clipboard, with `C` for writes (arboard's handle; a stand-in in tests). On Linux + /// and the BSDs its reads run on a worker with a handle of its own and wait + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, a paste's bitmap 10 s off the UI thread: arboard's Wayland + /// data-control read waits for the app that copied to send its data with no limit, and its X11 + /// read waits up to 4 s for each format it tries. + struct Arboard { + /// Dropped before `cb`, and waits for its worker's handle to go, on X11 up to + /// [`X11_READ_LIMIT`] after an unanswered read was sent: there the last handle dropped + /// hands what the app copied to the clipboard manager. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + reads: super::loads::Loads, + /// The last read that got no answer in its wait, until a paste takes it or the next read + /// is sent. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + unanswered: Option, + /// Writes, and elsewhere reads. + cb: C, + } + + /// A read on arboard's worker that got no answer in its wait. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + struct Unanswered { + request: Request, + answer: std::sync::mpsc::Receiver, + sent: Instant, + /// The worker, for the paste's other reads. + reads: super::loads::Asker, + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + impl Unanswered { + /// Whether a paste of one of `mimes` can take this read's answer: files paste as any of + /// them. + fn fits(&self, mimes: &[&'static str]) -> bool { + match self.request { + Request::Text => mimes.contains(&TEXT), + Request::Image => mimes.contains(&BITMAP), + Request::Files => true, + } + } + + /// What a paste of one of `mimes` takes, once this read's answer comes, waiting for it until + /// [`IMAGE_WAIT`] after it was sent, as long as a paste's bitmap read waits. Once it comes, + /// the same read is made again, since the clipboard may have changed hands meanwhile. + /// `None`, so the paste takes the internal clipboard, when that answer doesn't come or the + /// new one is what `ours` put on the clipboard. Otherwise the clipboard is read as + /// [`Clipboard::read_now_or_later`] and the paste read it, the new answer standing in for + /// the read it answers: copied files, else + /// text, else the bitmap, and the bitmap when it is asked for and the text is blank or only + /// names a picture (`adopt_system_clipboard_or_defer` in `vectorcraft-ui-egui`). Those reads + /// wait as [`follow_up_wait`] says. + fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option { + let wait = (self.sent + IMAGE_WAIT).saturating_duration_since(Instant::now()); + super::loads::wait_for_answer(&self.answer, wait).ok()?; + let fresh = |request: Request| { + self.reads + .ask(request) + .and_then(|a| super::loads::wait_for_answer(&a, follow_up_wait(request, self.sent, Instant::now()))) + .unwrap_or_else(|why| Answer::failed(request, why)) + }; + // The late answer may come from an app that lost the clipboard while it was awaited. + let answer = fresh(self.request); + let held = match (&answer, ours) { + (Answer::Text(Ok(now)), Some(Ours::Text(t))) => now == t, + (Answer::Image(Ok(now)), Some(Ours::Image(w, h))) => (now.width, now.height) == (*w, *h), + _ => false, + }; + if held { + return None; + } + let mut answer = Some(answer); + let mut read = |request: Request| match answer.take_if(|a| a.request() == request) { + Some(answer) => answer, + None => fresh(request), + }; + if let Some(f) = copied_file(read(Request::Files).files(), mimes) { + return Some(f); + } + let text = if mimes.contains(&TEXT) { read(Request::Text).text().ok().filter(|t| !t.is_empty()) } else { None }; + let bitmap = |read: &mut dyn FnMut(Request) -> Answer| image_flavour(read(Request::Image).image().ok()?); + match text { + Some(t) if mimes.contains(&BITMAP) && (t.trim().is_empty() || vectorcraft_engine::cmd::clipboard::names_a_picture(&t)) => { + bitmap(&mut read).or(Some(Flavour { mime: TEXT, data: t.into_bytes() })) + } + Some(t) => Some(Flavour { mime: TEXT, data: t.into_bytes() }), + None if mimes.contains(&BITMAP) => bitmap(&mut read), + None => None, + } + } + } + + /// A read that got no answer in its wait, for a paste to wait for off the UI thread. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + enum Late { + Arboard(Unanswered), + /// The core protocol's text read. + #[cfg(target_os = "linux")] + Wayland(super::wayland::Unanswered), + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + impl Late { + /// What a paste of one of `mimes` takes once the answer comes ([`Unanswered::paste`]). The + /// core protocol's text is waited for as long, until [`IMAGE_WAIT`] after its read was + /// sent, then read again, and pasted unless it is empty, what `ours` put on the clipboard, + /// or doesn't come. + fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option { + match self { + Self::Arboard(unanswered) => unanswered.paste(ours, mimes), + #[cfg(target_os = "linux")] + Self::Wayland(unanswered) => { + let text = unanswered.text(IMAGE_WAIT).ok().filter(|t| !t.is_empty())?; + let held = matches!(ours, Some(Ours::Text(t)) if *t == text); + (!held).then(|| Flavour { mime: TEXT, data: text.into_bytes() }) + } + } + } + } + + /// arboard's bitmap as a PNG flavour. + fn image_flavour(img: arboard::ImageData<'static>) -> Option { + let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); + png_flavour(&image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?) + } + + impl Arboard { + fn open() -> Result { + let cb = arboard::Clipboard::new().map_err(|e| e.to_string())?; + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + { + let mut reader = arboard::Clipboard::new().map_err(|e| e.to_string())?; + let reads = super::loads::Loads::spawn("clipboard-read", finish(on_data_control()), move |request| answer(&mut reader, request)) + .ok_or("the clipboard's reader didn't start")?; + Ok(Self { reads, unanswered: None, cb }) + } + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] + Ok(Self { cb }) + } + + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] + fn read(&mut self, request: Request) -> Answer { + answer(&mut self.cb, request) + } + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + impl Arboard { + /// Reads on the worker; the write handle `C` is not used. + fn read(&mut self, request: Request) -> Answer { + self.read_waiting(request, wait_for(request)) + } + + /// A bitmap read on the worker that waits [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, for + /// a check on the UI thread. + fn image_now(&mut self) -> Result, String> { + self.read_waiting(Request::Image, super::loads::LOAD_WAIT).image() + } + + /// `request` read on the worker, waiting `wait` at most. A read that gets no answer in that + /// time is kept ([`Self::unanswered`]) for a paste to wait for off the UI thread. + fn read_waiting(&mut self, request: Request, wait: Duration) -> Answer { + let sent = Instant::now(); + let answer = match self.ask(request) { + Ok(answer) => answer, + Err(why) => return Answer::failed(request, why), + }; + match super::loads::wait_for_answer(&answer, wait) { + Ok(answer) => answer, + Err(why) => { + if why != super::loads::GONE { + self.unanswered = Some(Unanswered { request, answer, sent, reads: self.reads.asker() }); + } + Answer::failed(request, why) + } + } + } + + /// `request` sent to the worker. A read sent means the one kept unanswered has been + /// answered, and its answer is dropped. + fn ask(&mut self, request: Request) -> Result, String> { + let answer = self.reads.ask(request)?; + self.unanswered = None; + Ok(answer) + } + + /// A bitmap read sent to the worker now; the work returned waits [`IMAGE_WAIT`] at most for + /// it, so the caller can wait off the UI thread. Err when the read can't be sent. + fn image_later(&mut self) -> Result { + let answer = self.ask(Request::Image)?; + Ok(Box::new(move || super::loads::wait_for_answer(&answer, IMAGE_WAIT).and_then(Answer::image))) + } + + /// The read that got no answer in its wait, while its answer has still not come and a paste + /// of one of `mimes` can take it: what the clipboard holds is not known until it comes. Once + /// it has come the answer may be old, and it is dropped: a new read is made. + fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + let unanswered = self.unanswered.take_if(|u| u.fits(mimes))?; + self.reads.waiting().then_some(unanswered) + } + } + + /// A bitmap read whose answer is waited for when it runs. + type ImageWork = Box Result, String> + Send>; + /// An open clipboard connection. enum Backend { - Arboard(arboard::Clipboard), + Arboard(Arboard), /// The compositor's core data-device protocol on the window's connection (text only). #[cfg(target_os = "linux")] Wayland(super::wayland::TextClipboard), @@ -366,7 +975,7 @@ mod portable { /// Wayland connection. Err: why each failed. fn open(window: Option<&Arc>) -> Result { first_open( - || arboard::Clipboard::new().map(Self::Arboard).map_err(|e| e.to_string()), + || Arboard::open().map(Self::Arboard), || { #[cfg(target_os = "linux")] return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window"); @@ -389,7 +998,7 @@ mod portable { fn get_text(&mut self) -> Result { match self { - Self::Arboard(cb) => cb.get_text().map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.read(Request::Text).text(), #[cfg(target_os = "linux")] Self::Wayland(cb) => cb.get_text(), } @@ -397,7 +1006,7 @@ mod portable { fn set_text(&mut self, text: &str) -> Result<(), String> { match self { - Self::Arboard(cb) => cb.set_text(text).map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.cb.set_text(text).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] Self::Wayland(cb) => { cb.set_text(text); @@ -406,9 +1015,30 @@ mod portable { } } - fn get_image(&mut self) -> Result, String> { + /// The bitmap, for a check on the UI thread: on Linux and the BSDs it waits + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, as text does. + fn image_now(&mut self) -> Result, String> { match self { - Self::Arboard(cb) => cb.get_image().map_err(|e| e.to_string()), + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + Self::Arboard(cb) => cb.image_now(), + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] + Self::Arboard(cb) => cb.read(Request::Image).image(), + #[cfg(target_os = "linux")] + Self::Wayland(_) => Err(TEXT_ONLY.into()), + } + } + + /// The bitmap as work for another thread: on Linux and the BSDs the wait for the app + /// that copied happens when the work runs, elsewhere the bitmap is read here. + fn image_later(&mut self) -> Result { + match self { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + Self::Arboard(cb) => cb.image_later(), + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] + Self::Arboard(cb) => { + let image = cb.read(Request::Image).image()?; + Ok(Box::new(move || Ok(image))) + } #[cfg(target_os = "linux")] Self::Wayland(_) => Err(TEXT_ONLY.into()), } @@ -416,7 +1046,7 @@ mod portable { fn set_image(&mut self, img: arboard::ImageData<'_>) -> Result<(), String> { match self { - Self::Arboard(cb) => cb.set_image(img).map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.cb.set_image(img).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] Self::Wayland(_) => Err(TEXT_ONLY.into()), } @@ -424,7 +1054,7 @@ mod portable { fn clear(&mut self) -> Result<(), String> { match self { - Self::Arboard(cb) => cb.clear().map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.cb.clear().map_err(|e| e.to_string()), #[cfg(target_os = "linux")] Self::Wayland(cb) => { cb.set_text(""); @@ -433,10 +1063,31 @@ mod portable { } } - /// Files copied in a file manager (none where the backend can't read a file list). - fn file_list(&mut self) -> Vec { + /// The read that got no answer in its wait, when a paste of one of `mimes` can take its + /// answer ([`Arboard::unanswered`], [`TextReads::unanswered`](super::wayland::TextReads::unanswered)). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn unanswered(&mut self, mimes: &[&'static str]) -> Option { match self { - Self::Arboard(cb) => cb.get().file_list().unwrap_or_default(), + Self::Arboard(cb) => cb.unanswered(mimes).map(Late::Arboard), + #[cfg(target_os = "linux")] + Self::Wayland(cb) => cb.unanswered(mimes).map(Late::Wayland), + } + } + + /// Drops the read that got no answer in its wait: a paste no longer takes its answer. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn forget_unanswered(&mut self) { + match self { + Self::Arboard(cb) => cb.unanswered = None, + #[cfg(target_os = "linux")] + Self::Wayland(cb) => cb.forget_unanswered(), + } + } + + /// Files copied in a file manager (none where the backend can't read a file list). + fn file_list(&mut self) -> Vec { + match self { + Self::Arboard(cb) => cb.read(Request::Files).files(), #[cfg(target_os = "linux")] Self::Wayland(_) => vec![], } @@ -560,6 +1211,38 @@ mod portable { let window = self.window.as_ref(); self.cb.get(asker, Instant::now(), || Backend::open(window), Backend::name).ok_or_else(|| "no system clipboard".to_string()) } + + /// The work that pastes the answer to a read that got no answer in its wait + /// ([`Late::paste`]), while that answer has not come. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn paste_unanswered(&mut self, mimes: &[&'static str]) -> Option { + let ours = self.ours.clone(); + let unanswered = self.cb(Asker::User).ok()?.unanswered(mimes)?; + let mimes = mimes.to_vec(); + Some(ClipboardRead::Later(Box::new(move || unanswered.paste(ours.as_ref(), &mimes)))) + } + + /// Text and copied files are read here, and work that reads arboard's image comes back. + fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option { + let cb = self.cb(Asker::User).ok()?; + if let Some(f) = copied_file(cb.file_list(), mimes) { + return Some(ClipboardRead::Now(f)); + } + mimes.iter().find_map(|m| { + #[cfg(target_os = "macos")] + if let Some(f) = super::mac::read(m) { + return Some(ClipboardRead::Now(f)); + } + match *m { + TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })), + BITMAP => { + let read = cb.image_later().ok()?; + Some(ClipboardRead::Later(Box::new(move || image_flavour(read().ok()?)))) + } + _ => None, + } + }) + } } impl SystemClipboard for Clipboard { @@ -585,6 +1268,9 @@ mod portable { None } }; + // What a read got late was on the clipboard before this write. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + cb.forget_unanswered(); self.ours = ours; Ok(()) } @@ -593,37 +1279,31 @@ mod portable { let Some(cb) = self.cb.opened() else { return false }; match &self.ours { Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t), - Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)), + Some(Ours::Image(w, h)) => cb.image_now().is_ok_and(|now| (now.width, now.height) == (*w, *h)), None => false, } } fn read(&mut self, mimes: &[&'static str]) -> Option { - self.read_deferred(mimes)?.flavour() + self.read_now_or_later(mimes)?.flavour() } - /// arboard's image is read here and encoded as PNG by the work that comes back. + /// arboard's image is read here and encoded as PNG by the work that comes back. On Linux and + /// the BSDs, after a read that got no answer in its wait (such as the check whether the + /// clipboard still holds what was copied here, or one of the reads here), the work waits for + /// that read's answer instead and pastes from it, or nothing when it is what was copied here. fn read_deferred(&mut self, mimes: &[&'static str]) -> Option { - let cb = self.cb(Asker::User).ok()?; - if let Some(f) = copied_file(cb.file_list(), mimes) { - return Some(ClipboardRead::Now(f)); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + if let Some(read) = self.paste_unanswered(mimes) { + return Some(read); } - mimes.iter().find_map(|m| { - #[cfg(target_os = "macos")] - if let Some(f) = super::mac::read(m) { - return Some(ClipboardRead::Now(f)); - } - match *m { - TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })), - BITMAP => { - let img = cb.get_image().ok()?; - let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); - let bytes = img.bytes.into_owned(); - Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?)))) - } - _ => None, - } - }) + let read = self.read_now_or_later(mimes); + // One of these reads got no answer in its wait, and the ones after it failed at once. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + if read.is_none() { + return self.paste_unanswered(mimes); + } + read } /// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no @@ -642,6 +1322,7 @@ mod portable { mod tests { use std::cell::Cell; + use super::super::loads::Loads; use super::*; /// A clock that starts now and moves only when told. @@ -763,6 +1444,270 @@ mod portable { ); } + #[test] + fn reads_return_while_the_clipboard_owner_sends_nothing() { + let (release, gate) = std::sync::mpsc::channel::<()>(); + // The app that copied never sends its data until released. + let mut reads = Loads::spawn("test", Duration::ZERO, move |request| { + let _ = gate.recv(); + Answer::failed(request, "late".into()) + }) + .unwrap(); + // Copy's look at the text, Paste's file list and its bitmap: none waits for the owner. + assert!(read_within(&mut reads, Request::Text, Duration::ZERO).text().is_err()); + assert!(read_within(&mut reads, Request::Files, Duration::from_secs(600)).files().is_empty()); + assert!(read_within(&mut reads, Request::Image, Duration::from_secs(600)).image().is_err()); + drop(release); + } + + #[test] + fn each_read_takes_its_own_answer() { + let mut reads = Loads::spawn("test", Duration::ZERO, |request| match request { + Request::Text => Answer::Text(Ok("".into())), + Request::Image => Answer::Image(Ok(arboard::ImageData { width: 2, height: 1, bytes: Cow::Owned(vec![0; 8]) })), + Request::Files => Answer::Files(vec![PathBuf::from("art.svg")]), + }) + .unwrap(); + let wait = Duration::from_secs(600); + assert_eq!(read_within(&mut reads, Request::Text, wait).text(), Ok("".to_string())); + assert_eq!(read_within(&mut reads, Request::Image, wait).image().map(|i| (i.width, i.height)), Ok((2, 1))); + assert_eq!(read_within(&mut reads, Request::Files, wait).files(), [PathBuf::from("art.svg")]); + // The answer to another read is not taken for this one. + assert_eq!(Answer::Files(vec![]).text(), Err(MIXED.to_string())); + assert!(Answer::Text(Ok(String::new())).image().is_err()); + assert!(Answer::Text(Ok("art.svg".into())).files().is_empty()); + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn arboards_reads_run_on_its_reader_and_wait_by_kind() { + let reads = Loads::spawn("test", Duration::ZERO, |request| match request { + Request::Text => Answer::Text(Ok("read on the worker".into())), + _ => Answer::failed(request, "not asked".into()), + }) + .unwrap(); + // `()` for writes: a read made through the write handle wouldn't build. + let mut cb = Arboard { reads, unanswered: None, cb: () }; + assert_eq!(cb.read(Request::Text).text(), Ok("read on the worker".to_string())); + assert_eq!(wait_for(Request::Text), super::super::loads::LOAD_WAIT); + assert_eq!(wait_for(Request::Files), super::super::loads::LOAD_WAIT); + assert_eq!(wait_for(Request::Image), Duration::from_secs(10)); + // A paste's reads after a late answer wait until 10 s after the unanswered read was + // sent, and never less than any read of their kind. + let sent = Instant::now(); + assert_eq!(follow_up_wait(Request::Text, sent, sent + Duration::from_secs(3)), Duration::from_secs(7)); + assert_eq!(follow_up_wait(Request::Files, sent, sent + Duration::from_secs(9)), super::super::loads::LOAD_WAIT); + assert_eq!(follow_up_wait(Request::Image, sent, sent + Duration::from_secs(20)), IMAGE_WAIT); + // X11 waits for an unanswered read when the clipboard closes; data-control doesn't. + assert_eq!(finish(false), X11_READ_LIMIT); + assert_eq!(finish(true), Duration::ZERO); + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn a_bitmap_paste_waits_for_the_owner_off_the_ui_thread() { + let (release, gate) = std::sync::mpsc::channel::<()>(); + // The app that copied sends its bitmap only when released. + let reads = Loads::spawn("test", Duration::ZERO, move |request| match request { + Request::Image => { + let _ = gate.recv(); + Answer::Image(Ok(arboard::ImageData { width: 2, height: 1, bytes: Cow::Owned(vec![0; 8]) })) + } + _ => Answer::failed(request, "not asked".into()), + }) + .unwrap(); + let mut cb = Arboard { reads, unanswered: None, cb: () }; + // The read is sent and the UI thread goes on without its answer. + let work = cb.image_later().unwrap(); + // A check on the UI thread meanwhile fails at once instead of waiting behind it. + assert!(cb.read(Request::Text).text().is_err()); + let paste = std::thread::spawn(work); + release.send(()).unwrap(); + assert_eq!(paste.join().ok().and_then(Result::ok).map(|i| (i.width, i.height)), Some((2, 1))); + } + + /// An arboard clipboard whose owner answers each read with `answer` only once the sender + /// returned is used or dropped. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn slow_owner(answer: impl Fn(Request) -> Answer + Send + 'static) -> (Arboard<()>, std::sync::mpsc::Sender<()>) { + let (release, gate) = std::sync::mpsc::channel::<()>(); + let reads = Loads::spawn("test", Duration::ZERO, move |request| { + let _ = gate.recv(); + answer(request) + }) + .unwrap(); + (Arboard { reads, unanswered: None, cb: () }, release) + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn a_paste_after_an_unanswered_ownership_check_waits_for_its_answer() { + let image = |width: usize| Answer::Image(Ok(arboard::ImageData { width, height: 1, bytes: Cow::Owned(vec![0; width * 4]) })); + // A 2×1 bitmap was copied here; the clipboard's owner sends a `width`×1 one late. + let paste_after_check = |width: usize| { + let (mut cb, release) = slow_owner(move |_| image(width)); + // The check whether the clipboard still holds what was copied here gets no answer + // in its wait (2 s on the UI thread, none here). + assert!(cb.read_waiting(Request::Image, Duration::ZERO).image().is_err()); + // The paste's own reads fail at once while the owner hasn't answered. + assert!(cb.read(Request::Files).files().is_empty()); + assert!(cb.read(Request::Text).text().is_err()); + assert!(cb.image_later().is_err()); + // The paste waits for the check's answer off the UI thread instead. + let unanswered = cb.unanswered(&vectorcraft_engine::cmd::clipboard::PASTE_ORDER).expect("the check's read is kept for the paste"); + let paste = std::thread::spawn(move || unanswered.paste(Some(&Ours::Image(2, 1)), &[BITMAP])); + drop(release); + paste.join().ok().flatten() + }; + // Another app's bitmap is pasted, not the art copied here. + let pasted = paste_after_check(3).expect("the new bitmap is pasted"); + assert_eq!((pasted.mime, decode_png(&pasted.data).map(|i| i.dimensions())), (PNG, Some((3, 1)))); + // The bitmap copied here: the paste takes the internal clipboard. + assert!(paste_after_check(2).is_none()); + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn a_late_answer_to_an_ownership_check_is_pasted_only_while_it_is_awaited() { + // Text was copied here; the clipboard's owner answers the check with `text` late, and + // has a 3×1 bitmap. + let paste_after_check = |text: &'static str| { + let (mut cb, release) = slow_owner(move |request| match request { + Request::Text => Answer::Text(Ok(text.into())), + Request::Image => Answer::Image(Ok(arboard::ImageData { width: 3, height: 1, bytes: Cow::Owned(vec![0; 12]) })), + Request::Files => Answer::Files(vec![]), + }); + assert!(cb.read_waiting(Request::Text, Duration::ZERO).text().is_err()); + // A paste of a bitmap alone can't take a text read's answer: the read stays kept. + assert!(cb.unanswered(&[BITMAP]).is_none()); + let unanswered = cb.unanswered(&vectorcraft_engine::cmd::clipboard::PASTE_ORDER).expect("the check's read is kept for the paste"); + let ours = Ours::Text("copied here".into()); + let paste = std::thread::spawn(move || unanswered.paste(Some(&ours), &vectorcraft_engine::cmd::clipboard::PASTE_ORDER)); + drop(release); + paste.join().ok().flatten() + }; + let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted"); + assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec())); + // Text that only names a picture goes with it: the picture is pasted, as at once. + let pasted = paste_after_check("").expect("the picture is pasted"); + assert_eq!((pasted.mime, decode_png(&pasted.data).map(|i| i.dimensions())), (PNG, Some((3, 1)))); + // The text copied here: the paste takes the internal clipboard. + assert!(paste_after_check("copied here").is_none()); + + // A paste's own file-list read that gets no answer: once it comes, the text is read. + let (mut cb, release) = slow_owner(|request| match request { + Request::Text => Answer::Text(Ok("late text".into())), + _ => Answer::failed(request, "none".into()), + }); + assert!(cb.read_waiting(Request::Files, Duration::ZERO).files().is_empty()); + let unanswered = cb.unanswered(&[TEXT]).expect("the file-list read is kept for the paste"); + let paste = std::thread::spawn(move || unanswered.paste(None, &[TEXT])); + drop(release); + assert_eq!(paste.join().ok().flatten().map(|f| f.data), Some(b"late text".to_vec())); + + // A paste of text alone pastes text that names a picture, not the picture. + let (mut cb, release) = slow_owner(|request| match request { + Request::Text => Answer::Text(Ok("".into())), + Request::Image => Answer::Image(Ok(arboard::ImageData { width: 3, height: 1, bytes: Cow::Owned(vec![0; 12]) })), + Request::Files => Answer::Files(vec![]), + }); + assert!(cb.read_waiting(Request::Text, Duration::ZERO).text().is_err()); + let unanswered = cb.unanswered(&[TEXT]).expect("the text read is kept for the paste"); + let paste = std::thread::spawn(move || unanswered.paste(None, &[TEXT])); + drop(release); + let pasted = paste.join().ok().flatten().expect("the text is pasted"); + assert_eq!((pasted.mime, pasted.data), (TEXT, b"".to_vec())); + + // The app that answers late may have lost the clipboard meanwhile: the paste reads it + // again and takes the new owner's text. + let texts_read = std::sync::atomic::AtomicUsize::new(0); + let (mut cb, release) = slow_owner(move |request| match request { + Request::Text if texts_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 => Answer::Text(Ok("old owner".into())), + Request::Text => Answer::Text(Ok("new owner".into())), + _ => Answer::failed(request, "none".into()), + }); + assert!(cb.read_waiting(Request::Text, Duration::ZERO).text().is_err()); + let unanswered = cb.unanswered(&[TEXT]).expect("the text read is kept for the paste"); + let paste = std::thread::spawn(move || unanswered.paste(None, &[TEXT])); + drop(release); + assert_eq!(paste.join().ok().flatten().map(|f| f.data), Some(b"new owner".to_vec())); + + // An answer that came before the paste may be old: it is dropped, not pasted. + let (mut cb, release) = slow_owner(|request| Answer::failed(request, "not asked".into())); + assert!(cb.read_waiting(Request::Text, Duration::ZERO).text().is_err()); + drop(release); + while cb.reads.waiting() { + std::thread::yield_now(); + } + assert!(cb.unanswered(&[TEXT]).is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_paste_after_an_unanswered_core_protocol_text_read_waits_for_its_answer() { + use super::super::wayland::TextReads; + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + + /// Core protocol text reads whose owner answers with `text` only once the sender + /// returned is used or dropped. + fn slow_text(text: &'static str) -> (TextReads, std::sync::mpsc::Sender<()>) { + let (release, gate) = std::sync::mpsc::channel::<()>(); + let loads = Loads::spawn("test", Duration::ZERO, move |()| { + let _ = gate.recv(); + Ok(text.to_string()) + }) + .unwrap(); + (TextReads::new(loads), release) + } + // Text was copied here; the clipboard's owner answers the check with `text` late. + let paste_after_check = |text: &'static str| { + let (mut reads, release) = slow_text(text); + // The check whether the clipboard still holds what was copied here gets no answer + // in its wait (2 s on the UI thread, none here). + assert!(reads.get(Duration::ZERO).is_err()); + // The paste's own read fails at once while the owner hasn't answered. + assert!(reads.get(Duration::from_secs(600)).is_err()); + // A paste of a bitmap alone can't take text: the read stays kept. + assert!(reads.unanswered(&[BITMAP]).is_none()); + let late = Late::Wayland(reads.unanswered(&PASTE_ORDER).expect("the check's read is kept for the paste")); + let ours = Ours::Text("copied here".into()); + let paste = std::thread::spawn(move || late.paste(Some(&ours), &PASTE_ORDER)); + drop(release); + paste.join().ok().flatten() + }; + let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted"); + assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec())); + // The text copied here, or nothing: the paste takes the internal clipboard. + assert!(paste_after_check("copied here").is_none()); + assert!(paste_after_check("").is_none()); + + // An answer that came before the paste may be old: it is dropped, not pasted. + let (mut reads, release) = slow_text("old"); + assert!(reads.get(Duration::ZERO).is_err()); + drop(release); + while reads.waiting() { + std::thread::yield_now(); + } + assert!(reads.unanswered(&[TEXT]).is_none()); + + // The app that answers late may have lost the clipboard meanwhile: the paste reads it + // again and takes the new owner's text. + let (release, gate) = std::sync::mpsc::channel::<()>(); + let mut texts_read = 0; + let loads = Loads::spawn("test", Duration::ZERO, move |()| { + let _ = gate.recv(); + texts_read += 1; + Ok(if texts_read == 1 { "old owner" } else { "new owner" }.to_string()) + }) + .unwrap(); + let mut reads = TextReads::new(loads); + assert!(reads.get(Duration::ZERO).is_err()); + let late = Late::Wayland(reads.unanswered(&[TEXT]).expect("the read is kept for the paste")); + let paste = std::thread::spawn(move || late.paste(None, &[TEXT])); + drop(release); + assert_eq!(paste.join().ok().flatten().map(|f| f.data), Some(b"new owner".to_vec())); + } + #[test] fn one_outage_is_logged_once_across_handles() { static LOGGED: AtomicBool = AtomicBool::new(false); @@ -783,27 +1728,93 @@ mod portable { /// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on /// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the /// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's -/// data with no limit, so reads run on a worker of their own. +/// data with no limit, so reads run on a worker of their own. A read that gets no answer in that +/// time is kept for a paste to wait for off the UI thread, as arboard's are. #[cfg(target_os = "linux")] mod wayland { - use std::sync::Arc; - use std::sync::mpsc; - use std::time::Duration; + use std::sync::{Arc, mpsc}; + use std::time::{Duration, Instant}; - /// The longest a read waits for the selection's owner to send its text. - const LOAD_WAIT: Duration = Duration::from_secs(2); - /// What a read says once its worker has stopped. - const GONE: &str = "the clipboard's reader stopped"; + use super::TEXT; + use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer}; + + /// What a text read gives. + type Text = Result; pub struct TextClipboard { /// Writes: a store only hands the text to smithay-clipboard's thread. cb: smithay_clipboard::Clipboard, /// Reads, on a worker with a second clipboard on the same connection. - loads: Loads, + reads: TextReads, /// Held as long as the clipboards: their connection is the window's. _window: Arc, } + /// Text reads on a worker, and the last one that got no answer in its wait. + pub(super) struct TextReads { + loads: Loads<(), Text>, + /// The last read that got no answer in its wait, until a paste takes it or the next read + /// is sent. + unanswered: Option, + } + + /// A text read that got no answer in its wait. + pub(super) struct Unanswered { + answer: mpsc::Receiver, + sent: Instant, + /// The worker, for the read made again once the answer comes. + reads: Asker<(), Text>, + } + + impl Unanswered { + /// The text, read again once this read's answer comes, waiting for that answer until + /// `limit` after the read was sent, and for the new one as long, [`LOAD_WAIT`] at least. + /// The late answer may come from an app that lost the clipboard while it was awaited. + pub(super) fn text(self, limit: Duration) -> Text { + let left = |now: Instant| (self.sent + limit).saturating_duration_since(now); + let _late = wait_for_answer(&self.answer, left(Instant::now()))?; + wait_for_answer(&self.reads.ask(())?, LOAD_WAIT.max(left(Instant::now())))? + } + } + + impl TextReads { + pub(super) fn new(loads: Loads<(), Text>) -> Self { + Self { loads, unanswered: None } + } + + /// The text, waiting `wait` at most. A read that gets no answer in that time is kept + /// ([`Self::unanswered`]) for a paste to wait for off the UI thread; a read sent means the + /// one kept has been answered, and its answer is dropped. + pub(super) fn get(&mut self, wait: Duration) -> Text { + let sent = Instant::now(); + let answer = self.loads.ask(())?; + self.unanswered = None; + match wait_for_answer(&answer, wait) { + Ok(text) => text, + Err(why) => { + if why != GONE { + self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker() }); + } + Err(why) + } + } + } + + /// The read that got no answer in its wait, while its answer has still not come and a + /// paste of one of `mimes` can take text. Once it has come the answer may be old, and it is + /// dropped: a new read is made. + pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + let unanswered = self.unanswered.take_if(|_| mimes.contains(&TEXT))?; + self.loads.waiting().then_some(unanswered) + } + + /// Whether a read sent is still unanswered. + #[cfg(test)] + pub(super) fn waiting(&self) -> bool { + self.loads.waiting() + } + } + /// The read worker's clipboard and the window whose connection it uses, dropped in that order. struct Loader { cb: smithay_clipboard::Clipboard, @@ -830,113 +1841,29 @@ mod wayland { let loader = Loader { cb: reader, _window: window.clone() }; // `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names, // and the worker must hold the window as long as its clipboard. - let loads = Loads::spawn(move || loader.load())?; - Some(Self { cb, loads, _window: window }) + // Dropping doesn't wait for an unanswered read: only arboard's X11 handles hand what the + // app copied on when the last one goes. + let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |()| loader.load())?; + Some(Self { cb, reads: TextReads::new(loads), _window: window }) } pub fn get_text(&mut self) -> Result { - self.loads.get(LOAD_WAIT) + self.reads.get(LOAD_WAIT) } pub fn set_text(&self, text: &str) { self.cb.store(text); } - } - /// Reads made on a worker thread and waited for a bounded time: a selection owner that never - /// sends its data holds the worker, not the caller. - struct Loads { - requests: mpsc::Sender<()>, - answers: mpsc::Receiver>, - /// A read that timed out and hasn't answered yet: until it does, reads don't queue behind it. - pending: bool, - } - - impl Loads { - fn spawn(mut load: impl FnMut() -> Result + Send + 'static) -> Option { - let (requests, asked) = mpsc::channel::<()>(); - let (answer, answers) = mpsc::channel(); - let work = move || { - while asked.recv().is_ok() { - if answer.send(load()).is_err() { - break; - } - } - }; - std::thread::Builder::new().name("wayland-clipboard-read".into()).spawn(work).ok()?; - Some(Self { requests, answers, pending: false }) + /// The read that got no answer in its wait, when a paste of one of `mimes` can take its + /// answer ([`TextReads::unanswered`]). + pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + self.reads.unanswered(mimes) } - /// The text, or why there is none, after `wait` at most. - fn get(&mut self, wait: Duration) -> Result { - if self.pending { - match self.answers.try_recv() { - // The late answer of the read that timed out: stale by now. - Ok(_) => self.pending = false, - Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()), - Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()), - } - } - self.requests.send(()).map_err(|_| GONE.to_string())?; - match self.answers.recv_timeout(wait) { - Ok(text) => text, - Err(mpsc::RecvTimeoutError::Timeout) => { - self.pending = true; - Err(format!("the clipboard's owner didn't send its text within {wait:?}")) - } - Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), - } - } - } - - #[cfg(test)] - mod tests { - use std::time::Instant; - - use super::*; - - #[test] - fn a_read_that_answers_in_time_gives_the_text() { - let mut loads = Loads::spawn(|| Ok("".into())).unwrap(); - assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); - assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); - } - - #[test] - fn an_owner_that_never_answers_holds_the_worker_not_the_caller() { - let (release, gate) = mpsc::channel::<()>(); - let mut calls = 0; - let mut loads = Loads::spawn(move || { - calls += 1; - if calls == 1 { - // The first owner sends nothing until released. - let _ = gate.recv(); - return Ok("stale".into()); - } - Ok("fresh".into()) - }) - .unwrap(); - let start = Instant::now(); - assert!(loads.get(Duration::from_millis(50)).is_err(), "the first read times out"); - // While it is unanswered, a read doesn't queue behind it or wait at all. - assert!(loads.get(Duration::from_secs(60)).is_err()); - assert!(start.elapsed() < Duration::from_secs(30), "no read waited past its bound"); - release.send(()).unwrap(); - // Once the late answer is in, it is dropped and a new read is made. - let text = (0..200).find_map(|_| { - std::thread::sleep(Duration::from_millis(10)); - loads.get(Duration::from_secs(5)).ok() - }); - assert_eq!(text.as_deref(), Some("fresh")); - } - - #[test] - fn a_read_after_the_worker_stops_says_so() { - let mut loads = Loads::spawn(|| Ok(String::new())).unwrap(); - // The worker ends when its answers can't be sent: drop them by hand. - let (_, dead) = mpsc::channel(); - drop(std::mem::replace(&mut loads.answers, dead)); - assert_eq!(loads.get(Duration::from_millis(50)), Err(GONE.to_string())); + /// Drops the read that got no answer in its wait: a paste no longer takes its answer. + pub fn forget_unanswered(&mut self) { + self.reads.unanswered = None; } } } diff --git a/apps/vectorcraft/src/mac_menu.rs b/apps/vectorcraft/src/mac_menu.rs index f90e4f7304..53b610a210 100644 --- a/apps/vectorcraft/src/mac_menu.rs +++ b/apps/vectorcraft/src/mac_menu.rs @@ -30,6 +30,9 @@ use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, // AppKit can deliver window input while its system menu is still tracking (especially with // asynchronous menu presentation). Keep the native tracking state separate from egui popups. static TRACKING: AtomicBool = AtomicBool::new(false); +/// A tracking session began since the last [`Backend::drain`], which reports it as +/// [`Event::Opened`]. +static OPENED: AtomicBool = AtomicBool::new(false); pub(crate) fn is_tracking() -> bool { TRACKING.load(Ordering::Relaxed) @@ -46,9 +49,11 @@ fn observe_tracking(ctx: &egui::Context) { let block = RcBlock::new(move |_: std::ptr::NonNull| { TRACKING.store(tracking, Ordering::Relaxed); if tracking { + OPENED.store(true, Ordering::Relaxed); super::mac_cursor::release(); } - // Apply any deferred menu updates as soon as the tracking session ends. + // Report the opened menu and apply any deferred menu updates as soon as the + // tracking session ends. if !tracking { repaint.request_repaint(); } @@ -267,6 +272,9 @@ impl Backend for MacMenu { fn drain(&mut self) -> Vec { let mut out = Vec::new(); + if OPENED.swap(false, Ordering::Relaxed) { + out.push(Event::Opened); + } while let Ok(chosen) = self.rx.try_recv() { cancel_tracking(); // Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled. diff --git a/apps/vectorcraft/tests/it/automation_args.rs b/apps/vectorcraft/tests/it/automation_args.rs index 93b33fa16d..4a1e6f524c 100644 --- a/apps/vectorcraft/tests/it/automation_args.rs +++ b/apps/vectorcraft/tests/it/automation_args.rs @@ -1,8 +1,6 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft"); - /// Invalid roots must exit before desktop initialization on every platform, including a CI /// machine with no display. A regression must fail rather than hang with a window open. fn rejects(args: &[&str], message: &str) { @@ -10,7 +8,7 @@ fn rejects(args: &[&str], message: &str) { } fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(args) .env_remove("VECTORCRAFT_CONTROL_PORT") .env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT") @@ -62,7 +60,7 @@ fn missing_empty_or_option_operands_fail_before_startup() { #[test] fn repeated_roots_are_accepted_for_both_spellings() { for flag in ["--automation-read-root", "--automation-write-root"] { - let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap(); + let out = Command::new(crate::bin()).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap(); assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); assert!(out.stderr.is_empty()); @@ -73,7 +71,8 @@ fn repeated_roots_are_accepted_for_both_spellings() { #[test] fn explicit_dash_folder_does_not_consume_version() { - let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap(); + let out = + Command::new(crate::bin()).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); assert!(out.stderr.is_empty()); @@ -95,7 +94,7 @@ fn control_without_a_valid_port_fails_before_startup() { #[test] fn version_wins_over_a_bad_control_port_variable() { - let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap(); + let out = Command::new(crate::bin()).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); } diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index ece66b98ab..d82475b9ff 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -6,12 +6,10 @@ use std::process::{Command, Output, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft"); - /// Runs the desktop binary without a display and with a control port it cannot parse, so a /// regression exits with that error instead of opening a window. fn run(args: &[&str], env: &[(&str, &str)]) -> Output { - let mut child = Command::new(BIN) + let mut child = Command::new(crate::bin()) .args(args) .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") diff --git a/apps/vectorcraft/tests/it/main.rs b/apps/vectorcraft/tests/it/main.rs index d9dcf39cbb..658f4d49f6 100644 --- a/apps/vectorcraft/tests/it/main.rs +++ b/apps/vectorcraft/tests/it/main.rs @@ -4,3 +4,11 @@ mod automation_args; mod help; + +/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner. +fn bin() -> std::path::PathBuf { + std::env::var_os("NEXTEST_BIN_EXE_vectorcraft") + .or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft")) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft").into()) +} diff --git a/assets/icons/dc-grad-conic.svg b/assets/icons/dc-grad-conic.svg new file mode 100644 index 0000000000..f6e49150be --- /dev/null +++ b/assets/icons/dc-grad-conic.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-grad-diamond.svg b/assets/icons/dc-grad-diamond.svg new file mode 100644 index 0000000000..2efeca27e9 --- /dev/null +++ b/assets/icons/dc-grad-diamond.svg @@ -0,0 +1 @@ + diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml index c335c7b16c..798b0734f5 100644 --- a/crates/affinity/Cargo.toml +++ b/crates/affinity/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vectorcraft-affinity" -description = "Bounded reader for native Affinity documents: archive, object stream and document model" +description = "craft-affinity under its old name, for the corpus tests and fuzz targets" version.workspace = true edition.workspace = true license.workspace = true @@ -11,13 +11,13 @@ workspace = true [features] # Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter. -synth = [] +synth = ["craft-affinity/synth"] [dependencies] -crc32fast = { workspace = true } -flate2 = { workspace = true } -image = { workspace = true } -ruzstd = "0.9.1" +craft-affinity = { workspace = true } [dev-dependencies] proptest = { workspace = true } +# The tests build containers and decode previews. +crc32fast = { workspace = true } +image = { workspace = true } diff --git a/crates/affinity/README.md b/crates/affinity/README.md index fd2d6dc71a..38c096cbcc 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -1,5 +1,7 @@ # vectorcraft-affinity +The reader is [`craft-affinity`](https://github.com/storytold/craft-libs/tree/main/crates/affinity) in craft-libs; this crate re-exports it under its old name for the corpus tests and fuzz targets. The engine maps its document onto VectorCraft art (`crates/engine/src/cmd/fileio/affinity`), as described below. + An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and `.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads the archive, the tagged object stream in `doc.dat` and the part of the document model listed below, @@ -35,7 +37,7 @@ Affinity files. Everything else is reported in the import warnings, one line per kind with a count, never dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold, -transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial), +transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (the active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs deleted file mode 100644 index 0b60ed7dbd..0000000000 --- a/crates/affinity/src/access.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has -//! another type, so the document mapping treats odd data as absent rather than failing. - -use crate::stream::{ObjId, Object, Stream, Tag, Value}; - -impl Stream { - pub fn object(&self, id: ObjId) -> Option<&Object> { - self.objects.get(id) - } - - pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> { - self.object(id)?.get(Tag::of(tag)) - } - - pub fn class(&self, id: ObjId) -> Option { - self.object(id).map(|o| o.class) - } - - pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool { - self.class(id) == Some(Tag::of(class)) - } - - /// A child object (shared, linked or inline); `None` for null or missing. - pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Obj(o) => *o, - _ => None, - } - } - - /// An array of objects (nulls skipped). - pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec { - match self.field(id, tag) { - Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(), - _ => Vec::new(), - } - } - - pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Float(f) if f.is_finite() => Some(*f), - _ => None, - } - } - - pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Int(i) => Some(*i), - Value::UInt(u) => i64::try_from(*u).ok(), - _ => None, - } - } - - pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Bool(b) => Some(*b), - _ => None, - } - } - - pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { - match self.field(id, tag)? { - Value::Str(s) => Some(s), - _ => None, - } - } - - pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> { - match self.field(id, tag)? { - Value::Enum { id, version } => Some((*id, *version)), - _ => None, - } - } - - /// A fixed vector of `N` finite floats (points, rectangles, transforms). - pub fn floats(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> { - match self.field(id, tag)? { - Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => { - let mut a = [0.0; N]; - a.copy_from_slice(v); - Some(a) - } - _ => None, - } - } - - pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> { - match self.field(id, tag)? { - Value::Bytes(b) | Value::Blob(b) => Some(b), - _ => None, - } - } - - pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { - match self.field(id, tag)? { - Value::Entry(s) => Some(s), - _ => None, - } - } - - /// Untagged fields of a positional object, in order. - pub fn positional(&self, id: ObjId) -> impl Iterator { - self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v)) - } -} diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs deleted file mode 100644 index f26948e919..0000000000 --- a/crates/affinity/src/container.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! The Affinity archive: header, the chain of file allocation tables (one per save) and -//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles. - -use std::collections::{HashMap, HashSet}; -use std::io::Read; - -use crate::Error; - -pub const MAGIC: &[u8; 4] = b"\x00\xffKA"; -/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8, -/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists. -pub const VERSIONS: std::ops::RangeInclusive = 8..=12; -/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic. -const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; - -/// A save appends one table; real files have one to a few hundred. -const MAX_TABLES: usize = 4096; -/// Allocation records read over the whole table chain. Each save lists every entry again, so a -/// document with a few hundred saves of a few thousand entries stays far below; tables whose -/// record regions overlap (or a table chain of thousands of huge tables) can't multiply the work. -const MAX_INDEX_RECORDS: usize = 1 << 22; -/// Bytes of entry names kept by the index (after lossy UTF-8 decoding, which can triple a name). -/// Real names (`doc.dat`, `d/12`) are a few bytes each. -const MAX_INDEX_NAME_BYTES: usize = 16 << 20; -/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. -const MAX_ZSTD_WINDOW: u64 = 64 << 20; - -/// Decompression budget for one import. Every entry declares its size, which is checked against -/// these limits before anything is allocated, and the decoded length must match it exactly. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Limits { - /// Largest single entry (`doc.dat` or one tile). - pub max_entry: usize, - /// Total bytes extracted over the whole import. - pub max_total: usize, -} - -impl Default for Limits { - fn default() -> Self { - Self { max_entry: 256 << 20, max_total: 1 << 30 } - } -} - -/// The container header, all integers little-endian. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Header { - pub version: u16, - /// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected. - pub flags: u16, - pub(crate) fat_offset: u64, - pub(crate) thumbnail_offset: u64, - /// Save time in Unix seconds, as written by the application. - pub saved: u32, -} - -/// One FAT record, as of the newest table that mentions its id. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Entry { - pub id: u32, - pub name: String, - offset: u64, - size: u64, - compressed: u64, - crc: u32, - compression: u8, -} - -impl Entry { - /// Decompressed size declared by the table. - pub fn size(&self) -> u64 { - self.size - } -} - -#[derive(Debug)] -pub struct Archive<'a> { - bytes: &'a [u8], - pub header: Header, - /// Head revision of every named, non-deleted entry. - entries: HashMap, - /// Number of tables (saves) in the chain. - pub revisions: usize, - extracted: usize, - limits: Limits, - /// Entries whose extraction failed after their size was charged: asked again, they fail - /// again without being decompressed again. - failed: HashMap, -} - -pub(crate) struct Cursor<'a> { - bytes: &'a [u8], - pub(crate) pos: usize, -} - -impl<'a> Cursor<'a> { - pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self { - Self { bytes, pos } - } - pub(crate) fn remaining(&self) -> usize { - self.bytes.len().saturating_sub(self.pos) - } - pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { - let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?; - let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?; - self.pos = end; - Ok(s) - } - pub(crate) fn array(&mut self) -> Result<[u8; N], Error> { - let s = self.take(N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - pub(crate) fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub(crate) fn u16(&mut self) -> Result { - Ok(u16::from_le_bytes(self.array()?)) - } - pub(crate) fn u32(&mut self) -> Result { - Ok(u32::from_le_bytes(self.array()?)) - } - pub(crate) fn u64(&mut self) -> Result { - Ok(u64::from_le_bytes(self.array()?)) - } -} - -fn offset(v: u64) -> Result { - usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory")) -} - -/// True when `bytes` start with the Affinity container signature (documents and libraries). -pub fn is_affinity(bytes: &[u8]) -> bool { - bytes.starts_with(MAGIC) -} - -/// Check the signature, version and document kind. -pub fn header(bytes: &[u8]) -> Result { - if !is_affinity(bytes) { - return Err(Error::Malformed("missing container signature")); - } - let mut c = Cursor::new(bytes, 4); - let version = c.u16()?; - let flags = c.u16()?; - if !VERSIONS.contains(&version) { - return Err(Error::Unsupported("unknown container version")); - } - if flags & 3 != 0 { - return Err(Error::Unsupported("container variant")); - } - if c.take(4)? != DOCUMENT_KIND { - return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document")); - } - if c.take(4)? != b"#Inf" { - return Err(Error::Malformed("missing information header")); - } - let fat_offset = c.u64()?; - let thumbnail_offset = c.u64()?; - c.take(16)?; - let saved = u32::try_from(c.u64()?).unwrap_or(0); - c.take(8)?; - if c.take(4)? != b"Prot" { - return Err(Error::Malformed("missing protocol header")); - } - Ok(Header { version, flags, fat_offset, thumbnail_offset, saved }) -} - -impl<'a> Archive<'a> { - pub fn open(bytes: &'a [u8], limits: Limits) -> Result { - Self::open_within(bytes, limits, MAX_INDEX_RECORDS, MAX_INDEX_NAME_BYTES) - } - - /// [`Archive::open`] reading at most `max_records` allocation records and keeping at most - /// `max_name_bytes` bytes of entry names, both over the whole table chain. - pub(crate) fn open_within(bytes: &'a [u8], limits: Limits, max_records: usize, max_name_bytes: usize) -> Result { - let header = header(bytes)?; - let mut records_left = max_records; - let mut name_bytes_left = max_name_bytes; - // Newest record per id: (table date, table index, record). - let mut latest: HashMap)> = HashMap::new(); - let mut names: HashMap = HashMap::new(); - let mut seen = HashSet::new(); - let mut next = header.fat_offset; - let mut tables = 0usize; - while next != 0 { - if !seen.insert(next) { - return Err(Error::Malformed("allocation tables form a cycle")); - } - tables += 1; - if tables > MAX_TABLES { - return Err(Error::Limit("too many saved revisions")); - } - let mut c = Cursor::new(bytes, offset(next)?); - let tag = c.array::<4>()?; - let level = match &tag { - b"#FAT" => 1, - b"#FT2" => 2, - b"#FT3" => 3, - b"#FT4" => 4, - _ => return Err(Error::Malformed("missing allocation table")), - }; - next = c.u64()?; - let date = c.u64()?; - c.take(24)?; - let files = c.u32()?; - c.take(8)?; - let dirs = c.u16()?; - c.take(1)?; - for _ in 0..files { - records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; - let id = c.u32()?; - let flag = c.u8()?; - let mut record = None; - match flag { - 0 | 1 => { - let offset = c.u64()?; - let size = c.u64()?; - let compressed = c.u64()?; - let crc = c.u32()?; - let stored = c.u8()?; - if level >= 2 { - c.take(4)?; - } - if level >= 4 { - c.take(4)?; - } - // Old tables number the methods; newer ones store the method byte itself. - let compression = if level <= 2 { - match stored { - 1 => 0x01, - 2 => 0x41, - 3 => 0x81, - 4 => 0xC1, - _ => 0, - } - } else { - stored - }; - record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression }); - } - 2 => {} - _ => return Err(Error::Malformed("unknown allocation record")), - } - if flag == 0 { - let len = usize::from(c.u16()?); - let raw = c.take(len)?; - // Only the first name of an id is kept: later ones are not even decoded. - if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) { - let name = String::from_utf8_lossy(raw); - name_bytes_left = name_bytes_left.checked_sub(name.len()).ok_or(Error::Limit("archive entry names too long"))?; - slot.insert(name.into_owned()); - } - } - // Equal dates: the first table in the chain (the newest) wins, as in afread. - let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); - if replace { - latest.insert(id, (date, tables, record)); - } - } - for _ in 0..dirs { - records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; - let len = usize::from(c.u16()?); - c.take(10)?; - c.take(len)?; - } - } - let mut entries = HashMap::new(); - for (id, (_, _, record)) in latest { - if let (Some(mut e), Some(name)) = (record, names.remove(&id)) { - e.name = name; - entries.insert(e.name.clone(), e); - } - } - Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits, failed: HashMap::new() }) - } - - pub fn entry(&self, name: &str) -> Option<&Entry> { - self.entries.get(name) - } - - pub fn entry_names(&self) -> impl Iterator { - self.entries.keys().map(String::as_str) - } - - /// Bytes extracted so far, counted against [`Limits::max_total`]. - pub fn extracted(&self) -> usize { - self.extracted - } - - /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. - /// What is left of the limits for documents embedded in this one. - pub(crate) fn remaining(&self) -> Limits { - Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) } - } - - /// Count `n` bytes extracted elsewhere (from embedded documents) against this import. - pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> { - let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?; - if total > self.limits.max_total { - return Err(Error::Limit("document too large")); - } - self.extracted = total; - Ok(()) - } - - pub fn read(&mut self, name: &str) -> Result, Error> { - if let Some(e) = self.failed.get(name) { - return Err(*e); - } - let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?; - let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; - if size > self.limits.max_entry { - return Err(Error::Limit("archive entry too large")); - } - let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?; - if total > self.limits.max_total { - return Err(Error::Limit("document too large")); - } - // Charged before the work, and kept when it fails: an entry that fails its checksum - // can't be decompressed over and over for free. - self.extracted = total; - let result = extract(self.bytes, e, size); - if let Err(err) = result { - self.failed.insert(name.to_owned(), err); - } - result - } -} - -/// Decompress entry `e` of `bytes` (declared `size` bytes), undo its predictor and check its CRC. -fn extract(bytes: &[u8], e: &Entry, size: usize) -> Result, Error> { - let mut c = Cursor::new(bytes, offset(e.offset)?); - if c.take(4)? != b"#Fil" { - return Err(Error::Malformed("archive entry has no data block")); - } - let method = e.compression & 3; - let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; - let data = c.take(offset(stored_len)?)?; - let mut out = match method { - 1 => inflate(data, size)?, - 2 => unzstd(data, size)?, - _ => data.to_vec(), - }; - if out.len() != size { - return Err(Error::Malformed("archive entry size mismatch")); - } - unpredict(&mut out, e.compression)?; - if crc32fast::hash(&out) != e.crc { - return Err(Error::Malformed("archive entry checksum mismatch")); - } - Ok(out) -} - -/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught -/// without unbounded allocation. -fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { - let mut out = Vec::with_capacity(size.min(64 << 20)); - let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1); - r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?; - Ok(out) -} - -fn inflate(data: &[u8], size: usize) -> Result, Error> { - read_bounded(flate2::read::ZlibDecoder::new(data), size) -} - -fn unzstd(data: &[u8], size: usize) -> Result, Error> { - let mut src = data; - let mut out = Vec::new(); - // Entries may hold several concatenated frames. - while !src.is_empty() { - let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?; - let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW) - .map_err(|_| Error::Malformed("corrupt compressed entry"))?; - let frame = read_bounded(decoder, left)?; - if frame.is_empty() { - return Err(Error::Malformed("corrupt compressed entry")); - } - out.extend_from_slice(&frame); - if out.len() > size { - return Err(Error::Malformed("archive entry size mismatch")); - } - } - Ok(out) -} - -/// Undo the byte predictor selected by the high bits of the compression byte. -fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> { - match (compression & 0xC0, compression & 0x20 != 0) { - (0x00, _) => {} - (0x40, _) => byte_delta(b), - (0x80, false) => { - let mut prev = 0u16; - for pair in b.as_chunks_mut::<2>().0.iter_mut() { - let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev); - pair.copy_from_slice(&v.to_le_bytes()); - prev = v; - } - } - (0x80, true) => { - byte_delta(b); - // A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes. - if b.len() == 0x10000 { - let planes = b.to_vec(); - let (hi, lo) = planes.split_at(0x8000); - for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else { - return Err(Error::Malformed("tile size")); - }; - out.copy_from_slice(&[l[0], h[0], l[1], h[1]]); - } - } - } - _ => return Err(Error::Unsupported("archive entry predictor")), - } - Ok(()) -} - -fn byte_delta(b: &mut [u8]) { - let mut prev = 0u8; - for v in b { - *v = v.wrapping_add(prev); - prev = *v; - } -} diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs deleted file mode 100644 index bb1c45746b..0000000000 --- a/crates/affinity/src/geometry.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in -//! node space. - -use crate::model::{Affine, Path, Point, Reader, SubPath}; -use crate::stream::{ObjId, Tag, Value}; - -/// Nodes per curve object. -const MAX_NODES: usize = 4_000_000; -/// A shared-object stream is bounded, but a compound graph can expand exponentially. Count -/// each visit and each curve record / generated contour / transformed segment across the whole -/// outline, including repeated references. Match the document reader's nesting limit. -const MAX_OUTLINE_DEPTH: usize = 128; -const MAX_OUTLINE_VISITS: usize = 100_000; - -struct OutlineBudget { - active: Vec, - visits: usize, - work: usize, - failed: bool, -} - -impl OutlineBudget { - fn fail(&mut self, r: &mut Reader, warning: &'static str) -> Option<()> { - if !self.failed { - r.warn(warning); - } - self.failed = true; - None - } - - fn spend(&mut self, r: &mut Reader, work: usize) -> Option<()> { - if work > self.work { - return self.fail(r, "compound outlines exceeding the geometry work limit"); - } - self.work -= work; - Some(()) - } - - fn path(&mut self, r: &mut Reader, path: &Path) -> Option<()> { - for subpath in &path.subpaths { - self.spend(r, 1)?; - self.spend(r, subpath.segments.len())?; - } - Some(()) - } -} - -/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd. -/// Instances (master-page or symbol copies) take the geometry of their master. -pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { - let mut budget = OutlineBudget { active: Vec::new(), visits: MAX_OUTLINE_VISITS, work: MAX_NODES, failed: false }; - bounded_outline(r, id, class, world, &mut budget) -} - -fn bounded_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> { - if budget.active.contains(&id) { - budget.fail(r, "a compound outline that contains itself")?; - } - if budget.active.len() >= MAX_OUTLINE_DEPTH { - budget.fail(r, "compound outlines nested deeper than 128 levels")?; - } - if budget.visits == 0 { - budget.fail(r, "compound outlines exceeding the operand visit limit")?; - } - budget.visits -= 1; - budget.active.push(id); - let result = outline_inner(r, id, class, world, budget); - // The popped value is our own stack marker, and is no longer needed. - let _ = budget.active.pop(); - result -} - -fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> { - let s = r.s; - match &class.0.to_be_bytes() { - b"PCrv" | b"TxtC" => { - let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?; - let path = curves(r, crvs, budget)?; - budget.path(r, &path)?; - let multi = path.subpaths.len() > 1; - Some((path, multi)) - } - b"Comp" => { - // One path of all the operands' contours in the compound's space, filled even-odd: this - // matches add and subtract operands in every public sample; a true union of overlapping - // added operands would differ. - let mut path = Path::default(); - for c in s.objs(id, b"Chld") { - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let class = s.class(c)?; - if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { - r.warn("compound shapes that intersect or exclude (imported as add)"); - } - let child_world = local.then(world); - if !child_world.is_finite() { - budget.fail(r, "a compound operand with an invalid transform")?; - } - if let Some((mut p, _)) = bounded_outline(r, c, class, child_world, budget) { - // Charge before transforming or extending. Transform the owned path in place - // so nested compounds do not make an additional copy of every contour. - budget.path(r, &p)?; - for subpath in &mut p.subpaths { - subpath.start = local.apply(subpath.start); - for segment in &mut subpath.segments { - *segment = segment.map(|point| local.apply(point)); - } - } - path.subpaths.extend(p.subpaths); - } else if budget.failed { - // Never present a partial compound as complete after a cycle or budget limit. - return None; - } - } - Some((path, true)) - } - _ => { - let (shape, box_owner) = match s.obj(id, b"Shpe") { - Some(sh) => (sh, id), - None => { - let master = s.obj(id, b"ShpM")?; - (s.obj(master, b"Shpe")?, master) - } - }; - let b = s.floats::<4>(box_owner, b"ShpB")?; - let path = crate::shapes::shape(r, shape, b, world)?; - budget.path(r, &path)?; - Some((path, false)) - } - } -} - -/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`. -/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the -/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first -/// anchor at the end. `CnrD` live corners are rounded on the stored polyline. -fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option { - let s = r.s; - let data = s.obj(crvs, b"Data")?; - let mut fields = s.positional(data); - fields.next()?; - let count = match fields.next()? { - Value::UInt(n) => usize::try_from(*n).ok()?, - _ => return None, - }; - budget.spend(r, count)?; - let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect())); - let mut path = Path::default(); - let mut seen = 0usize; - for index in 0..count { - let closed = match fields.next()? { - Value::Bool(b) => *b, - _ => return None, - }; - let Value::Array(records) = fields.next()? else { return None }; - seen = seen.checked_add(records.len())?; - if seen > MAX_NODES { - budget.fail(r, "curves with more than four million nodes")?; - } - budget.spend(r, records.len())?; - let anchors = anchors(records)?; - let radii = if index == 0 { corners.as_ref() } else { None }; - if let Some(sp) = subpath(&anchors, closed, radii) { - path.subpaths.push(sp); - } - } - if corners.is_some() && count > 1 { - r.warn("rounded corners on curves with several subpaths (only the first is rounded)"); - } - Some(path) -} - -/// Most segments a stroke's pressure curve may have; the curves in real documents have a handful. -const MAX_PRESSURE_SEGMENTS: usize = 256; - -/// Most regular samples of a pressure curve: each segment gets an equal share, at least 2 (its -/// middle and its end) and 8 at most. The points where a segment's width stops rising or falling -/// come on top (at most two per segment), so a curve has at most 1 + 4 × [`MAX_PRESSURE_SEGMENTS`] -/// points; the document's limit counts all of them. -const PRESSURE_POINTS: usize = 512; - -/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read -/// counts this many against the document's limit. -pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS; - -/// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of -/// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as -/// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most -/// [`PRESSURE_POINTS`] in all, and also where its width stops rising or falling (at most two more -/// points), so a dip between samples is kept → (position, width) in order along the path, -/// positions clamped into 0..1 and never going back, widths clamped into 0..1. `None` when the -/// curve can't be read or has more than [`MAX_PRESSURE_SEGMENTS`] segments. -pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option> { - let s = r.s; - let data = s.obj(crvd, b"Data")?; - let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?; - // Every segment takes at least an anchor record. - if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 { - return None; - } - let curve = subpath(&anchors(records)?, false, None)?; - if curve.segments.len() > MAX_PRESSURE_SEGMENTS { - return None; - } - let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2); - let mut samples = vec![(curve.start.x, curve.start.y)]; - let mut from = curve.start; - for [c1, c2, to] in &curve.segments { - let mut ts: Vec = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect(); - ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten()); - ts.sort_by(f64::total_cmp); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); - for t in ts { - let u = 1.0 - t; - let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d; - samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y))); - } - from = *to; - } - let mut last = 0.0f64; - Some( - samples - .into_iter() - .map(|(x, y)| { - last = x.clamp(last, 1.0); - (last, y.clamp(0.0, 1.0)) - }) - .collect(), - ) -} - -/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`, -/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into -/// -1..1 first, so huge ones can't overflow the discriminant. -fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option; 2] { - let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs()); - if !(scale.is_finite() && scale > 0.0) { - return [None, None]; - } - let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale); - let (d0, d1, d2) = (b - a, c - b, d - c); - let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0); - let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t); - if qa.abs() < 1e-12 { - return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None]; - } - let disc = qb * qb - 4.0 * qa * qc; - if disc < 0.0 { - return [None, None]; - } - let root = disc.sqrt(); - [inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))] -} - -struct Anchor { - p: Point, - inh: Option, - out: Option, - /// Index of the anchor's record (live corners refer to records). - record: usize, -} - -fn record(v: &Value) -> Option<(Point, u8)> { - let Value::Bytes(b) = v else { return None }; - if b.len() != 18 { - return None; - } - let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?); - let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?); - if !x.is_finite() || !y.is_finite() { - return None; - } - Some((Point { x, y }, *b.get(17)?)) -} - -fn anchors(records: &[Value]) -> Option> { - let mut out: Vec = Vec::with_capacity(records.len() / 3 + 1); - let mut pending_in = None; - for (i, v) in records.iter().enumerate() { - let (p, role) = record(v)?; - match role { - 0 => { - out.push(Anchor { p, inh: pending_in.take(), out: None, record: i }); - } - 1 => { - // A leading out-handle without an anchor is edit state only. - if let Some(a) = out.last_mut() { - a.out = Some(p); - } - } - 2 => pending_in = Some(p), - _ => return None, - } - } - Some(out) -} - -fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option { - let first = anchors.first()?; - if let Some(radii) = radii.filter(|r| !r.is_empty()) { - // The largest radius of each record, once (not a scan of all of them for each anchor). - let mut largest: std::collections::HashMap = std::collections::HashMap::new(); - for (rec, r) in radii { - let e = largest.entry(*rec).or_insert(0.0); - *e = e.max(*r); - } - let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0); - let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p)); - if polyline { - let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect(); - // A closed subpath repeats its first anchor; its radius may be on either copy. - if closed - && pts.len() > 1 - && pts.first().map(|p| p.0) == pts.last().map(|p| p.0) - && let Some((_, r)) = pts.pop() - && let Some(f) = pts.first_mut() - { - f.1 = f.1.max(r); - } - return Some(crate::shapes::fillet(&pts, closed)); - } - } - let segments = anchors - .windows(2) - .filter_map(|w| { - let [a, b] = w else { return None }; - Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p]) - }) - .collect(); - Some(SubPath { start: first.p, segments, closed }) -} - -/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`. -fn corner_radii(fields: Vec<&Value>) -> Option> { - let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None }; - Some( - idx.iter() - .zip(rad) - .filter_map(|(i, r)| match (i, r) { - (Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)), - _ => None, - }) - .collect(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn turning_points_of_huge_control_values_are_found() { - let small = turning_points(1.0, -3.0, 3.0, 1.0); - let huge = turning_points(1e200, -3e200, 3e200, 1e200); - assert!(small.iter().all(Option::is_some), "{small:?}"); - for (s, h) in small.iter().zip(&huge) { - let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") }; - assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}"); - } - } -} diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs index 38d86a8990..e14c1439a7 100644 --- a/crates/affinity/src/lib.rs +++ b/crates/affinity/src/lib.rs @@ -1,52 +1,6 @@ -//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from -//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the -//! subset of the document model VectorCraft imports. -//! -//! Layouts follow the MIT-licensed afread reference and were checked against public documents -//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance -//! and the exact supported subset. Nothing here writes Affinity files. +//! The Affinity reader is `craft-affinity` (storytold/craft-libs). This crate keeps its name so +//! the corpus tests and fuzz targets under it, and the CI job that runs them, still build. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::fmt; - -mod access; -pub mod container; -mod geometry; -pub mod model; -pub mod overlay; -pub mod paint; -pub mod preview; -mod raster; -mod shapes; -pub mod stream; -#[cfg(any(test, feature = "synth"))] -pub mod synth; -mod text; - -pub use container::{Archive, Limits, MAGIC, is_affinity}; -pub use model::{Document, read}; -pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview}; - -/// Why a file could not be read. Messages are short reasons; callers add advice. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Error { - Malformed(&'static str), - Unsupported(&'static str), - Limit(&'static str), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"), - Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"), - Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"), - } - } -} - -impl std::error::Error for Error {} - -#[cfg(test)] -mod tests; +pub use craft_affinity::*; diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs deleted file mode 100644 index a6d18f339e..0000000000 --- a/crates/affinity/src/model.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes, -//! text and images, with transforms composed into document space. Anything else is reported -//! by [`Document::warnings`] instead of being dropped silently. - -use std::collections::{BTreeMap, HashSet}; - -use crate::paint::{self, Paint, Stroke}; -use crate::stream::{ObjId, Stream, Tag}; -use crate::{Archive, Error, Limits, stream}; - -mod bounds; -mod read; - -pub(crate) use bounds::rect; -pub use bounds::{kind_bounds, nodes_bounds}; -pub(crate) use read::Reader; -pub use read::read; -#[cfg(test)] -pub(crate) use read::read_within; - -/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Affine(pub [f64; 6]); - -impl Affine { - pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); - - /// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f). - pub(crate) fn from_xfrm(m: [f64; 6]) -> Self { - Self([m[0], m[3], m[1], m[4], m[2], m[5]]) - } - - pub fn then(self, outer: Self) -> Self { - let [a, b, c, d, e, f] = self.0; - let [oa, ob, oc, od, oe, of] = outer.0; - Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of]) - } - - pub fn apply(&self, p: Point) -> Point { - let [a, b, c, d, e, f] = self.0; - Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f } - } - - /// Geometric mean of the axis scales: how much a stroke width or font size grows. - pub fn scale(&self) -> f64 { - let [a, b, c, d, ..] = self.0; - (a * d - b * c).abs().sqrt() - } - - pub fn is_finite(&self) -> bool { - self.0.iter().all(|v| v.is_finite()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Point { - pub x: f64, - pub y: f64, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rect { - pub x0: f64, - pub y0: f64, - pub x1: f64, - pub y1: f64, -} - -/// One cubic Bézier subpath, in document pixels. -#[derive(Debug, Clone, PartialEq)] -pub struct SubPath { - pub start: Point, - /// (control 1, control 2, end) per segment. - pub segments: Vec<[Point; 3]>, - pub closed: bool, -} - -/// An outline in document pixels. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Path { - pub subpaths: Vec, -} - -impl Path { - pub fn transformed(&self, m: Affine) -> Self { - Self { - subpaths: self - .subpaths - .iter() - .map(|s| SubPath { - start: m.apply(s.start), - segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(), - closed: s.closed, - }) - .collect(), - } - } -} - -/// Affinity's blend modes, numbered by the `Blnd` enum. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Blend { - #[default] - Normal, - PassThrough, - Darken, - DarkerColor, - Multiply, - ColorBurn, - Lighten, - LighterColor, - Screen, - ColorDodge, - Add, - Overlay, - SoftLight, - HardLight, - VividLight, - PinLight, - LinearLight, - HardMix, - Difference, - Exclusion, - Subtract, - Hue, - Saturation, - Luminosity, - Color, - Average, - Negation, - Reflect, - Glow, - Erase, -} - -impl Blend { - /// The enum (id, version) pairs observed in a document whose layers are named after their mode. - fn from_enum(id: u16, version: u16) -> Option { - use Blend::*; - Some(match (id, version) { - (0, _) => Normal, - (1, 0) => Darken, - (2, 1) => DarkerColor, - (2, 0) => Multiply, - (3, 0) => ColorBurn, - (4, 0) => Lighten, - (6, 1) => LighterColor, - (5, 0) => Screen, - (6, 0) => ColorDodge, - (7, 0) => Add, - (8, 0) => Overlay, - (9, 0) => SoftLight, - (10, 0) => HardLight, - (11, 0) => VividLight, - (12, 0) => PinLight, - (15, 1) => LinearLight, - (13, 0) => HardMix, - (14, 0) => Difference, - (15, 0) => Exclusion, - (16, 0) => Subtract, - (17, 0) => Hue, - (18, 0) => Saturation, - (19, 0) => Luminosity, - (20, 0) => Color, - (21, 0) => Average, - (22, 0) => Negation, - (23, 0) => Reflect, - (24, 0) => Glow, - (25, 0) => Erase, - _ => return None, - }) - } -} - -/// A run of text with one set of character attributes. -#[derive(Debug, Clone, PartialEq)] -pub struct TextRun { - pub text: String, - /// PostScript name and family of the chosen font. - pub postscript: String, - pub family: String, - /// CSS weight (400 regular, 700 bold). - pub weight: i64, - pub italic: bool, - /// Boolean OpenType feature overrides, as tags or `-tag` for disabled features. - pub features: Vec, - pub all_caps: bool, - /// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its - /// owner made, all other characters there and in the public corpus have 0). - pub underline: bool, - /// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck - /// through in another document its owner made). - pub strikethrough: bool, - /// Font size in document pixels, before the node's transform. - pub size: f64, - /// Tracking in em. - pub tracking: f64, - /// Horizontal scale of the glyphs (1 = as designed). - pub h_scale: f64, - /// Fixed line pitch in document pixels, when the run overrides automatic leading. - pub leading: Option, - pub fill: Paint, - /// Outlined characters: the outline's paint and width (node space). - pub stroke: Option, - /// The run's paragraph: alignment, indents and spacing. - pub paragraph: Paragraph, -} - -/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the -/// node's text scale). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Paragraph { - pub align: Align, - /// Indent of every line but the first, from the left. - pub left: f64, - pub right: f64, - /// Indent of the first line, from the left. - pub first: f64, - pub space_before: f64, - pub space_after: f64, - /// Fixed distance between baselines, when the paragraph sets one. - pub leading: Option, - /// Automatic leading as a multiple of the font's own line height (ascent plus descent). - pub auto_leading: f64, -} - -impl Default for Paragraph { - fn default() -> Self { - Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Align { - #[default] - Left, - Center, - Right, - Justify, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Text { - pub runs: Vec, - pub align: Align, - /// Artistic text: the first baseline's anchor point in node space (start, centre or end of the - /// line according to `align`). Frame text: the frame box in node space. - pub anchor: Point, - pub frame: Option, - /// Node space to document pixels. - pub transform: Affine, - /// Node space to document pixels for the type (its linear part matters): artistic text scales - /// with its node, frame text only with its frame's text scale. - pub text_transform: Affine, -} - -/// Vertical alignment of a table cell's text. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VAlign { - Top, - Center, - Bottom, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct TableCell { - pub runs: Vec, - /// Space between the cell edges and its text, in node space: left, top, right, bottom. - pub inset: [f64; 4], - pub valign: VAlign, - /// Part of the cell to its left (merged): it has no text of its own. - pub merged_left: bool, -} - -/// A table: a grid of cells with text, and a line on every cell edge. -#[derive(Debug, Clone, PartialEq)] -pub struct Table { - /// Column boundaries (one more than the columns), increasing, in node space. - pub columns: Vec, - /// Row boundaries (one more than the rows), increasing, in node space. - pub rows: Vec, - /// Row by row. - pub cells: Vec, - /// Vertical edges row by row, one more per row than the columns; `None` draws no line, and - /// the list is empty when the file's didn't match the grid. - pub vertical: Vec>, - /// Horizontal edges boundary by boundary (one more than the rows), one per column (empty - /// as `vertical` can be). - pub horizontal: Vec>, - /// Node space to document pixels. - pub transform: Affine, - /// Node space to document pixels for the cells' type: resizing a table moves its grid, not - /// its type. - pub text_transform: Affine, -} - -impl Table { - pub fn ncols(&self) -> usize { - self.columns.len().saturating_sub(1) - } - pub fn nrows(&self) -> usize { - self.rows.len().saturating_sub(1) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Pixels { - /// Straight-alpha RGBA, 8 bits per channel, row by row. - Rgba8(Vec), - /// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and - /// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a - /// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals - /// into a blue cast. - Cmyka8(Vec), - /// The original file a placed image was made from (PNG, JPEG…), as embedded. - Encoded(Vec), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Image { - pub width: u32, - pub height: u32, - pub pixels: Pixels, - /// Pixel space (0..w, 0..h) to document pixels. - pub transform: Affine, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Kind { - /// Layer container (`Scop`). - Layer, - Group, - /// Artboard: an export rectangle and vector outline that clips its children. - /// `background` paints behind them, and `strokes` over them. - Artboard { - rect: Rect, - /// Actual clipping outline in document pixels. The artboard's export rectangle is its - /// bounding box, but a rotated or non-rectangular board still clips to this outline. - path: Path, - even_odd: bool, - background: Vec, - strokes: Vec, - }, - /// Curve, parametric shape or compound shape: an outline with its fills and strokes. - /// Children of a shape are clipped by its outline (painted over its fills, under its strokes). - Shape { - path: Path, - fills: Vec, - strokes: Vec, - even_odd: bool, - }, - Text(Text), - Table(Table), - Image(Image), - /// A master page shown on this spread's pages: pages `page..page + count` show the master - /// spread [`Document::masters`]`[master]`'s pages from `master_page` on. - MasterInstance { - master: usize, - page: usize, - master_page: usize, - count: usize, - }, - /// Something this reader does not import; its children are still imported. - Unsupported, -} - -/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in -/// (radians, clockwise from +x as y points down: π/2 puts it below). -#[derive(Debug, Clone, PartialEq)] -pub enum Effect { - DropShadow { - color: crate::paint::Color, - opacity: f64, - radius: f64, - distance: f64, - angle: f64, - }, - InnerShadow { - color: crate::paint::Color, - opacity: f64, - radius: f64, - distance: f64, - angle: f64, - }, - OuterGlow { - color: crate::paint::Color, - opacity: f64, - radius: f64, - }, - /// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred. - Outline { - color: crate::paint::Color, - opacity: f64, - width: f64, - align: u16, - }, - /// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth - /// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default); - /// `inner` is false for the outer, emboss and pillow kinds. - Bevel { - size: f64, - depth: f64, - angle: f64, - highlight: crate::paint::Color, - highlight_opacity: f64, - shadow: crate::paint::Color, - shadow_opacity: f64, - inner: bool, - }, - /// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at - /// `opacity` (the node's own alpha is kept). - ColorOverlay { - color: crate::paint::Color, - opacity: f64, - blend: Blend, - }, - /// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels). - GradientOverlay { - gradient: crate::paint::Gradient, - opacity: f64, - blend: Blend, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Node { - pub class: Tag, - pub name: String, - pub visible: bool, - pub locked: bool, - pub opacity: f64, - pub blend: Blend, - pub kind: Kind, - /// Vector mask in document pixels: the node shows only inside it (nonzero fill). - pub mask: Option, - /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden. - pub pixel_mask: Option, - /// Layer effects the importer can show or bake in (others are reported). - pub effects: Vec, - /// Bottom to top. - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Spread { - /// Spread bounds in document pixels (the canvas, or the union of the artboards). - pub bounds: Rect, - /// Pages of a Publisher-style spread (one, or two facing pages), in document pixels. - pub pages: Vec, - /// Transparent background; otherwise the page is white. - pub transparent: bool, - /// Bottom to top. - pub nodes: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Document { - /// Pixels per inch: all geometry is in document pixels at this resolution. - pub dpi: f64, - pub spreads: Vec, - /// Master spreads, which [`Kind::MasterInstance`] nodes show on pages. - pub masters: Vec, - /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present. - pub saved_by: Option, - /// Content that was not imported or was approximated, one line per kind, deduplicated. - pub warnings: Vec, - /// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`, - /// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative - /// colorimetric intent and black-point compensation in every document checked. - pub cmyk_profile: Option>, -} diff --git a/crates/affinity/src/model/bounds.rs b/crates/affinity/src/model/bounds.rs deleted file mode 100644 index 7ef9fff0b2..0000000000 --- a/crates/affinity/src/model/bounds.rs +++ /dev/null @@ -1,142 +0,0 @@ -use super::read::MAX_TREE_DEPTH; -use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag}; - -/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames -/// and tables), in document pixels. -pub fn nodes_bounds(nodes: &[Node]) -> Option { - let mut out = None; - walk_bounds(nodes, true, 0, &mut out); - out -} - -/// The bounds of `node` and everything in it, hidden or not. -pub(super) fn tree_bounds(node: &Node) -> Option { - let mut out = None; - walk_bounds(std::slice::from_ref(node), false, 0, &mut out); - out -} - -fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) { - if depth > MAX_TREE_DEPTH { - return; - } - for n in nodes.iter().filter(|n| n.visible || !visible_only) { - kind_points(&n.kind, &mut |p| add_point(out, p)); - walk_bounds(&n.children, visible_only, depth + 1, out); - } -} - -/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they -/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here: -/// artistic text, whose glyphs only the layout places. -pub(super) fn mask_extent(kind: &Kind, children: &[Node]) -> Option { - fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool { - if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) { - return false; - } - let mut own = kind_bounds(kind); - if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) { - // As far as a stroke can reach out of its path, miter joins included. - let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max); - own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach }); - } - if let Some(r) = own { - *out = Some(out.map_or(r, |o| union(o, r))); - } - children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out)) - } - let mut out = None; - if visit(kind, children, 0, &mut out) { out } else { None } -} - -/// The bounds of what one node's own kind draws (not its children): its outline's points, its -/// image's or frame's corners, or artistic text's anchor. -pub fn kind_bounds(kind: &Kind) -> Option { - let mut out = None; - kind_points(kind, &mut |p| add_point(&mut out, p)); - out -} - -fn add_point(out: &mut Option, p: Point) { - if p.x.is_finite() && p.y.is_finite() { - let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; - *out = Some(out.map_or(r, |o| union(o, r))); - } -} - -fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { - let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); - match kind { - Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { - for sp in &path.subpaths { - add(sp.start); - for seg in &sp.segments { - seg.iter().for_each(|p| add(*p)); - } - } - } - Kind::Image(img) => { - corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) - } - Kind::Text(t) => match t.frame { - Some(f) => corners(f, t.transform).into_iter().for_each(add), - None => add(t.transform.apply(t.anchor)), - }, - Kind::Table(t) => { - if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { - corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); - } - } - Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} - } -} - -/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` -/// equal pages (Publisher 1 facing spreads). -pub(super) fn pages(s: &Stream, spread: ObjId) -> Vec { - if let Some(md) = s.obj(spread, b"SpMd") { - let v: Vec = - s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); - if !v.is_empty() { - return v; - } - } - match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { - (Some(b), Some(n @ 2..=16)) => { - let w = (b.x1 - b.x0) / n as f64; - (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() - } - _ => Vec::new(), - } -} - -pub(crate) fn union(a: Rect, b: Rect) -> Rect { - Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) } -} - -pub(super) fn is_container(class: Tag) -> bool { - class == Tag::of(b"Scop") || class == Tag::of(b"Grup") -} - -pub(crate) fn rect(v: [f64; 4]) -> Rect { - Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] } -} - -/// A readable name for a layer class in warnings. -pub(crate) fn describe(class: Tag) -> &'static str { - match &class.0.to_be_bytes() { - b"PCrv" => "curve", - b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape", - b"Comp" => "compound", - b"Rstr" => "pixel", - b"ImgN" => "image", - b"EmbN" => "embedded document", - b"MPIN" => "master page instance", - b"FRst" => "fill", - b"MRst" => "mask", - b"Slic" | b"SlcP" => "slice", - b"3DLA" => "3D", - [_, _, b'R', b'A'] => "adjustment", - _ => "unknown", - } -} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs deleted file mode 100644 index 80f4e0993b..0000000000 --- a/crates/affinity/src/model/read.rs +++ /dev/null @@ -1,685 +0,0 @@ -use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union}; -use super::{ - Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream, - Tag, paint, stream, -}; - -/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). -const MAX_PROFILE_BYTES: usize = 32 << 20; - -/// Deepest layer nesting followed; deeper content is reported, not imported. -pub(super) const MAX_TREE_DEPTH: usize = 128; - -/// Total layer nodes imported. -const MAX_NODES: usize = 500_000; - -/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits -/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). -const MAX_LAYER_PIXELS: u64 = 512 << 20; - -/// Embedded documents inside embedded documents are followed this deep. -const MAX_EMBED_DEPTH: usize = 4; - -/// Characters of all stories together (a story may be shared by many text layers). -const MAX_DOCUMENT_CHARS: usize = 16 << 20; - -/// Table cells of all tables together (each becomes a text object with its edge lines). -const MAX_DOCUMENT_CELLS: usize = 1 << 18; - -/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). -const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; - -/// Child references inspected, page rectangles produced, and outline segments retained, in all -/// (shared objects are visited once per link, so each visit is charged). Real documents stay -/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that. -const MAX_DOCUMENT_WORK: usize = 1 << 24; - -/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy). -const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20; - -/// What a whole read may still use. Shared objects can be linked from many layers, and embedded -/// documents are read with what their embedding document left, so linking or embedding the -/// same content many times can't multiply the work. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Budget { - /// Layer nodes ([`MAX_NODES`] in all). - pub(crate) nodes: usize, - /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all). - pub(crate) pixels: u64, - /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all). - pub(crate) chars: usize, - /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). - pub(crate) cells: usize, - /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). - pub(crate) pressure: usize, - /// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all). - pub(crate) work: usize, - /// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all). - pub(crate) name_chars: usize, -} - -impl Budget { - pub(crate) const FULL: Budget = Budget { - nodes: MAX_NODES, - pixels: MAX_LAYER_PIXELS, - chars: MAX_DOCUMENT_CHARS, - cells: MAX_DOCUMENT_CELLS, - pressure: MAX_DOCUMENT_PRESSURE_POINTS, - work: MAX_DOCUMENT_WORK, - name_chars: MAX_DOCUMENT_NAME_CHARS, - }; - - /// Take `n` units of work, or fail the read. - pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> { - self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?; - Ok(()) - } -} - -/// Read a document: archive, `doc.dat` and the model. -pub fn read(bytes: &[u8], limits: Limits) -> Result { - read_within(bytes, limits, MAX_LAYER_PIXELS) -} - -/// [`read`], its pixel layers holding at most `pixels` pixels together. -pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { - read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document -} - -/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted -/// either way. -struct ReadAt { - document: Result, - extracted: usize, -} - -/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` -/// deep). What it uses is taken from `budget`, also when it fails part of the way. -/// The most characters of a layer's name kept. -const MAX_NAME_CHARS: usize = 1024; - -fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt { - let mut archive = match Archive::open(bytes, limits) { - Ok(a) => a, - Err(e) => return ReadAt { document: Err(e), extracted: 0 }, - }; - let document = read_archive(&mut archive, budget, base, depth); - ReadAt { document, extracted: archive.extracted() } -} - -fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result { - let doc = archive.read("doc.dat")?; - let s = stream::parse(&doc)?; - let mut reader = Reader::new(&s, archive, base, depth); - reader.budget = *budget; - let document = reader.document(); - *budget = reader.budget; - document -} - -pub(crate) struct Reader<'s, 'a, 'b> { - pub(crate) s: &'s Stream, - pub(crate) archive: &'b mut Archive<'a>, - pub(crate) dpi: f64, - pub(crate) warnings: BTreeMap, - active: HashSet, - /// Where the spreads go: the identity, or an embedding document's placement. - base: Affine, - /// The linear part of `base`, which also scales frame text. - pub(crate) type_base: Affine, - /// How deep inside embedded documents this one is. - depth: usize, - /// An embedded document's content, waiting to become its node's children. - pending: Option>, - /// The master spreads, in the order of [`Document::masters`]. - master_ids: Vec, - /// What the rest of the read may use. - pub(crate) budget: Budget, -} - -impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { - let [a, b, c, d, ..] = base.0; - Self { - s, - archive, - dpi: 72.0, - warnings: BTreeMap::new(), - active: HashSet::new(), - base, - type_base: Affine([a, b, c, d, 0.0, 0.0]), - depth, - pending: None, - master_ids: Vec::new(), - budget: Budget::FULL, - } - } - - pub(crate) fn warn(&mut self, what: impl Into) { - *self.warnings.entry(what.into()).or_default() += 1; - } - - /// The document. - fn document(&mut self) -> Result { - let s = self.s; - let root = s.root; - self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); - let saved_by = s.obj(root, b"NVer").map(|v| { - let product = s.str(v, b"Prod").unwrap_or("Affinity"); - match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) { - (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"), - _ => product.to_string(), - } - }); - let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; - self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); - let mut spreads = Vec::new(); - for spread in s.objs(doc, b"Chld") { - if let Some(sp) = self.spread(doc, spread)? { - spreads.push(sp); - } - } - if spreads.is_empty() { - return Err(Error::Malformed("document has no pages")); - } - let mut masters = Vec::new(); - for master in self.master_ids.clone() { - match self.spread(doc, master)? { - Some(sp) => masters.push(sp), - // Keep the indices of `MasterInstance::master` valid. - None => masters.push(Spread { - bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, - pages: Vec::new(), - transparent: true, - nodes: Vec::new(), - }), - } - } - let cmyk_profile = s - .obj(root, b"CMYP") - .and_then(|p| s.bytes(p, b"Data")) - .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) - .map(<[u8]>::to_vec); - let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); - Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }) - } - - /// A spread (`Sprd`) of the document `doc`, or of its master spreads. - fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { - let s = self.s; - if !s.is(spread, b"Sprd") { - self.warn("an unknown kind of page"); - return Ok(None); - } - let pages = pages(s, spread); - self.budget.spend(pages.len() + 1)?; - let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); - let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { - x0: 0.0, - y0: 0.0, - x1: 0.0, - y1: 0.0, - }); - let transparent = s.bool(spread, b"SprT").unwrap_or(false); - let (nodes, mask) = self.children(spread, self.base, 0)?; - if mask.is_some() { - self.warn("mask layers directly on a page (imported without them)"); - } - Ok(Some(Spread { bounds, pages, transparent, nodes })) - } - - /// The children of `parent`, and the mask layer among them that masks the parent (with its - /// placement), read later over just what it masks. - #[allow(clippy::type_complexity)] - fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { - let s = self.s; - let mut out = Vec::new(); - let mut mask = None; - let kids = s.objs(parent, b"Chld"); - // Invisible masks and skipped children cost no node, so the visit is charged here. - self.budget.spend(kids.len())?; - for c in kids { - if s.is(c, b"MRst") { - // A mask layer inside a group or layer masks that container. - if s.bool(c, b"Visi") == Some(false) { - continue; - } - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - if mask.is_none() { - mask = Some((c, local.then(world))); - } else { - self.warn("several pixel masks on one layer (only the first is used)"); - } - continue; - } - if let Some(n) = self.node(c, world, depth + 1)? { - out.push(n); - } - } - Ok((out, mask)) - } - - fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { - let s = self.s; - if depth > MAX_TREE_DEPTH { - self.warn("layers nested deeper than 128 levels"); - return Ok(None); - } - let Some(left) = self.budget.nodes.checked_sub(1) else { - return Err(Error::Limit("too many layers")); - }; - self.budget.nodes = left; - // A node that contains itself (a shared object linked back into its own subtree). - if !self.active.insert(id) { - self.warn("a layer that contains itself"); - return Ok(None); - } - let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let world = local.then(parent); - let class = s.class(id).unwrap_or(Tag(0)); - // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect(); - self.budget.name_chars -= name.chars().count(); - let visible = s.bool(id, b"Visi").unwrap_or(true); - let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); - let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); - let blend = match s.enumeration(id, b"Blnd") { - None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough, - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - }; - let kind = if !world.is_finite() { - self.warn("a layer with an invalid transform"); - Kind::Unsupported - } else { - self.kind(id, class, world, parent)? - }; - // This node's embedded content, before its children's `node` calls can take it. - let embedded = self.pending.take(); - // A compound's children are its operands, already merged into its outline. - let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; - if let Some(embedded) = embedded { - children.extend(embedded); - } - // Pixel masks are read only over what they mask (they can span a whole spread at print - // resolution, a gigapixel). - let over = mask_extent(&kind, &children); - let child_mask = match child_mask { - Some((c, at)) => crate::raster::mask(self, c, at, over)?, - None => None, - }; - let (mask, attached_mask) = self.attached_masks(id, world, over)?; - let pixel_mask = attached_mask.or(child_mask); - self.active.remove(&id); - let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; - // Gradient overlays are laid out over the node's bounds, also while it is hidden. - let bounds = tree_bounds(&node); - node.effects = self.effects(id, world, bounds); - Ok(Some(node)) - } - - fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { - let s = self.s; - Ok(match &class.0.to_be_bytes() { - b"Scop" => Kind::Layer, - b"Grup" => Kind::Group, - b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { - // Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp` - // property object in `phrp`, while older documents use `ABEn`. Ordinary shapes - // in that fixture have neither. See the pinned corpus and validation audit. - if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) { - return Ok(self.artboard(id, world)); - } - let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { - self.warn(format!("a {} that could not be read", describe(class))); - return Ok(Kind::Unsupported); - }; - self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?; - let (fills, strokes) = paint::node_paint(self, id, world); - Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } - } - b"TxtT" => match crate::text::table(self, id, world) { - Some(t) => Kind::Table(t), - None => { - self.warn("tables that could not be read"); - Kind::Unsupported - } - }, - b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { - Some(t) => Kind::Text(t), - None => { - self.warn("text whose story could not be read (master-page text, or an unknown layout)"); - Kind::Unsupported - } - }, - b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? { - Some(img) => Kind::Image(img), - None => Kind::Unsupported, - }, - b"MPIN" => { - // `SLnk` links the instance with the master spread it shows. - let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); - let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); - let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); - match master { - Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, - None => { - self.warn("master page instances without their master"); - Kind::Unsupported - } - } - } - b"EmbN" => match self.embedded_document(id, world) { - // The embedded document itself, as editable content. - Some(nodes) => { - self.pending = Some(nodes); - Kind::Group - } - None => match crate::raster::embedded(self, id, world)? { - Some(img) => { - self.warn("embedded documents and symbols (imported as pictures of them)"); - Kind::Image(img) - } - None => { - self.warn("embedded documents and symbols without a cached picture"); - Kind::Unsupported - } - }, - }, - b"FRst" => { - // A fill layer paints its whole bitmap area; its gradient lives in page space. - let b = s.obj(id, b"Bitm"); - let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0); - match (size(b"BmpW"), size(b"BmpH")) { - (Some(w), Some(h)) => { - let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] }; - let fills = paint::fills(self, id, parent, b"BFFl"); - Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false } - } - _ => { - self.warn("fill layers without an area"); - Kind::Unsupported - } - } - } - _ => { - self.warn(format!("{} layers", describe(class))); - Kind::Unsupported - } - }) - } - - /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the - /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. - fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { - let s = self.s; - let mut out = Vec::new(); - for e in s.objs(id, b"FiEf").into_iter().take(64) { - if s.bool(e, b"Enab") == Some(false) { - continue; - } - // "Scale with object": lengths follow the node's transform; otherwise they are absolute. - let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; - let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; - let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); - let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); - let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); - let class = s.class(e).map(|c| c.0.to_be_bytes()); - match (class.as_ref(), color) { - (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), - (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { - let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); - out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); - } - (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), - (Some(b"BevE" | b"EmbE"), _) => { - // No radius, no bevel (as Affinity draws it). - let size = len(b"Radi"); - if size > 0.0 { - let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; - let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; - let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); - let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); - let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); - let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); - let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); - let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); - out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); - } - } - (Some(b"ColO"), Some(color)) => { - let blend = self.effect_blend(e); - out.push(Effect::ColorOverlay { color, opacity, blend }); - } - (Some(b"GrdO"), _) => { - let blend = self.effect_blend(e); - // The gradient's unit space spans the node's bounding box from -1 to 1, centred - // on it (y down): checked against the stored previews of real documents. - let space = - bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); - match (s.obj(e, b"GrFl"), space) { - (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { - Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), - Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), - _ => {} - }, - // Nothing visible to lay it over. - (Some(_), None) => {} - (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), - } - } - (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), - (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), - _ => self.warn("layer effects of other kinds (left out)"), - } - } - for a in s.objs(id, b"AdCh") { - if let Some(effect) = self.attached_recolor(a) { - out.push(effect); - } - } - out - } - fn attached_recolor(&mut self, a: ObjId) -> Option { - let s = self.s; - if !s.is(a, b"RcRA") { - return None; - } - if s.bool(a, b"Visi") == Some(false) { - return None; - } - let adjp = s.obj(a, b"AdjP")?; - if !s.is(adjp, b"RecP") { - return None; - } - let h = s.f64(adjp, b"RecH").unwrap_or(0.0); - let sat = s.f64(adjp, b"RecS").unwrap_or(1.0); - let l = s.f64(adjp, b"RecL").unwrap_or(0.5); - let (r, g, b) = crate::paint::hsl(h, sat, l); - let color = crate::paint::Color::Rgb { r, g, b, a: 1.0 }; - let opacity = s.f64(a, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); - let blend = self.effect_blend(a); - Some(Effect::ColorOverlay { color, opacity, blend }) - } - - /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). - fn effect_blend(&mut self, e: ObjId) -> Blend { - match self.s.enumeration(e, b"BlnM") { - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - } - } - - /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; - /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. - fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { - let s = self.s; - let attached = s.objs(id, b"AdCh"); - let mut mask: Option = None; - let mut pixels: Option = None; - for a in attached { - let class = s.class(a).unwrap_or(Tag(0)); - if s.bool(a, b"Visi") == Some(false) { - continue; - } - if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - if let Some((p, _)) = crate::geometry::outline(self, a, class, local) { - mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths); - } - } else if class == Tag::of(b"MRst") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - match crate::raster::mask(self, a, local, over)? { - Some(m) if pixels.is_none() => pixels = Some(m), - Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), - None => {} - } - } else if class == Tag::of(b"RcRA") { - // Handled in `effects` as a colour overlay. - } else { - self.warn(format!("{} layers attached to other layers (imported without them)", describe(class))); - } - } - Ok((mask, pixels)) - } - - /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds - /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, - /// placed so the centre of its page, first artboard or content is the node's origin. - fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { - let s = self.s; - let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); - if entry.is_empty() { - return None; - } - if self.depth >= MAX_EMBED_DEPTH { - self.warn("embedded documents nested more than four deep"); - return None; - } - let data = self.archive.read(&entry).ok()?; - let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; - if !crate::is_affinity(inner) { - return None; - } - // A first reading finds its page and resolution, the second places it. - // Both readings are charged to the budget and the archive's limits, also when they fail: - // each does the work. - let mut budget = self.budget; - let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1); - self.budget = budget; - if self.archive.charge(probe.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let probe = match probe.document { - Ok(d) => d, - Err(e) => { - self.warn(format!("embedded documents that could not be read ({e})")); - return None; - } - }; - let spread = probe.spreads.first()?; - // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. - let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); - let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); - let page = if content { None } else { spread.pages.first().copied().or(first_board) }; - let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); - // Its pixels aren't needed any more: never hold both readings' at once. - drop(probe); - // Its document pixels are the node's units, whatever either document's resolution. - let k = 1.0; - let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); - let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); - if !base.is_finite() { - self.warn("embedded documents with an invalid placement"); - return None; - } - let mut budget = self.budget; - let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1); - self.budget = budget; - if self.archive.charge(placed.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let doc = placed.document.ok()?; - for w in doc.warnings { - self.warn(format!("{w} [in embedded documents]")); - } - if doc.spreads.len() > 1 { - self.warn("embedded documents with several pages (the first is used)"); - } - let mut nodes = doc.spreads.into_iter().next()?.nodes; - // Its master page instances count its own masters, which aren't kept: in this document - // they would show other masters. - fn strip_masters(nodes: &mut [Node], depth: usize) -> bool { - let mut found = false; - for n in nodes { - if matches!(n.kind, Kind::MasterInstance { .. }) { - n.kind = Kind::Unsupported; - found = true; - } - if depth < MAX_TREE_DEPTH { - found |= strip_masters(&mut n.children, depth + 1); - } - } - found - } - if strip_masters(&mut nodes, 0) { - self.warn("master pages of embedded documents (left out)"); - } - Some(nodes) - } - - fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { - let s = self.s; - let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { - self.warn("an artboard without valid bounds (its children are kept without clipping)"); - return Kind::Unsupported; - }; - let corners = - [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p)); - let [a, b2, c, d, ..] = world.0; - if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 { - self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)"); - } - let xs = corners.map(|p| p.x); - let ys = corners.map(|p| p.y); - let rect = Rect { - x0: xs.iter().copied().fold(f64::INFINITY, f64::min), - y0: ys.iter().copied().fold(f64::INFINITY, f64::min), - x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 { - self.warn("an artboard with an invalid transformed size (its children are kept without clipping)"); - return Kind::Unsupported; - } - let class = s.class(id).unwrap_or(Tag::of(b"ShpN")); - let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| { - self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); - (Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false) - }); - let (background, strokes) = paint::node_paint(self, id, world); - Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } - } -} - -#[cfg(test)] -mod budget_tests { - use super::*; - - #[test] - fn shared_work_is_bounded() { - let mut b = Budget::FULL; - assert!(b.spend(MAX_DOCUMENT_WORK).is_ok()); - assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data"))); - } -} diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs deleted file mode 100644 index 80f54f68e9..0000000000 --- a/crates/affinity/src/overlay.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what -//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's -//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps). -//! -//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid -//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow, -//! Darker/Lighter Color) use their usual definitions. - -use std::borrow::Cow; - -use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point}; -use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke}; - -/// Deepest subtree an overlay is baked into (the reader bounds trees well below this). -const MAX_DEPTH: usize = 256; - -/// Most pixels an overlay is baked into per image. -const MAX_PIXELS: usize = 64 << 20; - -/// Work the overlays of one import may take together: each overlay counts the nodes and pixels -/// of the subtree it is baked into (which is copied once more for them all). Nested overlays -/// and shared content would otherwise multiply it; past it, overlays are left out and said. -pub const MAX_WORK: u64 = 1 << 29; - -#[derive(Clone, Copy)] -enum Source<'a> { - Solid(Color), - Gradient(&'a Gradient), -} - -#[derive(Clone, Copy)] -struct Overlay<'a> { - source: Source<'a>, - opacity: f64, - blend: Blend, -} - -/// `node` with its colour and gradient overlays baked in, and those of everything in it -/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`], -/// can't pay for copying it). `warn` hears what could only be approximated or was left out. -pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> { - if overlays(&node.effects).is_empty() { - return Cow::Borrowed(node); - } - let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else { - warn("overlay effects beyond the work one import may take (left out)"); - return Cow::Borrowed(node); - }; - *work = left; - let mut out = node.clone(); - bake_tree(&mut out, work, 0, warn); - Cow::Owned(out) -} - -/// The visible colour and gradient overlays among `effects`. -fn overlays(effects: &[Effect]) -> Vec> { - effects - .iter() - .filter_map(|e| match e { - Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }), - Effect::GradientOverlay { gradient, opacity, blend } => { - Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend }) - } - _ => None, - }) - .filter(|o| o.opacity > 0.0) - .collect() -} - -fn is_overlay(e: &Effect) -> bool { - matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }) -} - -/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay -/// lies over what its node shows, its children's overlays included. Each overlay pays for the -/// subtree it is baked into out of `work`; past it, it is left out (and said). -fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) { - if depth > MAX_DEPTH { - return; - } - for c in &mut node.children { - bake_tree(c, work, depth + 1, warn); - } - if !node.effects.iter().any(is_overlay) { - return; - } - let (effects, rest): (Vec, Vec) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay); - node.effects = rest; - let overlays = overlays(&effects); - if overlays.is_empty() { - return; - } - let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX); - let cost = subtree_cost(node, *work / count, 0).saturating_mul(count); - let Some(left) = work.checked_sub(cost) else { - warn("overlay effects beyond the work one import may take (left out)"); - return; - }; - *work = left; - for o in &overlays { - if matches!(o.blend, Blend::Erase | Blend::PassThrough) { - warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)"); - } - bake_node(node, o, 0, warn); - } -} - -/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`. -fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 { - if depth > MAX_DEPTH { - return 0; - } - let mut cost: u64 = match &node.kind { - Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1), - Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1), - Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1), - _ => 1, - }; - for c in &node.children { - if cost > limit { - break; - } - cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1)); - } - cost -} - -fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) { - if depth > MAX_DEPTH { - return; - } - match &mut node.kind { - Kind::Shape { fills, strokes, .. } => { - for f in fills.iter_mut() { - *f = paint(f, o, warn); - } - strokes.iter_mut().for_each(|s| stroke(s, o, warn)); - } - Kind::Artboard { background, strokes, .. } => { - for f in background.iter_mut() { - *f = paint(f, o, warn); - } - strokes.iter_mut().for_each(|s| stroke(s, o, warn)); - } - Kind::Text(t) => { - for r in &mut t.runs { - r.fill = paint(&r.fill, o, warn); - if let Some(s) = &mut r.stroke { - stroke(s, o, warn); - } - } - } - Kind::Table(t) => { - for c in &mut t.cells { - for r in &mut c.runs { - r.fill = paint(&r.fill, o, warn); - if let Some(s) = &mut r.stroke { - stroke(s, o, warn); - } - } - } - for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() { - stroke(s, o, warn); - } - } - Kind::Image(img) => { - if !pixels(img, o) { - warn("overlay effects on images too large to recolour (left out)"); - } - } - Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} - } - for e in &mut node.effects { - match e { - Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o), - Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {} - Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {} - } - } - for c in &mut node.children { - bake_node(c, o, depth + 1, warn); - } -} - -fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) { - s.paint = paint(&s.paint, o, warn); -} - -/// A paint with the overlay over it. -fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint { - match (p, o.source) { - (Paint::None, _) => Paint::None, - (Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)), - (Paint::Gradient(g), Source::Solid(s)) => { - Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() }) - } - // Over one colour, the result is the overlay's gradient with each stop laid over it. - (Paint::Solid(c), Source::Gradient(g)) => { - Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() }) - } - (Paint::Gradient(base), Source::Gradient(g)) => { - if !(o.blend == Blend::Normal && o.opacity >= 1.0) { - warn("gradient overlays on gradients (approximated)"); - } - // Exact when the overlay covers it; otherwise over the base gradient's average colour. - let avg = average(base); - Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() }) - } - } -} - -impl Source<'_> { - /// One colour for the source (a gradient's average) where only one fits. - fn at_solid(&self) -> Color { - match self { - Source::Solid(c) => *c, - Source::Gradient(g) => average(g), - } - } -} - -fn average(g: &Gradient) -> Color { - let n = g.stops.len().max(1) as f64; - let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0); - for s in &g.stops { - let [sr, sg, sb] = rgb(s.color); - r += sr; - gr += sg; - b += sb; - a += s.color.alpha(); - } - Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n } -} - -/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept. -fn over(base: Color, source: Color, o: &Overlay) -> Color { - let k = (o.opacity * source.alpha()).clamp(0.0, 1.0); - let alpha = base.alpha(); - // Normal mixes within the colour model when both share it, so CMYK stays CMYK. - if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) { - let mix = |a: f64, b: f64| a + (b - a) * k; - match (base, source) { - (Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => { - return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha }; - } - (Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha }, - (_, s) if k >= 1.0 => return with_alpha(s, alpha), - _ => {} - } - } - let b = rgb(base); - let s = rgb(source); - let blended = blend(o.blend, b, s); - let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k); - Color::Rgb { r, g, b: bl, a: alpha } -} - -fn with_alpha(c: Color, a: f64) -> Color { - match c { - Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a }, - Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a }, - Color::Gray { v, .. } => Color::Gray { v, a }, - Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a }, - } -} - -/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the -/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour. -fn rgb(c: Color) -> [f64; 3] { - let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; - match c { - Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)], - Color::Gray { v, .. } => [f(v); 3], - Color::Cmyk { c, m, y, k, .. } => { - let w = 1.0 - f(k); - [(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w] - } - Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f), - } -} - -/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65. -fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { - let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; - let (l, a, b) = (finite(l), finite(a), finite(b)); - let fy = (l + 16.0) / 116.0; - let (fx, fz) = (fy + a / 500.0, fy - b / 200.0); - let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; - // D50 white. - let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz)); - // XYZ (D50) to linear sRGB, Bradford-adapted. - let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z; - let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z; - let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z; - let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }; - [gamma(r), gamma(g), gamma(bl)] -} - -/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many. -fn pixels(img: &mut Image, o: &Overlay) -> bool { - let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false }; - let Some(len) = n.checked_mul(4) else { return false }; - let mut px = match &img.pixels { - Pixels::Rgba8(p) => match p.get(..len) { - Some(p) => p.to_vec(), - None => return false, - }, - Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) { - Some(p) => p, - None => return false, - }, - // An overlay is blended in RGB: CMYK pixels take the profile-free formula here. - Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) { - Some(p) if p.len() == n * 5 => p - .as_chunks::<5>() - .0 - .iter() - .flat_map(|[c, m, y, k, a]| { - let w = 255 - u16::from(*k); - let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8; - [ch(*c), ch(*m), ch(*y), *a] - }) - .collect(), - _ => return false, - }, - }; - let width = img.width.max(1) as usize; - // A solid source is converted once; a gradient is sampled at each pixel's centre. - let solid = match o.source { - Source::Solid(c) => Some((rgb(c), c.alpha())), - Source::Gradient(_) => None, - }; - let to_unit = match o.source { - Source::Gradient(g) => match invert(g.transform) { - Some(m) => Some((img.transform.then(m), g, stop_reach(g))), - None => return false, - }, - Source::Solid(_) => None, - }; - for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() { - if p[3] == 0 { - continue; - } - let (s, sa) = match (solid, &to_unit) { - (Some(s), _) => s, - (None, Some((m, g, reach))) => { - let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); - let c = sample(g, reach, q); - (rgb(c), c.alpha()) - } - (None, None) => continue, - }; - let k = (o.opacity * sa).clamp(0.0, 1.0); - let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0); - let m = blend(o.blend, b, s); - for c in 0..3 { - p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8; - } - } - img.pixels = Pixels::Rgba8(px); - true -} - -/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says. -fn decode(bytes: &[u8], width: u32, height: u32) -> Option> { - let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; - if probe.into_dimensions().ok()? != (width, height) { - return None; - } - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(width); - limits.max_image_height = Some(height); - limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8); - reader.limits(limits); - Some(reader.decode().ok()?.into_rgba8().into_raw()) -} - -/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`: -/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and -/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not. -fn stop_reach(g: &Gradient) -> Vec { - g.stops - .iter() - .skip(1) - .scan(f64::NEG_INFINITY, |m, s| { - *m = m.max(s.offset); - Some(*m) - }) - .collect() -} - -/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`). -fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color { - let t = match g.kind { - GradientKind::Linear => q.x, - GradientKind::Radial => q.x.hypot(q.y), - GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU, - }; - let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; - let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } }; - if t <= first.offset { - return first.color; - } - let k = reach.partition_point(|r| *r < t); - if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) { - let span = b.offset - a.offset; - let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; - // Affinity's midpoint bias (see `Stop::half_point`). - let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); - let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; - let (x, y) = (rgb(a.color), rgb(b.color)); - let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); - return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; - } - last.color -} - -fn invert(m: Affine) -> Option { - let [a, b, c, d, e, f] = m.0; - let det = a * d - b * c; - if !(det.is_finite() && det.abs() > 1e-12) { - return None; - } - let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) -} - -/// The blend of backdrop `b` and source `s` (sRGB, 0..1). -fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] { - use Blend::*; - let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0)); - match mode { - Normal | PassThrough | Erase => s, - Darken => sep(f64::min), - Lighten => sep(f64::max), - Multiply => sep(|b, s| b * s), - Screen => sep(screen), - ColorBurn => sep(burn), - ColorDodge => sep(dodge), - Blend::Overlay => sep(|b, s| hard_light(s, b)), - HardLight => sep(hard_light), - SoftLight => sep(soft_light), - Difference => sep(|b, s| (b - s).abs()), - Exclusion => sep(|b, s| b + s - 2.0 * b * s), - Add => sep(|b, s| b + s), - Subtract => sep(|b, s| b - s), - VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }), - LinearLight => sep(|b, s| b + 2.0 * s - 1.0), - PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), - HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), - Average => sep(|b, s| (b + s) / 2.0), - Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()), - Reflect => sep(reflect), - Glow => sep(|b, s| reflect(s, b)), - DarkerColor => { - if lum(s) < lum(b) { - s - } else { - b - } - } - LighterColor => { - if lum(s) > lum(b) { - s - } else { - b - } - } - Hue => set_lum(set_sat(s, sat(b)), lum(b)), - Saturation => set_lum(set_sat(b, sat(s)), lum(b)), - Blend::Color => set_lum(s, lum(b)), - Luminosity => set_lum(b, lum(s)), - } -} - -fn screen(b: f64, s: f64) -> f64 { - b + s - b * s -} - -fn burn(b: f64, s: f64) -> f64 { - if b >= 1.0 { - 1.0 - } else if s <= 0.0 { - 0.0 - } else { - 1.0 - ((1.0 - b) / s).min(1.0) - } -} - -fn dodge(b: f64, s: f64) -> f64 { - if b <= 0.0 { - 0.0 - } else if s >= 1.0 { - 1.0 - } else { - (b / (1.0 - s)).min(1.0) - } -} - -fn hard_light(b: f64, s: f64) -> f64 { - if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } -} - -fn soft_light(b: f64, s: f64) -> f64 { - if s <= 0.5 { - b - (1.0 - 2.0 * s) * b * (1.0 - b) - } else { - let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; - b + (2.0 * s - 1.0) * (d - b) - } -} - -fn reflect(b: f64, s: f64) -> f64 { - if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) } -} - -fn lum(c: [f64; 3]) -> f64 { - 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] -} - -fn clip_color(c: [f64; 3]) -> [f64; 3] { - let l = lum(c); - let n = c[0].min(c[1]).min(c[2]); - let x = c[0].max(c[1]).max(c[2]); - c.map(|v| { - let mut v = v; - if n < 0.0 && l - n > 1e-12 { - v = l + (v - l) * l / (l - n); - } - if x > 1.0 && x - l > 1e-12 { - v = l + (v - l) * (1.0 - l) / (x - l); - } - v.clamp(0.0, 1.0) - }) -} - -fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] { - let d = l - lum(c); - clip_color(c.map(|v| v + d)) -} - -fn sat(c: [f64; 3]) -> f64 { - c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) -} - -fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] { - let max = c[0].max(c[1]).max(c[2]); - let min = c[0].min(c[1]).min(c[2]); - if max - min <= 1e-12 { - return [0.0; 3]; - } - c.map(|v| (v - min) * s / (max - min)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn solid(c: Color, opacity: f64, blend: Blend) -> Node { - Node { - class: crate::stream::Tag::of(b"ShpN"), - name: String::new(), - visible: true, - locked: false, - opacity: 1.0, - blend: Blend::Normal, - kind: Kind::Shape { - path: Default::default(), - fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })], - strokes: vec![], - even_odd: false, - }, - mask: None, - pixel_mask: None, - effects: vec![Effect::ColorOverlay { color: c, opacity, blend }], - children: vec![], - } - } - - fn fill(n: &Node) -> Color { - match &n.kind { - Kind::Shape { fills, .. } => match fills.first() { - Some(Paint::Solid(c)) => *c, - other => panic!("{other:?}"), - }, - other => panic!("{other:?}"), - } - } - - #[test] - fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() { - let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; - let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); - assert_eq!(fill(&n), white); - assert!(n.effects.is_empty(), "the overlay is baked, not kept"); - // Half way, mixed in RGB. - let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); - assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 }); - } - - #[test] - fn blend_modes_follow_their_formulas() { - let b = [0.25, 0.5, 1.0]; - assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]); - assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]); - assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]); - assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]); - // Color keeps the backdrop's luminosity with the source's hue and saturation. - let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]); - assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}"); - } - - #[test] - fn overlays_recolour_pixels_inside_their_alpha() { - let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); - n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY }); - let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); - let Kind::Image(img) = &out.kind else { panic!() }; - assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0])); - } - - #[test] - fn a_gradient_overlay_over_one_colour_becomes_that_gradient() { - let g = Gradient { - kind: GradientKind::Linear, - stops: vec![ - Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 }, - Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 }, - ], - transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]), - }; - let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal); - n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }]; - let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); - let Kind::Shape { fills, .. } = &out.kind else { panic!() }; - assert_eq!(fills.first(), Some(&Paint::Gradient(g))); - } - - #[test] - fn a_group_overlay_lies_over_its_childrens_overlays() { - let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }; - let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }; - let mut group = solid(red, 0.5, Blend::Normal); - group.kind = Kind::Group; - group.children = vec![solid(blue, 1.0, Blend::Normal)]; - // The copy (2 nodes), the child's overlay (1) and the group's (2). - let mut work = 5; - let out = apply(&group, &mut work, &mut |_| {}).into_owned(); - assert_eq!(work, 0); - let child = out.children.first().unwrap(); - // Blue first, then half of the group's red over it. - assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 }); - assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked"); - // With no work left for the group's overlay, the child's still applies. - let mut warnings = Vec::new(); - let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned(); - assert_eq!(fill(out.children.first().unwrap()), blue); - assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); - } - - #[test] - fn overlays_stop_at_the_work_one_import_may_take() { - let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); - n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY }); - // 16 pixels and the node, for the copy and for the overlay: 34. - let mut work = 40; - let mut warnings = Vec::new(); - assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_))); - assert_eq!((work, warnings.len()), (6, 0)); - assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_))); - assert_eq!(work, 6); - assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); - } -} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs deleted file mode 100644 index c7b2acbbd3..0000000000 --- a/crates/affinity/src/paint.rs +++ /dev/null @@ -1,403 +0,0 @@ -//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint, -//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index). - -use crate::model::{Affine, Reader}; -use crate::stream::{ObjId, Value}; - -/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless -/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Color { - Rgb { - r: f64, - g: f64, - b: f64, - a: f64, - }, - Cmyk { - c: f64, - m: f64, - y: f64, - k: f64, - a: f64, - }, - Gray { - v: f64, - a: f64, - }, - /// CIE L*a*b* (D50). - Lab { - l: f64, - a: f64, - b: f64, - alpha: f64, - }, -} - -impl Color { - pub fn alpha(&self) -> f64 { - match self { - Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a, - Color::Lab { alpha, .. } => *alpha, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GradientKind { - Linear, - /// Elliptical and radial: distance from the centre in unit space. - Radial, - /// Angle around the centre. - Conical, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Stop { - pub offset: f64, - pub color: Color, - /// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend). - pub bias: f64, -} - -impl Stop { - /// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve - /// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`. - pub fn half_point(&self) -> f64 { - let k = 1.0 + 8.0 * (self.bias - 0.5).abs(); - let h = 0.5f64.powf(1.0 / k); - if self.bias >= 0.5 { h } else { 1.0 - h } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Gradient { - pub kind: GradientKind, - pub stops: Vec, - /// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit - /// circle; conical starts along unit +x and turns towards unit +y. - pub transform: Affine, -} - -#[derive(Debug, Clone, PartialEq, Default)] -pub enum Paint { - #[default] - None, - Solid(Color), - Gradient(Gradient), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Cap { - Butt, - Round, - Square, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Join { - Miter, - Round, - Bevel, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Align { - Center, - Inside, - Outside, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Stroke { - pub paint: Paint, - /// Width in document pixels. - pub width: f64, - pub cap: Cap, - pub join: Join, - pub miter_limit: f64, - pub align: Align, - /// Dash and gap lengths in document pixels, and the phase. - pub dash: Option<(Vec, f64)>, - /// Painted below the fill instead of above it. - pub behind: bool, - /// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in - /// order along the path, `None` for a stroke of the same width all along. - pub profile: Option>, -} - -/// Gradient stops per gradient. -const MAX_STOPS: usize = 1024; - -/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs). -const MAX_DASHES: usize = 64; - -fn floats(v: &Value) -> Option> { - match v { - Value::Floats(f) => Some(f.clone()), - Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()), - _ => None, - } -} - -/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). -pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { - color_within(r, id, 0) -} - -/// The most spot colours that stand for one another followed. -const MAX_SPOT_CHAIN: u32 = 8; - -/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here. -fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option { - let s = r.s; - let class = s.class(id)?; - let col = || -> Option> { - let v = floats(s.field(id, b"_col")?)?; - v.iter().all(|f| f.is_finite()).then_some(v) - }; - Some(match &class.0.to_be_bytes() { - b"RGBA" => { - let v = col()?; - Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) } - } - b"HSLA" => { - let v = col()?; - let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?); - Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) } - } - b"CMYK" => { - let v = col()?; - Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) } - } - b"GRAY" => { - let v = col()?; - Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) } - } - b"LABA" => { - let b = s.bytes(id, b"_col")?; - let u = |i: usize| -> Option { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) }; - Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? } - } - b"Pant" => { - r.warn("spot colours (imported as their RGB equivalent)"); - if depth >= MAX_SPOT_CHAIN { - return None; - } - return s - .obj(id, b"srgb") - .and_then(|c| color_within(r, c, depth + 1)) - .or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1))); - } - b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, - _ => { - r.warn("colours in an unknown model"); - return None; - } - }) -} - -pub(crate) fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) { - let c = (1.0 - (2.0 * l - 1.0).abs()) * s; - let hp = (h.rem_euclid(1.0)) * 6.0; - let x = c * (1.0 - (hp % 2.0 - 1.0).abs()); - let (r, g, b) = match hp as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = l - c / 2.0; - (r + m, g + m, b + m) -} - -/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's -/// space to document pixels. -pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option { - let s = r.s; - let (fill, unit) = if s.is(id, b"FDsc") { - let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - (s.obj(id, b"FDeF")?, unit) - } else { - (id, Affine::IDENTITY) - }; - match &s.class(fill)?.0.to_be_bytes() { - b"FilN" => Some(Paint::None), - b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)), - b"FilG" => { - let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) { - Some(0) | None => GradientKind::Linear, - Some(1 | 2) => GradientKind::Radial, - Some(3) => GradientKind::Conical, - Some(_) => { - r.warn("gradients of an unknown kind (imported as linear)"); - GradientKind::Linear - } - }; - let grad = s.obj(fill, b"Grad")?; - let cols = s.objs(grad, b"Cols"); - // Convert only the positions that pair with a colour, as shared attributes repeat this per run. - let posn: &[Value] = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a, - _ => &[], - }; - let mut stops = Vec::new(); - for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) { - let color = color(r, *c)?; - let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); - let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); - stops.push(Stop { offset, color, bias }); - } - if stops.is_empty() { - return Some(Paint::None); - } - Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) })) - } - b"FilB" => { - r.warn("bitmap and pattern fills (left empty)"); - Some(Paint::None) - } - _ => { - r.warn("fills of an unknown kind (left empty)"); - Some(Paint::None) - } - } -} - -fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option { - let items = r.s.objs(id, list); - let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); - items.get(i).or_else(|| items.first()).copied() -} - -/// The fills of `list` (artboard backgrounds use `BFFl`). -pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec { - let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil")); - d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect() -} - -/// A node's fill and stroke. `local` maps the node's space to document pixels. -pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec, Vec) { - let s = r.s; - let fills = fills(r, id, local, b"BFFl"); - if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 { - r.warn("several fills or strokes on one object (only the active one is imported)"); - } - if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) { - r.warn("fill opacity (imported at full fill opacity)"); - } - if s.obj(id, b"Trns").is_some() { - r.warn("transparency gradients (imported without them)"); - } - let (pen, style) = if s.field(id, b"LIFl").is_some() { - (current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr")) - } else { - (s.obj(id, b"PFil"), s.obj(id, b"LSty")) - }; - let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 })); - let stroke = match (pen, style) { - (Some(pen), Some(style)) => stroke(r, pen, style, local, behind), - _ => None, - }; - (fills, stroke.into_iter().collect()) -} - -pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option { - let s = r.s; - let paint = descriptor(r, pen, local)?; - if paint == Paint::None { - return None; - } - let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc }; - let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?; - // "Scale with object": the weight follows the object's transform; otherwise it is absolute. - let scales = s.bool(ldsc, b"LDSc").unwrap_or(true); - let width = if scales { weight * local.scale() } else { weight }; - let data = s.bytes(lsty, b"Data").unwrap_or_default(); - // Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a - // stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their - // owner made, where every such stroke is invisible in Affinity's own PDF export, and checked - // against the public corpus's thumbnails. - if data.get(10) == Some(&0) { - return None; - } - let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0); - // Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample; - // (2, 2) ends plain lines square and brush lines round; joins import round, which never - // looks wrong. - let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) { - (Some(0), Some(0), _) => (Cap::Butt, Join::Miter), - (Some(0), Some(_), _) => (Cap::Butt, Join::Round), - (Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round), - (Some(2), Some(2), _) => (Cap::Square, Join::Round), - _ => (Cap::Round, Join::Round), - }; - let align = match s.int(ldsc, b"LDSa") { - Some(1) => Align::Inside, - Some(2) => Align::Outside, - _ => Align::Center, - }; - let dash = match s.field(lsty, b"Patn") { - Some(Value::Array(a)) if a.len() > MAX_DASHES => { - r.warn("dash patterns of more than 64 lengths (imported as solid lines)"); - None - } - Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => { - let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect(); - while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) { - v.truncate(v.len() - 2); - } - let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0); - valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width)) - } - _ => None, - }; - if s.obj(lsty, b"Brus").is_some() { - r.warn("brush strokes (imported as plain strokes)"); - } - // The pressure curve sets the width along the path; one at full width all along is a plain - // stroke. Every curve read counts against the document's points: one at full width counts its - // points, and one that can't be read counts the most a curve may have. Once the points are - // used up the rest are not read. - const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)"; - let ldep = s.obj(ldsc, b"LDeP"); - if ldep.is_some() && r.budget.pressure == 0 { - r.warn(BEYOND); - } - let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) { - Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) { - Some(left) => { - r.budget.pressure = left; - (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points) - } - None => { - r.budget.pressure = 0; - r.warn(BEYOND); - None - } - }, - Some(None) => { - r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS); - r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)"); - None - } - None => None, - }; - Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hsl_lightness_1_is_white_and_0_is_black() { - assert_eq!(hsl(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)); - assert_eq!(hsl(0.5, 1.0, 1.0), (1.0, 1.0, 1.0)); - assert_eq!(hsl(0.0, 1.0, 0.0), (0.0, 0.0, 0.0)); - let (r, g, b) = hsl(0.0, 1.0, 0.5); - assert!((r - 1.0).abs() < 1e-6 && g.abs() < 1e-6 && b.abs() < 1e-6); - } -} diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs deleted file mode 100644 index d3341eeb1c..0000000000 --- a/crates/affinity/src/preview.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the -//! document, at most 512 pixels on the long side in real files, never the document itself. - -use crate::Error; -use crate::container::header; - -pub const MAX_PREVIEW_BYTES: usize = 16 << 20; -pub const MAX_PREVIEW_DIMENSION: u32 = 4096; - -/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions. -#[derive(Debug)] -pub struct Preview<'a> { - pub png: &'a [u8], - pub width: u32, - pub height: u32, -} - -fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> { - let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?; - bytes.get(offset..end).ok_or(Error::Malformed("truncated record")) -} - -fn le32(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 4)?; - Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?)) -} - -fn be32(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 4)?; - Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?)) -} - -/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed -/// pixels must still be validated by the consuming image decoder, with allocation limits. -pub fn preview(bytes: &[u8]) -> Result, Error> { - let h = header(bytes)?; - if h.thumbnail_offset == 0 { - return Err(Error::Unsupported("no embedded preview")); - } - let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?; - if offset < 72 { - return Err(Error::Malformed("thumbnail overlaps the file header")); - } - let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?; - if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" { - return Err(Error::Malformed("missing indexed thumbnail")); - } - if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] { - return Err(Error::Unsupported("thumbnail record version or encoding")); - } - let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?; - if size > MAX_PREVIEW_BYTES { - return Err(Error::Limit("encoded preview exceeds 16 MiB")); - } - if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 { - return Err(Error::Malformed("inconsistent thumbnail record lengths")); - } - let png = bytes_at(record, 29, size)?; - if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" { - return Err(Error::Malformed("thumbnail is not a PNG")); - } - let width = be32(png, 16)?; - let height = be32(png, 20)?; - if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION { - return Err(Error::Limit("preview dimensions must be 1..4096")); - } - // Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG. - let mut pos = 8usize; - let (mut palette, mut image_data, mut image_data_ended) = (false, false, false); - loop { - let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?; - let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?; - let chunk = bytes_at(png, pos, chunk_size)?; - let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?; - if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? { - return Err(Error::Malformed("PNG chunk checksum")); - } - let kind = bytes_at(chunk, 4, 4)?; - if !kind.iter().all(u8::is_ascii_alphabetic) { - return Err(Error::Malformed("PNG chunk type")); - } - match kind { - b"IHDR" if pos == 8 => {} - b"IHDR" => return Err(Error::Malformed("duplicate PNG header")), - b"PLTE" => { - if palette || image_data { - return Err(Error::Malformed("PNG palette order")); - } - palette = true; - } - b"IDAT" => { - if image_data_ended { - return Err(Error::Malformed("non-contiguous PNG image data")); - } - image_data = true; - } - b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")), - b"IEND" => {} - _ if kind.first().is_some_and(u8::is_ascii_uppercase) => { - return Err(Error::Unsupported("unknown critical PNG chunk")); - } - _ => {} - } - if image_data && kind != b"IDAT" { - image_data_ended = true; - } - // Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not - // necessarily bound aggregate compressed metadata; leave these layouts unsupported. - if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") { - return Err(Error::Unsupported("compressed PNG metadata in the preview")); - } - if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") { - return Err(Error::Unsupported("animated PNG preview")); - } - pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?; - if kind == b"IEND" { - if length != 0 || pos != png.len() { - return Err(Error::Malformed("PNG end or trailing data")); - } - break; - } - } - Ok(Preview { png, width, height }) -} diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs deleted file mode 100644 index bb8c1c88b8..0000000000 --- a/crates/affinity/src/raster.rs +++ /dev/null @@ -1,650 +0,0 @@ -//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file -//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of -//! 256 bytes × 256 rows per channel, each its own archive entry. - -use std::collections::HashMap; - -use crate::Error; -use crate::model::{Affine, Image, Pixels, Reader}; -use crate::stream::{self, ObjId, Tag, Value}; - -/// Pixels decoded per layer (the content's bounding box, not the canvas). -const MAX_PIXELS: u64 = 64 << 20; -const TILE: usize = 256; -const TILE_BYTES: usize = TILE * TILE; - -struct CachedPixels { - tiles: HashMap>, - source: Option<(usize, Vec)>, -} - -pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> { - let s = r.s; - let Some(bitmap) = s.obj(id, b"Bitm") else { - r.warn("pixel layers without pixels"); - return Ok(None); - }; - if !s.is(bitmap, b"DyBm") { - r.warn("pixel layers stored in an unknown way"); - return Ok(None); - } - let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - if width == 0 || height == 0 { - return Ok(None); - } - // A placed image: the original file, byte for byte. - let has_tiles = s.field(bitmap, b"Sta1").is_some(); - if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty()) - && (s.is(id, b"ImgN") || !has_tiles) - { - match original(r, name) { - // HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels - // stored as tiles are used instead (see `decode` when the tiles point back at the original). - Ok(bytes) if is_heif(&bytes) && has_tiles => {} - Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })), - Err(Error::Limit(e)) => return Err(Error::Limit(e)), - Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"), - } - } - // The content's bounding box, so a mostly empty canvas-sized layer stays small. - let crop = s - .field(id, b"BitR") - .and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None }) - .filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1) - .map(|v| { - ( - v[0].clamp(0, i64::from(width)) as u32, - v[1].clamp(0, i64::from(height)) as u32, - v[2].clamp(0, i64::from(width)) as u32, - v[3].clamp(0, i64::from(height)) as u32, - ) - }) - .unwrap_or((0, 0, width, height)); - let source_tiles_only = - (1..=4).all(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if !a.is_empty() && a.iter().all(|v| *v == Value::UInt(5)))); - if s.field(bitmap, b"Bckg").is_some() && has_tiles && !source_tiles_only && !s.is(id, b"ImgN") { - r.warn("edited placed images use their stored pixels"); - } - decode(r, bitmap, crop, world) -} - -/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand. -fn is_heif(bytes: &[u8]) -> bool { - bytes.get(4..8) == Some(b"ftyp".as_slice()) - && bytes.get(8..12).is_some_and(|brand| { - [b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice()) - }) -} - -/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read -/// over `over` (document pixels) when given; outside its pixels a mask hides everything. -pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option) -> Result, Error> { - let s = r.s; - let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) }; - let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) { - r.warn("pixel masks in an unknown format (imported without them)"); - return Ok(None); - } - let crop = match over.map(|o| crop_to(o, world, width, height)) { - Some(Some(c)) => c, - // What it masks lies outside it: all hidden, which one black pixel says as well. - Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })), - None => (0, 0, width, height), - }; - decode(r, bitmap, crop, world) -} - -/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover -/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap. -fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { - let inv = invert(world)?; - let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y })); - let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - for p in pts { - if !(p.x.is_finite() && p.y.is_finite()) { - return Some((0, 0, width, height)); - } - (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); - } - let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32; - let (cx0, cy0, cx1, cy1) = - (clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height)); - (cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1)) -} - -fn invert(m: Affine) -> Option { - let [a, b, c, d, e, f] = m.0; - let det = a * d - b * c; - if !(det.is_finite() && det.abs() > 1e-12) { - return None; - } - let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) -} - -/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches. -pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> { - let s = r.s; - let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) }; - let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) }; - let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - if width == 0 || height == 0 { - return Ok(None); - } - let [x0, y0, x1, y1] = bbox; - // The instance's origin is the centre of the embedded content (checked against the - // thumbnails of documents with embedded documents and SVG files). - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world); - decode(r, cache, (0, 0, width, height), place) -} - -/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document). -fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result, Error> { - decode_within(r, bitmap, crop, world, MAX_PIXELS) -} - -/// [`decode`] with at most `max_pixels` decoded pixels. -fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result, Error> { - let s = r.s; - let (x0, y0, x1, y1) = crop; - if x1 <= x0 || y1 <= y0 { - return Ok(None); - } - let (w, h) = (x1 - x0, y1 - y0); - // Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their - // resolution: every `step`th pixel of every `step`th row. - let mut step = 1u32; - while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels { - step *= 2; - if step > 1 << 12 { - r.warn("pixel layers too large to read (left out)"); - return Ok(None); - } - } - // Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract. - let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)); - let Some(left) = r.budget.pixels.checked_sub(pixels) else { - r.warn("pixel layers beyond the pixels one document may hold"); - return Ok(None); - }; - r.budget.pixels = left; - let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f); - let (channels, bps) = match format { - Some(0) => (4, 1), - Some(1) => (4, 2), - Some(6) => (1, 1), - Some(7) => (1, 2), - Some(4) => (5, 1), - _ => { - r.warn("pixel layers in grey, Lab or 32-bit formats"); - return Ok(None); - } - }; - let mut planes = Vec::with_capacity(channels); - // Current .af documents can keep level-zero tiles in their embedded JPEG/PNG instead of - // duplicating the pixels. State 5 is checked in the public Patchy embedded-jpeg fixture. - // Support only the observed, same-size RGBA8 image with a zero bitmap origin; other - // representations still receive the existing loss warning rather than guessing a mapping. - let needs_source = (1..=channels).any(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if a.contains(&Value::UInt(5)))); - let source = if needs_source && format == Some(0) && s.int(bitmap, b"LInf") == Some(0) && s.int(bitmap, b"TInf") == Some(0) { - if let Some(name) = s.entry(bitmap, b"Bckg") { - let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - // The whole source is decoded for every layer that reads it, so charge it all, not only the crop. - let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else { - return Err(Error::Limit("embedded image pixels")); - }; - r.budget.pixels = left; - match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) { - Ok(pixels) => Some((width as usize, pixels)), - Err(Error::Limit(e)) => return Err(Error::Limit(e)), - Err(_) => None, - } - } else { - None - } - } else { - None - }; - // Tiles kept only in an original that can't be decoded here are left empty (transparent). Where - // a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come - // out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said. - if needs_source && source.is_none() && channels > 1 { - let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.clone(), - _ => Vec::new(), - }; - let alpha = states(channels); - let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5)))); - if blank { - r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)"); - return Ok(None); - } - } - if step > 1 { - // Its stored tiles must fit what is left of the import's extraction budget. - let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum(); - if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total { - r.warn("pixel layers too large to read (left out)"); - return Ok(None); - } - r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)"); - } - let mut cache = CachedPixels { tiles: HashMap::new(), source }; - for c in 1..=channels { - planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?); - } - let (w, h) = (w.div_ceil(step), h.div_ceil(step)); - let n = (w as usize) * (h as usize); - let k = f64::from(step); - let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world); - if channels == 5 { - // Ink amounts and alpha as stored (8 bits), interleaved. - let mut cmyka = vec![0u8; n * 5]; - for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() { - for (c, v) in px.iter_mut().enumerate() { - *v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0); - } - } - return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform })); - } - let mut rgba = vec![0u8; n * 4]; - let sample = |p: &Vec, i: usize| -> f64 { - if bps == 2 { - f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0 - } else { - f64::from(p.get(i).copied().unwrap_or(0)) / 255.0 - } - }; - for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); - let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) }; - px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); - } - Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform })) -} - -/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`. -fn original(r: &mut Reader, name: &str) -> Result, Error> { - let bytes = r.archive.read(name)?; - let s = stream::parse(&bytes)?; - let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?; - match root.get(Tag::of(b"Data")) { - Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()), - _ => Err(Error::Malformed("embedded image without data")), - } -} - -/// Decode the source under the same pixel budget as tiled layers, checking its actual -/// dimensions before allocating decoded pixels. Only JPEG and PNG sources are supported. -fn source_pixels(bytes: &[u8], width: u32, height: u32) -> Result, Error> { - use image::{ImageFormat, ImageReader}; - use std::io::Cursor; - - if u64::from(width) * u64::from(height) > MAX_PIXELS { - return Err(Error::Limit("embedded image pixels")); - } - let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Error::Malformed("embedded image format"))?; - if !matches!(reader.format(), Some(ImageFormat::Jpeg | ImageFormat::Png)) { - return Err(Error::Unsupported("embedded source pixel format")); - } - let dimensions = ImageReader::new(Cursor::new(bytes)) - .with_guessed_format() - .map_err(|_| Error::Malformed("embedded image format"))? - .into_dimensions() - .map_err(|_| Error::Malformed("embedded image dimensions"))?; - if dimensions != (width, height) || width == 0 || height == 0 { - return Err(Error::Malformed("embedded source and bitmap dimensions differ")); - } - let mut limits = image::Limits::default(); - limits.max_image_width = Some(width); - limits.max_image_height = Some(height); - limits.max_alloc = Some(MAX_PIXELS * 8); - reader.limits(limits); - let decoded = reader.decode().map_err(|e| match e { - image::ImageError::Limits(_) => Error::Limit("embedded image decoding"), - _ => Error::Malformed("embedded image decoding"), - })?; - Ok(decoded.into_rgba8().into_raw()) -} - -fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] { - [prefix[0], prefix[1], prefix[2], b'0' + c as u8] -} - -/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state). -fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec) { - let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); - let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); - // A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference. - let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE)); - let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), - _ => Vec::new(), - }; - (tw, th, states) -} - -/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`. -fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec { - let (tw, th, states) = grid(s, bitmap, c, bps); - let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); - let (py0, py1) = (y0 as usize, (y0 + h) as usize); - let mut out = Vec::new(); - let mut next = 0usize; - for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { - if *state != 4 { - continue; - } - let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE); - if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) { - out.push(next); - } - next += 1; - } - out -} - -/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of -/// every `step`th row; `bps` bytes per sample. -fn plane( - r: &mut Reader, - bitmap: ObjId, - c: usize, - bps: usize, - (x0, y0, w, h): (u32, u32, u32, u32), - step: u32, - cache: &mut CachedPixels, -) -> Result, Error> { - let CachedPixels { tiles: cache, source } = cache; - let s = r.s; - let (tw, th, states) = grid(s, bitmap, c, bps); - let tiles = s.objs(bitmap, &tag(b"Idx", c)); - let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize }; - let row = at.w.div_ceil(at.step) * bps; - let mut out = vec![0u8; row * at.h.div_ceil(at.step)]; - let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); - let (py0, py1) = (y0 as usize, (y0 + h) as usize); - let mut next = 0usize; - for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { - let (ty, tx) = (t / tw.max(1), t % tw.max(1)); - let fill = match state { - 0 | 1 => None, - 2 => Some(0xFF), - 4 => { - let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) }; - next += 1; - let (ox, oy) = (tx * TILE, ty * TILE); - if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 { - continue; - } - let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string(); - let read = |r: &mut Reader| -> Result, Error> { - let mut data = r.archive.read(&name)?; - // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. - if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { - data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec(); - } - if data.len() != TILE_BYTES { - return Err(Error::Malformed("pixel tile size")); - } - Ok(data) - }; - // A layer read at a lower resolution is too large to keep its tiles around. - if at.step > 1 { - let tile = read(r)?; - copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); - continue; - } - if !cache.contains_key(&name) { - let data = read(r)?; - cache.insert(name.clone(), data); - } - let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?; - copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); - continue; - } - 3 => { - r.warn("32-bit pixel layers"); - None - } - 5 => { - if let Some((source_width, pixels)) = source.as_ref() { - let (ox, oy) = (tx * TILE, ty * TILE); - copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)); - continue; - } - r.warn("pixel layers drawn from an embedded image (left empty)"); - None - } - _ => return Err(Error::Malformed("unknown pixel tile state")), - }; - if let Some(v) = fill { - copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v); - } - } - Ok(out) -} - -/// Where a plane's pixels come from: the crop (pixels) and the sampling step. -struct Crop { - bps: usize, - x0: usize, - y0: usize, - w: usize, - h: usize, - step: usize, -} - -/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every -/// `step`th pixel of every `step`th row. -fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { - let row = c.w.div_ceil(c.step) * c.bps; - let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps); - for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) { - if !(y - c.y0).is_multiple_of(c.step) { - continue; - } - let out_row = (y - c.y0) / c.step * row; - for x in ox.max(bx0)..(ox + TILE).min(bx1) { - let px = x / c.bps - c.x0; - if !px.is_multiple_of(c.step) { - continue; - } - if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) { - *b = at(x - ox, y - oy); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::Kind; - use crate::synth::{self, F, Method, tag}; - - #[test] - fn heif_originals_are_recognised_by_their_brand() { - assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0")); - assert!(is_heif(b"\0\0\0\x1cftypavif")); - assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo"); - assert!(!is_heif(&png())); - assert!(!is_heif(b"ftyp")); - } - - fn png() -> Vec { - let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255])); - let mut bytes = std::io::Cursor::new(Vec::new()); - pixels.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); - bytes.into_inner() - } - - /// An original synthetic mixed source/cache bitmap. It exercises reader recovery, - /// not a claim that Affinity itself accepts the synthetic container. - fn fixture(source: &[u8], origin: i32) -> Vec { - fixture_in(source, origin, false) - } - - /// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored. - fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec { - let channels = if cmyk { 5 } else { 4 }; - let mut bitmap = vec![ - (tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)), - (tag(b"BmpW"), F::I32(300)), - (tag(b"BmpH"), F::I32(2)), - (tag(b"LInf"), F::I32(origin)), - (tag(b"TInf"), F::I32(0)), - (tag(b"Bckg"), F::Entry("c/1".into())), - ]; - for c in 1..=channels { - let states = match (cmyk, c) { - (false, _) => vec![5, 4], - (true, 5) => vec![4, 4], - (true, _) => vec![5, 5], - }; - bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2))); - bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states))); - bitmap.push(( - tag(&super::tag(b"Idx", c)), - F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]), - )); - } - let mut crop = vec![0x17]; - crop.extend(tag(b"BitR").0.to_le_bytes()); - crop.extend([250i32, 0, 270, 2].into_iter().flat_map(i32::to_le_bytes)); - let raster = F::Def( - 10, - vec![tag(b"Rstr")], - vec![ - (tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), - (tag(b"BitR"), F::Raw(crop)), - (tag(b"Xfrm"), F::F64s(vec![0.0, -2.0, 10.0, 2.0, 0.0, 20.0])), - ], - ); - let spread = F::Def(11, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![raster]))]); - let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); - let mut blob = vec![0x2d]; - blob.extend(tag(b"Data").0.to_le_bytes()); - blob.extend((source.len() as u32).to_le_bytes()); - blob.extend(source); - let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]); - let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect(); - let names: Vec = (1..=channels).map(|c| format!("d/{c}")).collect(); - let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)]; - for (name, tile) in names.iter().zip(&tiles) { - entries.push((name.as_str(), tile.as_slice(), Method::Zlib)); - } - synth::container(&entries, None) - } - - #[test] - fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() { - let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap(); - assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings); - assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind); - } - - #[test] - fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() { - let bytes = fixture(&png(), 0); - let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap(); - let doc = archive.read("doc.dat").unwrap(); - let s = stream::parse(&doc).unwrap(); - let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap(); - let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0); - // 20 × 2 pixels over a budget of 10: every other pixel of every other row. - let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap(); - assert_eq!((image.width, image.height), (10, 1)); - assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two"); - let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; - for x in 0..10 { - let sx = 2 * x; - let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] }; - assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}"); - } - assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)")); - // Within budget, nothing changes. - let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap(); - assert_eq!((image.width, image.height), (20, 2)); - } - - #[test] - fn source_tiles_and_cached_edits_survive_crop_rotation_and_tile_boundaries() { - let doc = crate::read(&fixture(&png(), 0), crate::Limits::default()).unwrap(); - assert_eq!(doc.warnings, ["edited placed images use their stored pixels"]); - let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() }; - assert_eq!((image.width, image.height), (20, 2)); - assert_eq!(image.transform, Affine([0.0, 2.0, -2.0, 0.0, 10.0, 520.0])); - let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; - for y in 0..2 { - for x in 0..20 { - let expected = if x + 250 < 256 { [((x + 250) % 251) as u8, (y * 31) as u8, 77, 255] } else { [200, 10, 30, 255] }; - assert_eq!(&pixels[(y * 20 + x) * 4..(y * 20 + x + 1) * 4], &expected); - } - } - } - - #[test] - fn unreadable_sources_and_unknown_origins_preserve_cached_edits_and_warn() { - for bytes in [fixture(b"invalid image", 0), fixture(&png(), 1)] { - let doc = crate::read(&bytes, crate::Limits::default()).unwrap(); - assert!(doc.warnings.iter().any(|w| w == "pixel layers drawn from an embedded image (left empty) (4×)")); - let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() }; - let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; - assert_eq!(&pixels[..4], &[0, 0, 0, 0]); - assert_eq!(&pixels[6 * 4..7 * 4], &[200, 10, 30, 255]); - } - } - - /// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile - /// of 0xFF). Written by hand for the reader, like [`fixture`]. - fn flat_layers(count: u32, side: i32) -> Vec { - let layer = |i: u32| { - let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))]; - for c in 1..=4 { - bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2]))); - } - F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))]) - }; - let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]); - let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); - synth::container(&[("doc.dat", &doc, Method::Zlib)], None) - } - - /// #1704: the pixels of all pixel layers together are bounded, also when the archive holds - /// no data for them: the layers past the allowance are left out with a note. - #[test] - fn pixel_layers_share_one_allowance_for_the_whole_document() { - let file = flat_layers(5, 16); - let layers = |pixels: u64| { - let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap(); - let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count(); - (n, doc.warnings) - }; - assert_eq!(layers(5 * 256), (5, vec![])); - let (kept, warnings) = layers(3 * 256 + 255); - assert_eq!(kept, 3); - assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}"); - assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5); - } - - #[test] - fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() { - let bytes = png(); - assert!(matches!(source_pixels(&bytes, 299, 2), Err(Error::Malformed(_)))); - assert!(matches!(source_pixels(&bytes, u32::MAX, u32::MAX), Err(Error::Limit(_)))); - assert_eq!(source_pixels(&bytes, 300, 2).unwrap().len(), 300 * 2 * 4); - } -} diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs deleted file mode 100644 index a2503539f4..0000000000 --- a/crates/affinity/src/shapes.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline. -//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files. - -use std::f64::consts::{FRAC_PI_2, PI, TAU}; - -use crate::model::{Affine, Path, Point, Reader, SubPath}; -use crate::stream::{ObjId, Tag, Value}; - -/// Handle length of a quarter circle of radius 1. -const KAPPA: f64 = 0.552_284_749_830_793_4; -/// Vertices of a polygon or star. -const MAX_SIDES: i64 = 10_000; - -/// The last value of a tag: a shape changed from one kind to another keeps a stale first block. -fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> { - r.s.object(id)?.get_last(Tag::of(tag)) -} - -fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option { - match last(r, id, tag)? { - Value::Float(f) if f.is_finite() => Some(*f), - Value::UInt(u) => Some(*u as f64), - Value::Int(i) => Some(*i as f64), - _ => None, - } -} - -fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize { - let n = num(r, id, tag).unwrap_or(default).round(); - n.clamp(3.0, MAX_SIDES as f64) as usize -} - -fn pt(x: f64, y: f64) -> Point { - Point { x, y } -} - -fn polygon(pts: impl IntoIterator) -> Option { - let mut it = pts.into_iter(); - let start = it.next()?; - let segments = it.map(|p| [p, p, p]).collect(); - Some(SubPath { start, segments, closed: true }) -} - -/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1). -pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option { - let [x0, y0, x1, y1] = b; - let (w, h) = (x1 - x0, y1 - y0); - let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0); - let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin()); - let class = r.s.class(shape)?; - let sub = match &class.0.to_be_bytes() { - b"ShNR" | b"ShRR" => { - // Only the four corners' entries can matter: longer arrays are read no further. - let types: Vec> = match last(r, shape, b"CTyp") { - Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), - _ => Vec::new(), - }; - let radii: Vec = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), - _ => Vec::new(), - }; - // Corners follow the first one (top left) unless they were unlocked; then each has - // its own radius and type, clockwise from the top left. - let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false))); - let corners: [(f64, Option); 4] = std::array::from_fn(|i| { - let i = if locked { 0 } else { i }; - (radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten()) - }); - if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) { - polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])? - } else { - if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) { - r.warn("rectangles with concave or other corner types (imported with round corners)"); - } - let absolute = r.s.bool(shape, b"AbSz") == Some(true); - let [a, b2, c, d, ..] = world.0; - let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); - cornered_rect( - b, - corners.map(|(f, t)| { - let (crx, cry) = if !absolute { - (f * w.min(h), f * w.min(h)) - } else if sx > 0.0 && sy > 0.0 { - // An absolute radius is in document pixels, whatever the node's scale. - (f / sx, f / sy) - } else { - (0.0, 0.0) - }; - (crx, cry, t == Some(1)) - }), - ) - } - } - b"ShpE" => ellipse(cx, cy, rx, ry), - b"ShPy" => { - let n = sides(r, shape, b"Side", 5.0); - let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64; - if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) { - // Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse. - let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan(); - let v = |i: usize| on_ellipse(a(i), 1.0); - let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos()); - let segments = (0..n) - .map(|i| { - let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1)); - [pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q] - }) - .collect(); - SubPath { start: v(0), segments, closed: true } - } else { - polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))? - } - } - b"ShSt" => { - let n = sides(r, shape, b"Pnts", 5.0); - let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0); - if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) { - r.warn("stars with rounded or curved points (imported with sharp points)"); - } - polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))? - } - b"ShSS" => { - let n = sides(r, shape, b"Side", 5.0); - let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0); - let step = PI / n as f64; - let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos()); - polygon((0..n).flat_map(|k| { - let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64; - let (c, s) = (phi.cos(), phi.sin()); - [(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c))) - }))? - } - b"ShPi" => { - let raw_start = num(r, shape, b"AngS").unwrap_or(0.0); - let raw_end = num(r, shape, b"AngE").unwrap_or(TAU); - // Normalize in constant time. Repeatedly adding TAU can hang on finite values - // large enough that floating-point addition no longer changes the endpoint. - let start = raw_start.rem_euclid(TAU); - let delta = raw_end - raw_start; - let sweep = if raw_end >= raw_start { - delta.min(TAU) - } else if delta.is_finite() { - delta.rem_euclid(TAU) - } else { - (raw_end.rem_euclid(TAU) - start).rem_euclid(TAU) - }; - let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0); - pie(cx, cy, rx, ry, start, start + sweep, inner) - } - b"ShpT" => { - let p = num(r, shape, b"Pos ").unwrap_or(0.5); - polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])? - } - b"ShTz" => { - let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75)); - polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])? - } - _ => { - r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)"); - ellipse(cx, cy, rx, ry) - } - }; - Some(Path { subpaths: vec![sub] }) -} - -pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath { - SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true } -} - -fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath { - let (kx, ky) = (KAPPA * rx, KAPPA * ry); - SubPath { - start: pt(cx + rx, cy), - segments: vec![ - [pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)], - [pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)], - [pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)], - [pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)], - ], - closed: true, - } -} - -/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded, -/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along -/// one side shrink in proportion until they do. -fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath { - let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r }; - let [tl, tr, br, bl] = corners; - let (w, h) = (x1 - x0, y1 - y0); - let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)]; - let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)]; - let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r)); - // (where the corner leaves the previous side, its two handles, where it joins the next side) - let arcs = [ - [pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])], - [pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)], - [pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])], - [pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)], - ]; - let mut segments = Vec::with_capacity(8); - for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) { - segments.push([s, s, s]); - if s != e { - segments.push(if straight { [e, e, e] } else { [c1, c2, e] }); - } - } - SubPath { start: pt(x0 + rx[0], y0), segments, closed: true } -} - -/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are), -/// as cubic segments of at most a quarter turn. -fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) { - let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize; - let step = (a1 - a0) / n as f64; - let k = 4.0 / 3.0 * (step / 4.0).tan(); - let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin()); - let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos()); - for i in 0..n { - let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64); - let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e)); - out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]); - } -} - -fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath { - let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin()); - let mut segments = Vec::new(); - arc(cx, cy, rx, ry, a0, a1, &mut segments); - if inner > 0.0 { - let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin()); - segments.push([p, p, p]); - arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments); - } else { - let c = pt(cx, cy); - segments.push([c, c, c]); - } - SubPath { start, segments, closed: true } -} - -/// A polyline with circular corners of the given radii (Affinity's live corners on curves). -pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath { - let n = pts.len(); - let get = |i: isize| -> Option { - if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) } - }; - let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n); - for (i, (p, radius)) in pts.iter().enumerate() { - let (prev, next) = (get(i as isize - 1), get(i as isize + 1)); - let (Some(a), Some(b)) = (prev, next) else { - corners.push((*p, *p, *p, *p)); - continue; - }; - let (u1, l1) = unit(*p, a); - let (u2, l2) = unit(*p, b); - let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0); - let phi = cos.acos(); - if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 { - corners.push((*p, *p, *p, *p)); - continue; - } - let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0); - let r = t * (phi / 2.0).tan(); - let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r; - let s = pt(p.x + u1.x * t, p.y + u1.y * t); - let e = pt(p.x + u2.x * t, p.y + u2.y * t); - corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e)); - } - let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } }; - let mut segments = Vec::with_capacity(2 * n); - let mut cur = first.0; - let start = cur; - for (i, (s, c1, c2, e)) in corners.iter().enumerate() { - if i > 0 { - segments.push([cur, *s, *s]); - } - if s != e { - segments.push([*c1, *c2, *e]); - } - cur = *e; - } - if closed && cur != start { - segments.push([cur, start, start]); - } - SubPath { start, segments, closed } -} - -fn unit(from: Point, to: Point) -> (Point, f64) { - let (dx, dy) = (to.x - from.x, to.y - from.y); - let l = (dx * dx + dy * dy).sqrt(); - if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) } -} diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs deleted file mode 100644 index 9d31f35e70..0000000000 --- a/crates/affinity/src/stream.rs +++ /dev/null @@ -1,491 +0,0 @@ -//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry -//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the -//! document mapping can name what it could not import. - -use crate::Error; -use crate::container::Cursor; - -/// A four-character tag in its readable order (`Desc`, `Chld`). -#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Tag(pub u32); - -impl Tag { - pub const fn of(s: &[u8; 4]) -> Self { - Self(u32::from_be_bytes(*s)) - } -} - -impl std::fmt::Debug for Tag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes())) - } -} - -impl std::fmt::Display for Tag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Debug::fmt(self, f) - } -} - -/// Index of an object in [`Stream::objects`]. -pub type ObjId = usize; - -#[derive(Debug, Clone, PartialEq)] -pub enum Value { - Int(i64), - UInt(u64), - Float(f64), - Bool(bool), - Enum { - id: u16, - version: u16, - }, - Str(String), - /// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms). - Ints(Vec), - Floats(Vec), - /// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components). - Bytes(Vec), - Blob(Vec), - /// Name of another archive entry (raster tiles, embedded files). - Entry(String), - Flags(u64), - /// `None` is a null object. - Obj(Option), - Array(Vec), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Kind { - Root, - /// Defined once and referenced by id elsewhere. - Shared, - Inline, - /// Untagged fields identified by position (curve data). - Positional, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Object { - pub class: Tag, - pub kind: Kind, - /// Class chain, most derived first (`ShpN`, `VNod`, `Node`). - pub chain: Vec, - /// Fields in stream order; positional fields have tag 0. - pub fields: Vec<(Tag, Value)>, - /// For objects with many fields: each tag with the positions of its first and last field, - /// sorted by tag, so a lookup isn't a scan of every field (a shared object is looked up - /// again on every visit). Built by [`Object::index`]; empty for small objects. - index: Vec<(Tag, u32, u32)>, -} - -/// Objects with more fields than this get an [`Object::index`]; fewer are scanned. -const INDEXED_FIELDS: usize = 16; - -impl Object { - pub fn new(class: Tag, kind: Kind, chain: Vec, fields: Vec<(Tag, Value)>) -> Self { - let mut o = Self { class, kind, chain, fields, index: Vec::new() }; - o.index(); - o - } - - /// (Re)build the tag index after `fields` changed. - fn index(&mut self) { - self.index.clear(); - if self.fields.len() <= INDEXED_FIELDS { - return; - } - let mut index: Vec<(Tag, u32, u32)> = Vec::new(); - if index.try_reserve_exact(self.fields.len()).is_err() { - return; - } - // Fields beyond u32 positions are impossible: a stream holds at most MAX_VALUES values. - index.extend(self.fields.iter().enumerate().filter_map(|(i, (t, _))| u32::try_from(i).ok().map(|i| (*t, i, i)))); - // Stable: equal tags stay in stream order, so the run of a tag starts at its first field. - index.sort_by_key(|(t, _, _)| *t); - index.dedup_by(|later, first| { - if later.0 == first.0 { - first.2 = later.2; - true - } else { - false - } - }); - index.shrink_to_fit(); - self.index = index; - } - - fn position(&self, tag: Tag, last: bool) -> Option { - if self.index.is_empty() { - let mut fields = self.fields.iter().enumerate(); - let hit = if last { fields.rev().find(|(_, (t, _))| *t == tag) } else { fields.find(|(_, (t, _))| *t == tag) }; - return hit.map(|(i, _)| i); - } - let at = self.index.binary_search_by_key(&tag, |(t, _, _)| *t).ok()?; - let (_, first, end) = self.index.get(at)?; - usize::try_from(if last { *end } else { *first }).ok() - } - - /// The first field tagged `tag`. - pub fn get(&self, tag: Tag) -> Option<&Value> { - self.fields.get(self.position(tag, false)?).map(|(_, v)| v) - } - - /// The last field tagged `tag` (a shape changed from one kind to another keeps a stale first one). - pub fn get_last(&self, tag: Tag) -> Option<&Value> { - self.fields.get(self.position(tag, true)?).map(|(_, v)| v) - } -} - -#[derive(Debug)] -pub struct Stream { - pub root: ObjId, - pub objects: Vec, -} - -/// Deepest object nesting accepted; real documents stay well below (each layer level adds three). -pub const MAX_DEPTH: usize = 384; - -const MAX_OBJECTS: usize = 8_000_000; - -/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes -/// in memory however few it took in the file (a bool array packs eight in a byte), so the size -/// of the input alone doesn't bound what parsing allocates: a small compressed entry could -/// otherwise ask for tens of gigabytes. Real documents stay far below. -pub const MAX_VALUES: usize = 1 << 24; - -struct Parser<'a> { - c: Cursor<'a>, - objects: Vec, - /// Shared ids, defined at first use and referenced backwards. - shared: std::collections::HashMap, - depth: usize, - /// Values decoded so far, against [`MAX_VALUES`]. - values: usize, -} - -/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored. -pub fn parse(bytes: &[u8]) -> Result { - let mut c = Cursor::new(bytes, 0); - if c.take(4)? != b"\x00\xffKS" { - return Err(Error::Malformed("document stream signature")); - } - let version = c.u16()?; - if version > 2 { - return Err(Error::Unsupported("document stream version")); - } - let class = Tag(c.u32()?); - c.u16()?; - if version == 2 { - c.u32()?; - } - let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 }; - let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?; - let fields = p.fields(true, class)?; - if let Some(o) = p.objects.get_mut(root) { - o.fields = fields; - o.index(); - } - Ok(Stream { root, objects: p.objects }) -} - -impl Parser<'_> { - fn push(&mut self, o: Object) -> Result { - if self.objects.len() >= MAX_OBJECTS { - return Err(Error::Limit("too many objects")); - } - self.objects.push(o); - Ok(self.objects.len() - 1) - } - - /// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them. - fn spend(&mut self, n: usize) -> Result<(), Error> { - self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?; - Ok(()) - } - - /// An empty array with room for `n` values, paid for first; an allocation that still fails - /// is an error, not an abort. - fn vec(&mut self, n: usize) -> Result, Error> { - self.spend(n)?; - let mut v = Vec::new(); - v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?; - Ok(v) - } - - /// Element count of an array: each element takes at least `min` bytes, so a count the - /// remaining input cannot hold is rejected before anything is allocated. - fn count(&mut self, min: usize) -> Result { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; - if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) { - return Err(Error::Malformed("array longer than its data")); - } - Ok(n) - } - - fn fields(&mut self, tagged: bool, class: Tag) -> Result, Error> { - self.depth += 1; - if self.depth > MAX_DEPTH { - return Err(Error::Limit("objects nested too deeply")); - } - let mut out = Vec::new(); - loop { - let t = self.c.u8()?; - let code = t & 0x7F; - if code == 0 { - break; - } - self.spend(1)?; - let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) }; - let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? }; - out.push((tag, v)); - } - self.depth -= 1; - Ok(out) - } - - fn string(&mut self) -> Result { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?; - Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned()) - } - - fn floats(&mut self, n: usize, wide: bool) -> Result { - let mut v = Vec::with_capacity(n); - for _ in 0..n { - v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) }); - } - Ok(Value::Floats(v)) - } - - fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result { - let c = &mut self.c; - Ok(match code { - 0x01 => Value::UInt(c.u8()?.into()), - 0x02 => Value::UInt(c.u16()?.into()), - 0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()), - 0x04 => Value::UInt(c.u64()?), - 0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()), - 0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()), - 0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()), - 0x08 => Value::Int(i64::from_le_bytes(c.array()?)), - 0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()), - 0x0a => Value::Float(f64::from_le_bytes(c.array()?)), - 0x15..=0x19 => { - let n = usize::from(code - 0x13); - let mut v = Vec::with_capacity(n); - for _ in 0..n { - v.push(i32::from_le_bytes(c.array()?).into()); - } - Value::Ints(v) - } - 0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false), - 0x24..=0x28 => return self.floats(usize::from(code - 0x22), true), - 0x29 => Value::Bool(c.u8()? != 0), - 0x2a => Value::Enum { id: c.u16()?, version: c.u16()? }, - 0x2b | 0x2e => Value::Str(self.string()?), - 0x2c => { - let n = usize::from(c.u16()?); - Value::Bytes(c.take(n)?.to_vec()) - } - 0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()), - 0x2d => { - let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?; - Value::Blob(c.take(n)?.to_vec()) - } - 0x33 => { - c.u32()?; - let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?; - Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned()) - } - 0x75 => { - c.u16()?; - let n = usize::from(c.u8()?); - if n > 8 { - return Err(Error::Malformed("flags wider than 64 bits")); - } - let mut b = [0u8; 8]; - b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?); - Value::Flags(u64::from_le_bytes(b)) - } - 0x30 => { - let fields = self.fields(false, class)?; - let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?; - Value::Obj(Some(id)) - } - 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")), - 0x31 => self.shared()?, - 0x32 => self.inline(None)?, - _ => return Err(Error::Unsupported("unknown field type in document stream")), - }) - } - - fn array(&mut self, code: u8, class: Tag) -> Result { - let min = match code { - 0x01 | 0x05 | 0x29 => 1, - 0x02 | 0x06 => 2, - 0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4, - 0x04 | 0x08 | 0x0a => 8, - 0x15..=0x19 => 4 * usize::from(code - 0x13), - 0x1f..=0x23 => 4 * usize::from(code - 0x1d), - 0x24..=0x28 => 8 * usize::from(code - 0x22), - 0x35..=0x74 => usize::from(code - 0x34), - _ => 1, - }; - match code { - 0x29 => { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; - let bits = self.c.take(n.div_ceil(8))?; - let mut v = self.vec(n)?; - v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0)))); - Ok(Value::Array(v)) - } - 0x2a => { - let n = self.count(2)?; - let version = self.c.u16()?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(Value::Enum { id: self.c.u16()?, version }); - } - Ok(Value::Array(v)) - } - 0x2b | 0x2e => { - self.c.u32()?; - let n = self.count(4)?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(Value::Str(self.string()?)); - } - Ok(Value::Array(v)) - } - 0x2c => { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; - let size = usize::from(self.c.u16()?); - // Records of no bytes take nothing from the data, whatever their number. - if n > 0 && size == 0 { - return Err(Error::Malformed("array of records without bytes")); - } - if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { - return Err(Error::Malformed("array longer than its data")); - } - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(Value::Bytes(self.c.take(size)?.to_vec())); - } - Ok(Value::Array(v)) - } - 0x32 => { - let n = self.count(1)?; - let class = Tag(self.c.u32()?); - self.c.u16()?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(self.inline(Some(class))?); - } - Ok(Value::Array(v)) - } - 0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")), - _ => { - let n = self.count(min)?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(self.scalar(code, true, class)?); - } - Ok(Value::Array(v)) - } - } - } - - /// Inline object; `class` is given once for a whole array of them. - fn inline(&mut self, class: Option) -> Result { - match self.c.u8()? { - 0 => return Ok(Value::Obj(None)), - 1 => {} - _ => return Err(Error::Malformed("object marker")), - } - let class = match class { - Some(c) => c, - None => { - let c = Tag(self.c.u32()?); - self.c.u16()?; - c - } - }; - let fields = self.fields(true, class)?; - let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?; - Ok(Value::Obj(Some(id))) - } - - fn shared(&mut self) -> Result { - let flag = self.c.u8()?; - if flag == 0 { - return Ok(Value::Obj(None)); - } - let key = self.c.u32()?; - match flag { - 2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")), - 1 => {} - _ => return Err(Error::Malformed("object marker")), - } - if self.shared.contains_key(&key) { - return Err(Error::Malformed("object defined twice")); - } - // Register before the fields: an object's own fields may refer back to it. - let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?; - self.shared.insert(key, id); - let mut chain = Vec::new(); - let mut fields = Vec::new(); - loop { - match self.c.u8()? { - 0 => { - let tag = Tag(self.c.u32()?); - self.c.u16()?; - chain.push(tag); - fields.extend(self.fields(true, tag)?); - } - 1 => { - chain.push(Tag(self.c.u32()?)); - break; - } - 2 => break, - _ => return Err(Error::Malformed("object class chain")), - } - if chain.len() > 64 { - return Err(Error::Limit("object class chain too long")); - } - } - let class = chain.first().copied().unwrap_or(Tag(0)); - fields.extend(self.fields(true, class)?); - if let Some(o) = self.objects.get_mut(id) { - *o = Object::new(class, Kind::Shared, chain, fields); - } - Ok(Value::Obj(Some(id))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lookups_in_a_large_object_find_the_first_and_last_fields_of_a_tag() { - let tag = |i: u32| Tag(0x4100_0000 + i); - let mut fields: Vec<(Tag, Value)> = (0..1000).map(|i| (tag(i % 300), Value::UInt(u64::from(i)))).collect(); - fields.push((tag(7), Value::Str("last".into()))); - let o = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), fields.clone()); - assert!(!o.index.is_empty()); - // The index has one entry per distinct tag, not per field. - assert_eq!(o.index.len(), 300); - for t in 0..300 { - let first = fields.iter().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); - let last = fields.iter().rev().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); - assert_eq!(o.get(tag(t)), first); - assert_eq!(o.get_last(tag(t)), last); - } - assert_eq!(o.get(tag(300)), None); - assert_eq!(o.get_last(tag(7)), Some(&Value::Str("last".into()))); - let small = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), vec![(tag(1), Value::Bool(true)), (tag(1), Value::Bool(false))]); - assert!(small.index.is_empty()); - assert_eq!(small.get(tag(1)), Some(&Value::Bool(true))); - assert_eq!(small.get_last(tag(1)), Some(&Value::Bool(false))); - } -} diff --git a/crates/affinity/src/synth.rs b/crates/affinity/src/synth.rs deleted file mode 100644 index 5b75ca0bff..0000000000 --- a/crates/affinity/src/synth.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`). -//! -//! These builders write the layout the reader accepts, so tests can describe documents in a few -//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use crate::stream::Tag; - -/// A field value to encode. -#[derive(Debug, Clone)] -pub enum F { - U8(u8), - U32(u32), - I32(i32), - F32(f32), - F64(f64), - Bool(bool), - Enum(u16, u16), - Str(String), - /// f64 vector of 2..6 values (points, rectangles, transforms). - F64s(Vec), - /// Fixed-size struct (colour components), 1..64 bytes. - Struct(Vec), - /// Array of fixed-size records, all `size` bytes. - Records(u16, Vec>), - U8s(Vec), - Entry(String), - /// Shared object: `Def` defines id with class chain and fields, `Ref` links back. - Def(u32, Vec, Vec<(Tag, F)>), - Ref(u32), - Null, - /// Inline object. - Obj(Tag, Vec<(Tag, F)>), - /// Positional (untagged) object. - Pos(Vec), - /// Array of shared objects / links. - Shared(Vec), - /// Array of inline objects of one class. - Objs(Tag, Vec>), - /// Raw bytes appended verbatim (malformed-input tests). - Raw(Vec), -} - -pub fn tag(s: &[u8; 4]) -> Tag { - Tag::of(s) -} - -fn put_tag(out: &mut Vec, t: Tag) { - out.extend(t.0.to_le_bytes()); -} - -fn code(f: &F) -> u8 { - match f { - F::U8(_) => 0x01, - F::U32(_) => 0x03, - F::I32(_) => 0x07, - F::F32(_) => 0x09, - F::F64(_) => 0x0a, - F::Bool(_) => 0x29, - F::Enum(..) => 0x2a, - F::Str(_) => 0x2b, - F::F64s(v) => 0x22 + v.len() as u8, - F::Struct(b) => 0x34 + b.len() as u8, - F::Records(..) => 0x80 | 0x2c, - F::U8s(_) => 0x80 | 0x01, - F::Entry(_) => 0x33, - F::Def(..) | F::Ref(_) => 0x31, - F::Null => 0x31, - F::Obj(..) => 0x32, - F::Pos(_) => 0x30, - F::Shared(_) => 0x80 | 0x31, - F::Objs(..) => 0x80 | 0x32, - F::Raw(_) => 0, - } -} - -fn payload(out: &mut Vec, f: &F) { - match f { - F::U8(v) => out.push(*v), - F::U32(v) => out.extend(v.to_le_bytes()), - F::I32(v) => out.extend(v.to_le_bytes()), - F::F32(v) => out.extend(v.to_le_bytes()), - F::F64(v) => out.extend(v.to_le_bytes()), - F::Bool(v) => out.push(u8::from(*v)), - F::Enum(id, ver) => { - out.extend(id.to_le_bytes()); - out.extend(ver.to_le_bytes()); - } - F::Str(s) => { - out.extend((s.len() as u32).to_le_bytes()); - out.extend(s.as_bytes()); - } - F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())), - F::Struct(b) => out.extend(b), - F::Records(size, recs) => { - out.extend((recs.len() as u32).to_le_bytes()); - out.extend(size.to_le_bytes()); - recs.iter().for_each(|r| out.extend(r)); - } - F::U8s(v) => { - out.extend((v.len() as u32).to_le_bytes()); - out.extend(v); - } - F::Entry(name) => { - put_tag(out, tag(b"Data")); - out.extend((name.len() as u32).to_le_bytes()); - out.extend(name.as_bytes()); - } - F::Def(id, chain, fields) => { - out.push(1); - out.extend(id.to_le_bytes()); - // Class chain: each level but the last carries no fields here; the last ends the chain. - let (last, rest) = chain.split_last().expect("class chain"); - for t in rest { - out.push(0); - put_tag(out, *t); - out.extend(0u16.to_le_bytes()); - out.push(0); - } - out.push(1); - put_tag(out, *last); - fields_into(out, fields, true); - } - F::Ref(id) => { - out.push(2); - out.extend(id.to_le_bytes()); - } - F::Null => out.push(0), - F::Obj(class, fields) => { - out.push(1); - put_tag(out, *class); - out.extend(0u16.to_le_bytes()); - fields_into(out, fields, true); - } - F::Pos(items) => { - for f in items { - out.push(code(f)); - payload(out, f); - } - out.push(0); - } - F::Shared(items) => { - out.extend((items.len() as u32).to_le_bytes()); - items.iter().for_each(|f| payload(out, f)); - } - F::Objs(class, items) => { - out.extend((items.len() as u32).to_le_bytes()); - put_tag(out, *class); - out.extend(0u16.to_le_bytes()); - for fields in items { - out.push(1); - fields_into(out, fields, true); - } - } - F::Raw(b) => out.extend(b), - } -} - -fn fields_into(out: &mut Vec, fields: &[(Tag, F)], tagged: bool) { - for (t, f) in fields { - if let F::Raw(b) = f { - out.extend(b); - continue; - } - out.push(code(f)); - if tagged { - put_tag(out, *t); - } - payload(out, f); - } - out.push(0); -} - -/// A `doc.dat` stream with root class `Pers`. -pub fn stream(root: &[(Tag, F)]) -> Vec { - let mut out = b"\x00\xffKS".to_vec(); - out.extend(2u16.to_le_bytes()); - put_tag(&mut out, tag(b"Pers")); - out.extend(0u16.to_le_bytes()); - out.extend(30u32.to_le_bytes()); - fields_into(&mut out, root, true); - out -} - -/// How an entry is stored. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Method { - Stored, - Zlib, - Zstd, -} - -/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record. -pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec { - let mut out = vec![0u8; 72]; - out[..4].copy_from_slice(crate::container::MAGIC); - out[4..6].copy_from_slice(&12u16.to_le_bytes()); - out[6..8].copy_from_slice(&4u16.to_le_bytes()); - out[8..12].copy_from_slice(b"nsrP"); - out[12..16].copy_from_slice(b"#Inf"); - out[64..68].copy_from_slice(b"Prot"); - let mut records = Vec::new(); - for (i, (name, data, method)) in entries.iter().enumerate() { - let packed = match method { - Method::Stored => data.to_vec(), - Method::Zlib => { - use std::io::Write; - let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); - e.write_all(data).unwrap(); - e.finish().unwrap() - } - Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest), - }; - let offset = out.len() as u64; - out.extend(b"#Fil"); - out.extend(&packed); - let mut r = Vec::new(); - r.extend((i as u32 + 1).to_le_bytes()); - r.push(0); - r.extend(offset.to_le_bytes()); - r.extend((data.len() as u64).to_le_bytes()); - r.extend((packed.len() as u64).to_le_bytes()); - r.extend(crc32fast::hash(data).to_le_bytes()); - r.push(match method { - Method::Stored => 0, - Method::Zlib => 1, - Method::Zstd => 2, - }); - r.extend(0u32.to_le_bytes()); - r.extend(0u32.to_le_bytes()); - r.extend((name.len() as u16).to_le_bytes()); - r.extend(name.as_bytes()); - records.push(r); - } - let fat = out.len() as u64; - out.extend(b"#FT4"); - out.extend(0u64.to_le_bytes()); - out.extend(1_700_000_000u64.to_le_bytes()); - out.extend([0u8; 24]); - out.extend((records.len() as u32).to_le_bytes()); - out.extend([0u8; 8]); - out.extend(0u16.to_le_bytes()); - out.push(0); - records.iter().for_each(|r| out.extend(r)); - let thumb = out.len() as u64; - out[16..24].copy_from_slice(&fat.to_le_bytes()); - if let Some(png) = thumbnail_png { - out[24..32].copy_from_slice(&thumb.to_le_bytes()); - out.extend(b"\xff\xff\xff\xffThmb"); - out.extend(1u32.to_le_bytes()); - out.extend((png.len() as u32 + 13).to_le_bytes()); - out.extend(29u32.to_le_bytes()); - out.extend(0u32.to_le_bytes()); - out.extend((png.len() as u32).to_le_bytes()); - out.push(1); - out.extend(png); - } - out -} diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs deleted file mode 100644 index 75b6b7b146..0000000000 --- a/crates/affinity/src/tests.rs +++ /dev/null @@ -1,408 +0,0 @@ -//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents. - -mod preview { - use crate::container::header; - use crate::*; - use proptest::prelude::*; - - fn fixture() -> Vec { - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - png.extend(13u32.to_be_bytes()); - png.extend(b"IHDR"); - png.extend(2u32.to_be_bytes()); - png.extend(1u32.to_be_bytes()); - png.extend([8, 6, 0, 0, 0]); - png.extend(crc32fast::hash(&png[12..]).to_be_bytes()); - png.extend([0; 4]); - png.extend(b"IDAT"); - png.extend(crc32fast::hash(b"IDAT").to_be_bytes()); - png.extend([0; 4]); - png.extend(b"IEND"); - png.extend(crc32fast::hash(b"IEND").to_be_bytes()); - let mut b = vec![0; 72]; - b[..4].copy_from_slice(MAGIC); - b[4..6].copy_from_slice(&12u16.to_le_bytes()); - b[8..12].copy_from_slice(b"nsrP"); - b[12..16].copy_from_slice(b"#Inf"); - b[24..32].copy_from_slice(&72u64.to_le_bytes()); - b[64..68].copy_from_slice(b"Prot"); - b.extend(b"\xff\xff\xff\xffThmb"); - b.extend(1u32.to_le_bytes()); - b.extend((png.len() as u32 + 13).to_le_bytes()); - b.extend(29u32.to_le_bytes()); - b.extend(0u32.to_le_bytes()); - b.extend((png.len() as u32).to_le_bytes()); - b.push(1); - b.extend(png); - b - } - - #[test] - fn indexed_preview_and_header() { - let b = fixture(); - assert_eq!(header(&b).unwrap().version, 12); - let p = preview(&b).unwrap(); - assert_eq!((p.width, p.height), (2, 1)); - assert_eq!(p.png, &b[101..]); - } - - #[test] - fn every_truncation_is_rejected() { - let b = fixture(); - for end in 0..b.len() { - assert!(preview(&b[..end]).is_err(), "{end}"); - } - } - - #[test] - fn rejects_assets_versions_offsets_lengths_and_bombs() { - let b = fixture(); - for (offset, value) in [ - (4, 13u64.to_le_bytes().to_vec()), - (8, b"urBR".to_vec()), - (24, u64::MAX.to_le_bytes().to_vec()), - (24, 1u64.to_le_bytes().to_vec()), - (24, 0u64.to_le_bytes().to_vec()), - (84, u32::MAX.to_le_bytes().to_vec()), - (88, 30u32.to_le_bytes().to_vec()), - (96, u32::MAX.to_le_bytes().to_vec()), - (117, 5000u32.to_be_bytes().to_vec()), - ] { - let mut broken = b.clone(); - broken[offset..offset + value.len()].copy_from_slice(&value); - assert!(preview(&broken).is_err(), "offset {offset}"); - } - // Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files). - for version in 8..12 { - let mut legacy = b.clone(); - legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes()); - assert!(preview(&legacy).is_ok()); - } - // Flags 4 and 8 occur in real files; the low two bits mark other container variants. - for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] { - let mut variant = b.clone(); - variant[6..8].copy_from_slice(&flags.to_le_bytes()); - assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}"); - } - let mut old = b.clone(); - old[4..6].copy_from_slice(&7u16.to_le_bytes()); - assert!(matches!(preview(&old), Err(Error::Unsupported(_)))); - } - - #[test] - fn an_unindexed_png_or_resource_is_never_used() { - let mut b = fixture(); - b[24..32].copy_from_slice(&0u64.to_le_bytes()); - assert!(preview(&b).is_err()); - b[24..32].copy_from_slice(&72u64.to_le_bytes()); - b[76..80].copy_from_slice(b"Meta"); - assert!(preview(&b).is_err()); - } - - #[test] - fn every_chunk_checksum_including_the_end_is_checked() { - let b = fixture(); - for at in [130, b.len() - 1] { - let mut damaged = b.clone(); - damaged[at] ^= 1; - assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum")); - } - let mut ancillary = vec![0; 4]; - ancillary.extend(b"tEXt"); - ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes()); - let mut with_ancillary = b.clone(); - let at = b.len() - 12; - with_ancillary.splice(at..at, ancillary); - let png_length = (with_ancillary.len() - 101) as u32; - with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes()); - with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes()); - assert!(preview(&with_ancillary).is_ok()); - with_ancillary[at + 8] ^= 1; - assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum")); - } - - #[test] - fn compressed_metadata_and_animation_are_not_preview_support() { - for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] { - let mut extra = vec![0; 4]; - extra.extend(kind); - extra.extend(crc32fast::hash(kind).to_be_bytes()); - let mut b = fixture(); - let at = b.len() - 12; - b.splice(at..at, extra); - let length = (b.len() - 101) as u32; - b[84..88].copy_from_slice(&(length + 13).to_le_bytes()); - b[96..100].copy_from_slice(&length.to_le_bytes()); - assert!(matches!(preview(&b), Err(Error::Unsupported(_)))); - } - } - - #[test] - fn critical_chunks_follow_a_single_complete_png_layout() { - fn with_chunks(kinds: &[&[u8; 4]]) -> Vec { - let original = fixture(); - let mut b = original[..134].to_vec(); // File, record and complete first IHDR. - for kind in kinds { - b.extend([0; 4]); - b.extend(*kind); - b.extend(crc32fast::hash(*kind).to_be_bytes()); - } - let length = (b.len() - 101) as u32; - b[84..88].copy_from_slice(&(length + 13).to_le_bytes()); - b[96..100].copy_from_slice(&length.to_le_bytes()); - b - } - assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok()); - // PNG readers must not reject an otherwise valid unknown ancillary chunk just - // because its reserved (third-letter) bit is set; future versions can define it. - assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok()); - for kinds in [ - vec![b"IEND"], - vec![b"IDAT", b"IHDR", b"IEND"], - vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"], - vec![b"IDAT", b"PLTE", b"IEND"], - vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"], - vec![b"ABCD", b"IDAT", b"IEND"], - vec![b"IDAT", b"IEND", b"tEXt"], - ] { - assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}"); - } - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - #[test] - fn arbitrary_input_never_panics(b in prop::collection::vec(any::(), 0..2048)) { - let _ = header(&b); - let _ = preview(&b); - } - #[test] - fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 0..32)) { - let mut b = fixture(); - for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } } - let _ = preview(&b); - } - } -} - -mod archive { - use crate::Error; - use crate::container::{Archive, Limits}; - use crate::stream::{self, Kind, Tag, Value}; - use crate::synth::{self, F, Method, tag}; - use proptest::prelude::*; - - fn doc() -> Vec { - synth::stream(&[ - (tag(b"Desc"), F::Str("Document".into())), - (tag(b"Visi"), F::Bool(true)), - (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])), - (tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])), - (tag(b"Same"), F::Ref(7)), - (tag(b"Null"), F::Null), - (tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])), - ( - tag(b"Crvs"), - F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]), - ), - (tag(b"Enum"), F::Enum(2, 1)), - (tag(b"Tile"), F::Entry("d/3".into())), - (tag(b"Stat"), F::U8s(vec![4, 0, 2])), - ]) - } - - #[test] - fn every_method_round_trips_with_checksums() { - let d = doc(); - for method in [Method::Stored, Method::Zlib, Method::Zstd] { - let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None); - let mut a = Archive::open(&file, Limits::default()).unwrap(); - assert_eq!(a.header.version, 12); - assert_eq!(a.read("doc.dat").unwrap(), d); - assert_eq!(a.read("d/3").unwrap(), b"tile"); - assert_eq!(a.extracted(), d.len() + 4); - assert!(a.read("missing").is_err()); - } - } - - #[test] - fn the_stream_parses_into_shared_objects_and_links() { - let s = stream::parse(&doc()).unwrap(); - let root = &s.objects[s.root]; - assert_eq!(root.class, Tag::of(b"Pers")); - assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into()))); - assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0]))); - let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() }; - assert_eq!(a, b); - assert_eq!(s.objects[*a].class, Tag::of(b"RGBA")); - assert_eq!(s.objects[*a].kind, Kind::Shared); - let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() }; - assert_eq!(kids[0], kids[1]); - let Value::Obj(Some(g)) = kids[0] else { panic!() }; - assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]); - assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 })); - assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into()))); - assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None))); - } - - #[test] - fn checksums_sizes_and_budgets_are_enforced() { - let d = doc(); - let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); - // Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72. - let mut broken = file.clone(); - broken[90] ^= 0x55; - let mut a = Archive::open(&broken, Limits::default()).unwrap(); - assert!(a.read("doc.dat").is_err()); - let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap(); - assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap(); - a.read("doc.dat").unwrap(); - assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - } - - #[test] - fn an_entry_that_fails_its_checksum_is_charged_once_and_not_decompressed_again() { - let d = doc(); - let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); - let mut broken = file.clone(); - broken[90] ^= 0x55; - let mut a = Archive::open(&broken, Limits::default()).unwrap(); - let first = a.read("doc.dat").unwrap_err(); - // The work was done, so it counts against the import even though it failed. - assert_eq!(a.extracted(), d.len()); - assert_eq!(a.read("doc.dat").unwrap_err(), first); - assert_eq!(a.extracted(), d.len()); - // A budget that only covers one attempt is not exceeded by asking again. - let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len() }).unwrap(); - assert!(a.read("doc.dat").is_err()); - assert_eq!(a.read("doc.dat").unwrap_err(), first); - } - - #[test] - fn the_table_index_has_a_budget_for_records_and_names() { - let d = doc(); - let long = "n".repeat(300); - let file = synth::container(&[("doc.dat", &d, Method::Stored), (&long, b"tile", Method::Stored)], None); - Archive::open_within(&file, Limits::default(), 2, 307).unwrap(); - assert_eq!(Archive::open_within(&file, Limits::default(), 1, 1 << 20).unwrap_err(), Error::Limit("too many archive entries")); - assert_eq!(Archive::open_within(&file, Limits::default(), 2, 306).unwrap_err(), Error::Limit("archive entry names too long")); - } - - // Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's - // window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than - // the format permits for a block. 0.9.1 refuses it. - #[test] - fn a_zstd_block_larger_than_its_frames_window_is_refused() { - const LEN: u32 = 100_000; - let plain = vec![0x61u8; LEN as usize]; - let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None); - // A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE - // block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3. - let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00]; - frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap()); - frame.push(0x61); - // Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72). - let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; - let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize; - let mut swapped = file[..76].to_vec(); - swapped.extend(&frame); - swapped.extend(&file[76 + old_len..]); - let new_fat = fat - old_len + frame.len(); - swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes()); - swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes()); - let mut a = Archive::open(&swapped, Limits::default()).unwrap(); - assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window"); - } - - #[test] - fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { - let big = vec![7u8; 1 << 20]; - let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None); - // Rewrite the declared size in the table to 1 KiB: decoding must stop and fail. - let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; - let size_at = fat + 59 + 5 + 8; - file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes()); - let mut a = Archive::open(&file, Limits::default()).unwrap(); - assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch")); - } - - #[test] - fn libraries_and_cyclic_tables_are_rejected() { - let d = doc(); - let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None); - let mut library = file.clone(); - library[8..12].copy_from_slice(b"rAsA"); - assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_)))); - let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()); - let at = fat as usize + 4; - file[at..at + 8].copy_from_slice(&fat.to_le_bytes()); - assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle")); - } - - #[test] - fn hostile_streams_fail_cleanly() { - let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]); - assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply")); - // An array claiming four billion elements in a few bytes. - let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]); - assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data")); - let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]); - assert!(stream::parse(&forward).is_err()); - let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]); - assert!(stream::parse(&twice).is_err()); - let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]); - assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_)))); - let d = doc(); - for end in 0..d.len() { - assert!(stream::parse(&d[..end]).is_err(), "{end}"); - } - } - - #[test] - fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() { - // Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB - // of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An - // array of one-byte values just past the budget fails the same way, before allocating. - let field = |code: u8, n: u32, data: usize| { - let mut f = vec![0x80 | code]; - f.extend(tag(b"Big_").0.to_le_bytes()); - f.extend(n.to_le_bytes()); - f.resize(f.len() + data, 0); - F::Raw(f) - }; - let bools = 1u32 << 27; - let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1; - for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] { - let d = synth::stream(&[(tag(b"Big_"), f)]); - let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); - assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len()); - let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap(); - assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream")); - } - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(512))] - #[test] - fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::()), 1..24)) { - let d = doc(); - for method in [Method::Stored, Method::Zlib, Method::Zstd] { - let mut file = synth::container(&[("doc.dat", &d, method)], None); - for (i, v) in &edits { - let n = file.len(); - file[i % n] = *v; - } - if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) { - let _ = stream::parse(&bytes); - } - } - let mut s = d.clone(); - for (i, v) in &edits { - let n = s.len(); - s[i % n] = *v; - } - let _ = stream::parse(&s); - } - } -} diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs deleted file mode 100644 index d2bb55ca0c..0000000000 --- a/crates/affinity/src/text.rs +++ /dev/null @@ -1,564 +0,0 @@ -//! Artistic and frame text: the story's characters with their font, size, colour, tracking and -//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning, -//! OpenType features) is left to the importing application. - -use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect}; -use crate::paint::{self, Color, Paint}; -use crate::stream::{ObjId, Value}; - -/// Characters read per text node (stories are split into blocks; this caps hostile ones). -const MAX_CHARS: usize = 1 << 20; -/// Shared character attributes must not amplify into unbounded font/feature/gradient data. -const MAX_ATTRIBUTE_BYTES: usize = 16 << 20; - -/// Ends a table cell in a table's story (Affinity's break glyph of kind 4). -pub const CELL_BREAK: char = '\u{1F}'; - -pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { - let s = r.s; - let story = s.obj(id, b"StSt")?; - let frame = s.obj(id, b"TxtH")?; - let artistic = s.is(frame, b"ArFr"); - let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?; - let (runs, align) = story_runs(r, story, world); - let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0); - let anchor_x = match align { - Align::Center => (bounds.x0 + bounds.x1) / 2.0, - Align::Right => bounds.x1, - _ => bounds.x0, - }; - if !artistic { - if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) { - r.warn("text frames with several columns (imported as one column)"); - } - if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) { - r.warn("text frames with a curved outline (imported as rectangular frames)"); - } - } - let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) }; - Some(Text { - runs, - align, - anchor: Point { x: anchor_x, y: first_baseline }, - frame: if artistic { None } else { Some(bounds) }, - transform: world, - text_transform, - }) -} - -/// Rows and columns a table may have. -const MAX_TABLE_LINES: usize = 4096; -/// Cells a table may have (rows × columns): each becomes a text object with its edge lines. -const MAX_TABLE_CELLS: usize = 1 << 16; - -/// Work charged for each copy of a run's formatting a table cell keeps. -const TABLE_RUN_COST: usize = 16; - -/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. -pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { - let s = r.s; - let story = s.obj(id, b"StSt")?; - let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?; - let bounds = |pos: Option| -> Option> { - let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None }; - let v: Vec = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::>()?; - (v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v) - }; - let columns = bounds(s.obj(tabl, b"CPos"))?; - let rows = bounds(s.obj(tabl, b"RPos"))?; - let (ncols, nrows) = (columns.len() - 1, rows.len() - 1); - let count = ncols.checked_mul(nrows)?; - if count > MAX_TABLE_CELLS { - r.warn("tables with more than 65536 cells (left out)"); - return None; - } - let Some(left) = r.budget.cells.checked_sub(count) else { - r.warn("tables beyond the cells one document may hold (left out)"); - return None; - }; - r.budget.cells = left; - let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default(); - // Formatting that doesn't match the grid would land on the wrong cells (and merging them, - // move text between cells): only text then. - let cell_objs = if cell_objs.len() == count { - cell_objs - } else { - r.warn("tables whose cells don't match their grid (cell formatting left out)"); - Vec::new() - }; - let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false }; - let mut cells: Vec = (0..count).map(|_| blank()).collect(); - for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() { - if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) { - cell.inset = inset; - } - cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) { - Some(1) => VAlign::Center, - Some(2) => VAlign::Bottom, - _ => VAlign::Top, - }; - // A cell merged into the one on its left; the first column has nothing to its left. - cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true); - } - // The story holds the text of every cell that isn't merged away, row by row, each ended by a - // cell break. - let owner_list: Vec = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect(); - let mut owners = owner_list.into_iter(); - let mut target = owners.next(); - let mut extra = false; - let (runs, _) = story_runs(r, story, world); - for run in runs { - let mut parts = run.text.split(CELL_BREAK).peekable(); - while let Some(part) = parts.next() { - if !part.is_empty() { - // Each cell keeps its own copy of the run's formatting, so each copy is charged. - match target.and_then(|i| cells.get_mut(i)) { - Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - _ => extra = true, - } - } - if parts.peek().is_some() { - target = owners.next(); - } - } - } - if extra { - r.warn("table text beyond its last cell (left out)"); - } - let mut edges = |tag: &[u8; 4], expected: usize| -> Vec> { - let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default(); - if objs.len() != expected { - r.warn("tables whose lines don't match their grid (lines left out)"); - return Vec::new(); - } - objs.into_iter() - .map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) { - (Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false), - _ => None, - }) - .collect() - }; - let vertical = edges(b"CEdg", (ncols + 1) * nrows); - let horizontal = edges(b"REdg", ncols * (nrows + 1)); - if let Some(format) = s.obj(tabl, b"TFmt") { - let filled = - s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN"))); - if filled { - r.warn("table cell fills (left out)"); - } - } - let text_transform = text_scale(r, id).then(r.type_base); - Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform }) -} - -/// A story's character runs, each with its paragraph's format, and the first paragraph's -/// alignment. Table cells end with [`CELL_BREAK`]. -pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec, Align) { - let s = r.s; - let mut runs = Vec::new(); - let mut first_align = None; - let mut total = 0usize; - let mut placeholders = false; - let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES); - for block in s.objs(story, b"Blok") { - // Shared blocks and run lists are visited once per link, so every visit is charged, empty or not. - let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default(); - let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default(); - if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() { - r.warn("text beyond the work one document may hold (truncated)"); - break; - } - let Some(glyphs) = s.obj(block, b"Glyp") else { - r.warn("a text block without readable characters (left out)"); - continue; - }; - // Bound collection itself, including placeholders, before allocating the character buffer: - // by the story's limit and by what the document's other stories left. - let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total)); - - let mut chars: Vec = match s.str(glyphs, b"Utf8") { - Some(t) => t.chars().take(budget + 1).collect(), - None => { - // Inline objects (page numbers, anchors, cell ends) each take one index before - // the segment text. - let segments = s.objs(glyphs, b"Mixd"); - if segments.is_empty() { - r.warn("a text block without readable characters (left out)"); - continue; - } - let mut v = Vec::new(); - for seg in segments { - for inline in s.objs(seg, b"Glys") { - if v.len() > budget { - break; - } - if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) { - v.push(CELL_BREAK); - } else { - v.push('\u{FFFC}'); - placeholders = true; - } - } - v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len()))); - if v.len() > budget { - break; - } - } - v - } - }; - let truncated = chars.len() > budget; - if truncated { - chars.truncate(budget); - if budget < MAX_CHARS - total { - r.warn("text beyond the characters one document may hold (truncated)"); - } else { - r.warn("text longer than a million characters (truncated)"); - } - } - total += chars.len(); - // Paragraph formats by end index; a run without an index covers the rest of the block. - let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new(); - let mut open: Option = None; - for run in paragraph_runs { - let Some(attrs) = s.obj(run, b"Item") else { continue }; - let a = paragraph(r, attrs); - first_align.get_or_insert(a.align); - match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) { - Some(end) => paragraphs.push((end, a)), - None => match open { - None => open = Some(a), - Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"), - _ => {} - }, - } - } - // Sorted by end (they are in order in every file), so both lookups below are binary - // searches: a block can have as many paragraphs and runs as characters. - paragraphs.sort_by_key(|(end, _)| *end); - let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default(); - let mut cuts: Vec = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect(); - cuts.dedup(); - let push = |r: &mut Reader, runs: &mut Vec, attrs: Option, from: usize, to: usize, budget: &mut Option| { - // Split at paragraph ends so every run has one paragraph format. - let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default(); - let mut a = from; - for &c in inside.iter().chain(std::iter::once(&to)) { - push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a)); - a = c; - } - }; - let mut start = 0usize; - for run in attribute_runs { - let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - continue; - }; - let end = index.min(chars.len()); - if index > chars.len() || end < start { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - } - if end <= start { - continue; - } - push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget); - start = end; - } - if start < chars.len() { - push(r, &mut runs, None, start, chars.len(), &mut attribute_budget); - } - if truncated { - break; - } - } - r.budget.chars = r.budget.chars.saturating_sub(total); - if placeholders { - r.warn("text fields such as page numbers (left out of the text)"); - } - (runs, first_align.unwrap_or(Align::Left)) -} - -fn characters(chars: &[char], start: usize, end: usize) -> String { - chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect() -} - -fn push_run( - r: &mut Reader, - runs: &mut Vec, - attrs: Option, - text: String, - world: Affine, - attribute_budget: &mut Option, - paragraph: Paragraph, -) { - if text.is_empty() { - return; - } - let run = match attrs { - Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) }, - _ => { - // This is a recovery policy, not an assertion about unspecified Affinity attributes. - if attrs.is_none() { - r.warn("text without character attributes (characters preserved with default attributes)"); - } - TextRun { - text, - postscript: String::new(), - family: String::new(), - weight: 400, - italic: false, - features: Vec::new(), - all_caps: false, - underline: false, - strikethrough: false, - size: 12.0 * r.dpi / 72.0, - tracking: 0.0, - h_scale: 1.0, - leading: None, - fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }), - stroke: None, - paragraph, - } - } - }; - runs.push(run); -} - -fn slot(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option) -> Option { - match v? { - Value::Array(a) => a.get(i).and_then(f), - _ => None, - } -} - -fn float(v: &Value) -> Option { - match v { - Value::Float(f) if f.is_finite() => Some(*f), - _ => None, - } -} - -fn int(v: &Value) -> Option { - match v { - Value::Int(i) => Some(*i), - Value::UInt(u) => i64::try_from(*u).ok(), - _ => None, - } -} - -fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option) -> TextRun { - let s = r.s; - let doubles = s.field(attrs, b"Doub"); - let ints = s.field(attrs, b"Ints"); - let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0); - let tracking = slot(doubles, 4, float).unwrap_or(0.0); - let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0); - let objects = match s.field(attrs, b"Objs") { - Some(Value::Array(a)) => a.iter().take(8).map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(), - _ => Vec::new(), - }; - let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) { - Some(fill) => fill, - None => { - r.warn("text without readable fill attributes (default black used)"); - Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }) - } - }; - // A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data - // before keeping it; subsequent runs skip attribute decoding once the budget is exhausted. - if let Paint::Gradient(gradient) = &fill { - let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::()); - if !charge_attributes(r, attribute_budget, bytes) { - fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }); - } - } - // Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes). - let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) { - (true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false), - _ => None, - }; - // Its gradient stops and dashes are owned data too, charged like the fill's. - if let Some(st) = &stroke { - let stops = match &st.paint { - Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::()), - _ => 0, - }; - let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::())); - if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) { - stroke = None; - } - } - // Underline (slot 0) and strikethrough (slot 1): 1 is a single line. - let mut decoration = |i: usize| match slot(ints, i, int) { - Some(1) => true, - Some(0) | None => false, - Some(_) => { - r.warn("text decorations of an unknown kind (left out)"); - false - } - }; - let (underline, strikethrough) = (decoration(0), decoration(1)); - let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0); - // A resolved font is populated on the emoji run of the public Affinity 3 text-runs file - // (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common. - // Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af - let requested = s.obj(attrs, b"DFnt"); - let resolved = - s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty()); - let font = resolved.or(requested); - if let Some(resolved) = resolved { - let differs = [b"Famy", b"Post"] - .iter() - .any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default()); - if differs { - r.warn("text uses a stored fallback font (imported with that font)"); - } - } - if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) { - r.warn("font width variants (exact face depends on the installed PostScript font)"); - } - let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default()); - let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default()); - let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget); - let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400); - let weight = if (1..=1000).contains(&weight) { - weight - } else { - r.warn("invalid font weight (imported as regular)"); - 400 - }; - TextRun { - text, - postscript, - family, - weight, - italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), - features, - all_caps, - underline, - strikethrough, - size, - tracking, - h_scale, - leading, - fill, - stroke, - paragraph: Paragraph::default(), - } -} - -fn charge_attributes(r: &mut Reader, budget: &mut Option, bytes: usize) -> bool { - let Some(remaining) = *budget else { return false }; - match remaining.checked_sub(bytes) { - Some(remaining) => { - *budget = Some(remaining); - true - } - None => { - *budget = None; - r.warn("text attribute budget exceeded (excess font names, features and gradients left out)"); - false - } - } -} - -/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it. -const MAX_FONT_NAME: usize = 256; - -fn font_name(r: &mut Reader, budget: &mut Option, value: &str) -> String { - if value.len() > MAX_FONT_NAME { - r.warn("font names longer than 256 bytes (the default font is used)"); - return String::new(); - } - metadata_string(r, budget, value) -} - -fn metadata_string(r: &mut Reader, budget: &mut Option, value: &str) -> String { - let bytes = value.len().saturating_add(std::mem::size_of::()); - if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() } -} - -/// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in -/// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other -/// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags -fn font_features(r: &mut Reader, attrs: Option, attribute_budget: &mut Option) -> (Vec, bool) { - let mut features = Vec::new(); - let mut all_caps = false; - if attribute_budget.is_none() { - return (features, all_caps); - } - let s = r.s; - let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) }; - for (index, setting) in settings.iter().take(257).enumerate() { - if index == 256 { - r.warn("more than 256 text feature settings (truncated)"); - break; - } - let Value::Obj(Some(setting)) = setting else { - r.warn("unreadable text feature settings (imported with defaults)"); - continue; - }; - let setting = *setting; - let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes); - let value = r.s.int(setting, b"Valu"); - match (tag, value) { - (Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1), - (Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => { - if !charge_attributes(r, attribute_budget, std::mem::size_of::() + if value == 0 { 5 } else { 4 }) { - break; - } - let name = String::from_utf8_lossy(&tag); - features.push(if value == 0 { format!("-{name}") } else { name.into_owned() }); - } - _ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"), - } - } - (features, all_caps) -} - -/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic -/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading -/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the -/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space -/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing -/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the -/// thumbnail of a document whose owner made it earlier. -fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph { - let doubles = r.s.field(p, b"Doub"); - let ints = r.s.field(p, b"Ints"); - let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0); - let align = match slot(ints, 0, int) { - Some(1) => Align::Center, - Some(2) => Align::Right, - Some(3) => Align::Justify, - Some(0) | None => Align::Left, - Some(_) => { - r.warn("an unknown paragraph alignment (imported as left)"); - Align::Left - } - }; - let leading = match slot(ints, 1, int) { - Some(2) => Some(at(1)).filter(|l| *l > 0.0), - Some(0) | None => None, - Some(_) => { - r.warn("paragraph leading of an unknown kind (imported as automatic)"); - None - } - }; - let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0); - Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading } -} - -/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set. -/// Neither the frame's own transform nor its groups' scale its type (checked against the -/// thumbnails of tables and frames inside scaled groups). -fn text_scale(r: &mut Reader, id: ObjId) -> Affine { - match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) { - Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]), - Some(_) => { - r.warn("text frames with an invalid text scale (imported unscaled)"); - Affine::IDENTITY - } - None => Affine::IDENTITY, - } -} diff --git a/crates/affinity/tests/it/outline_limits.rs b/crates/affinity/tests/it/outline_limits.rs index 74151c192f..15e08eb6be 100644 --- a/crates/affinity/tests/it/outline_limits.rs +++ b/crates/affinity/tests/it/outline_limits.rs @@ -20,6 +20,10 @@ fn shape(id: u32, class: &[u8; 4], parameters: Vec<(vectorcraft_affinity::stream } fn read(definitions: Vec, top: u32) -> Document { + try_read(definitions, top).unwrap() +} + +fn try_read(definitions: Vec, top: u32) -> Result { let data = synth::stream(&[ (tag(b"Defs"), F::Shared(definitions)), ( @@ -39,7 +43,7 @@ fn read(definitions: Vec, top: u32) -> Document { ), ]); let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); - vectorcraft_affinity::read(&bytes, Limits::default()).unwrap() + vectorcraft_affinity::read(&bytes, Limits::default()) } fn path(document: &Document) -> &Path { @@ -200,3 +204,79 @@ fn unlocked_rectangle_corners_keep_their_own_radius_and_type() { assert_eq!(sub.segments.len(), 8); assert_eq!(sub.segments[3][2], p(5.0, 20.0)); } + +/// The special shapes come in as their own outlines: one closed contour of finite points inside +/// (or, for a callout's tail, just below) the node's box, and none as the bounding ellipse. +#[test] +fn special_shapes_have_their_own_closed_outlines() { + for class in [b"ShCl", b"ShHt", b"ShCg", b"ShCR", b"ShCE", b"ShDA", b"ShDS", b"ShTr", b"ShCr", b"ShpD", b"ShSg"] { + let name = String::from_utf8_lossy(class).into_owned(); + let document = read(vec![shape(100, class, vec![])], 100); + assert!(!document.warnings.iter().any(|w| w.contains("bounding ellipse")), "{name}: {:?}", document.warnings); + let (Kind::Shape { path, even_odd, .. }, cog) = (&document.spreads[0].nodes[0].kind, class == b"ShCg") else { panic!("{name}: no shape") }; + // A cog is its teeth and its hole, filled even-odd. + assert_eq!((path.subpaths.len(), *even_odd), if cog { (2, true) } else { (1, false) }, "{name}"); + for sub in &path.subpaths { + assert!(sub.closed && sub.segments.len() >= 2, "{name}"); + for p in std::iter::once(sub.start).chain(sub.segments.iter().flatten().copied()) { + assert!(p.x.is_finite() && p.y.is_finite(), "{name}: {p:?}"); + assert!((-5.0..=15.0).contains(&p.x) && (-10.0..=30.0).contains(&p.y), "{name}: {p:?}"); + } + } + } + // A class the reader does not know is still its bounding ellipse, and said. + let document = read(vec![shape(100, b"ShZZ", vec![])], 100); + assert!(document.warnings.iter().any(|w| w.contains("unrecognized parametric shape")), "{:?}", document.warnings); +} + +fn group(id: u32, children: Vec) -> F { + F::Def(id, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared(children))]) +} + +/// A list's null slots yield no node, operand or segment, but each visit of a shared object scans +/// them all: they are charged to the document's work before the scan. +#[test] +fn null_slots_of_shared_lists_are_charged_for_every_visit() { + // A compound of a million null operands, placed 20 times: 20 million slots, over the 2^24. + let document = read(vec![compound(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200); + assert!(document.warnings.iter().any(|w| w.contains("geometry work limit")), "{:?}", document.warnings); + let shapes = document.spreads[0].nodes[0].children.iter().filter(|n| matches!(n.kind, Kind::Shape { .. })).count(); + assert_eq!(shapes, 16, "each placement costs a million slots of the 2^24"); + // A group of a million null children, placed 20 times, fails the read like too many layers. + let result = try_read(vec![group(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200); + assert!(matches!(result, Err(vectorcraft_affinity::Error::Limit(_))), "{:?}", result.map(|d| d.warnings)); + // Within the work of one document the same lists are read, nulls skipped. + let document = read(vec![group(100, vec![F::Null; 1_000]), group(200, vec![F::Ref(100); 20])], 200); + assert_eq!((document.spreads[0].nodes[0].children.len(), document.warnings.len()), (20, 0)); +} + +fn boxed(class: &[u8; 4], b: Vec) -> F { + F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(b)), (tag(b"Shpe"), F::Def(10_100, vec![tag(class)], vec![]))]) +} + +/// A callout rectangle (`ShCR`) whose box runs right to left (x0 > x1) panicked in `f64::clamp` +/// (min > max), found by the `stream` fuzz target. It reads, inside its box. +#[test] +fn callout_rectangle_with_a_reversed_box_reads_inside_it() { + for b in [vec![10.0_f64, 0.0, 0.0, 20.0], vec![6.8e38, 0.0, 10.0, 20.0]] { + let (lo, hi) = (b[0].min(b[2]), b[0].max(b[2])); + let document = read(vec![boxed(b"ShCR", b)], 100); + let sub = &path(&document).subpaths[0]; + let points = std::iter::once(&sub.start).chain(sub.segments.iter().flatten()); + assert!(points.into_iter().all(|q| q.x.is_finite() && q.y.is_finite() && (lo..=hi).contains(&q.x)), "{sub:?}"); + } +} + +/// A box with finite corners whose width overflows gave infinite and NaN outline points. Such a +/// shape imports as its box, with a warning. +#[test] +fn a_shape_too_large_to_compute_imports_as_its_box() { + for class in [b"ShCR", b"ShpE", b"ShCg"] { + let document = read(vec![boxed(class, vec![f64::MAX, 0.0, -f64::MAX, 20.0])], 100); + let sub = &path(&document).subpaths; + assert_eq!(sub.len(), 1, "{class:?}"); + let corners: Vec = std::iter::once(sub[0].start).chain(sub[0].segments.iter().map(|s| s[2])).collect(); + assert!(corners.iter().all(|p| p.x.abs() == f64::MAX && (p.y == 0.0 || p.y == 20.0)), "{class:?}: {corners:?}"); + assert!(document.warnings.iter().any(|w| w.contains("too large to compute")), "{:?}", document.warnings); + } +} diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index 13e8f0c53a..79ed6df5ec 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -19,7 +19,7 @@ patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"] patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"] +patchy-shapes-2.af dpi=72;spreads=1;model_crc32=6f174318;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 168, "shapes": 12, "strokes": 0, "subpaths": 13};boards=[];fonts={};warnings=[] patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[] patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 2ea6dea4fb..3e262b0764 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-cad" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)" +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader" [lints] workspace = true diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs new file mode 100644 index 0000000000..5016e301f6 --- /dev/null +++ b/crates/cad/src/hpgl.rs @@ -0,0 +1,195 @@ +//! HP-GL import: a plotter file (HP-GL/2, `.plt`, `.hpgl`, `.hgl`, `.hpg`) as a document. Older +//! HP-GL files are read by the HP-GL/2 rules for the instructions the two share. +//! +//! The pen's moves become paths: each run drawn with the pen down is one stroked path in the +//! selected pen's color and width (0.35 mm unless PW sets another), with the line type as a dash +//! pattern and the line attributes as caps and joins. Circles, arcs, wedges, rectangles and Bézier +//! curves are drawn as curves (the chord angle isn't used). Filled rectangles, wedges and polygons +//! (polygon mode, FP) become filled paths, edged ones stroked paths. Plotter units (1016 to the +//! inch, y up) become points, through user units (SC, IP, IR) and the rotation (RO). The artboard +//! is the bounds of what the art paints, its strokes included. +//! +//! VectorCraft's own rules where the device decides: before the first SP the pen is pen 1 (a +//! device draws nothing until SP), and the hard-clip limits are 11 × 8.5 in from the origin, so +//! P2 lies there when the file sets no scaling points. SP0 draws nothing. Labels, symbol mode's symbols and raster +//! images (HP RTL) aren't drawn, the input window doesn't clip, hatched fills are filled solid, +//! and the art of every page goes on one artboard; each comes back as a warning. Printer escape +//! sequences and PJL lines around the instructions are skipped. +//! +//! - `lex`: the bytes as instructions, numbers, labels and PE data. +//! - `plot`: the instructions played on the pen, as shapes. + +mod lex; +mod plot; + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LayerColor, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, PathData, Rect}; + +use crate::Imported; +use lex::Lexer; +use plot::Plot; + +/// Points per plotter unit. +const PT_PER_PLU: f64 = 72.0 / 1016.0; +/// The weight of a zero-width pen (one device dot), in points. +const THINNEST: f64 = 0.25; + +/// Instructions the reader knows and draws nothing for (their parameters are read and dropped). +const IGNORED: &[[u8; 2]] = &[ + *b"AC", *b"AD", *b"AF", *b"AH", *b"AP", *b"AS", *b"CA", *b"CF", *b"CM", *b"CP", *b"CS", *b"CT", *b"CV", *b"DC", *b"DI", *b"DL", *b"DP", *b"DR", + *b"DS", *b"DV", *b"EC", *b"ES", *b"FI", *b"FN", *b"FS", *b"GM", *b"IM", *b"IV", *b"KY", *b"LM", *b"LO", *b"MC", *b"MG", *b"MT", *b"NR", *b"OA", + *b"OC", *b"OD", *b"OE", *b"OF", *b"OG", *b"OH", *b"OI", *b"OL", *b"OP", *b"OS", *b"OT", *b"OW", *b"PB", *b"PP", *b"PS", *b"PT", *b"QL", *b"RF", + *b"RP", *b"SA", *b"SB", *b"SD", *b"SG", *b"SI", *b"SL", *b"SR", *b"SS", *b"ST", *b"SV", *b"TD", *b"TL", *b"TR", *b"UC", *b"VA", *b"VN", *b"VS", + *b"XT", *b"YT", +]; + +/// Instructions the reader plays. +const PLAYED: &[[u8; 2]] = &[ + *b"AA", *b"AR", *b"AT", *b"BL", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"CR", *b"DF", *b"DT", *b"EA", *b"EP", *b"ER", *b"EW", *b"FP", *b"FR", + *b"FT", *b"IN", *b"IP", *b"IR", *b"IW", *b"LA", *b"LB", *b"LT", *b"NP", *b"PA", *b"PC", *b"PD", *b"PE", *b"PG", *b"PM", *b"PR", *b"PU", *b"PW", + *b"RA", *b"RO", *b"RR", *b"RT", *b"SC", *b"SM", *b"SP", *b"UL", *b"WD", *b"WG", *b"WU", +]; + +/// Instructions a plot file starts with. +const FIRST: &[[u8; 2]] = &[ + *b"IN", *b"DF", *b"BP", *b"CO", *b"PS", *b"SP", *b"PU", *b"PD", *b"PA", *b"PR", *b"IP", *b"IR", *b"SC", *b"NP", *b"PC", *b"PW", *b"WU", *b"RO", + *b"QL", *b"MC", *b"PP", *b"TR", *b"CR", *b"LT", *b"LA", *b"FT", *b"VS", *b"FS", *b"AC", *b"PG", *b"PE", *b"DT", +]; + +/// Does `bytes` start like an HP-GL file? After any printer escapes and PJL lines (however long), +/// it must hold nothing but instructions the reader knows, the first one of those a plot file +/// starts with, up to the eighth instruction or 4 KB past the first. Their parameters must be +/// numbers, but for a comment's quoted string and the text, characters and encoded data of LB, BL, +/// WD, DT, SM and PE. An instruction the reader doesn't know counts when its numbers end with a +/// semicolon. +pub fn is_hpgl(bytes: &[u8]) -> bool { + holds_instructions(bytes, true) +} + +/// Does `bytes` hold HP-GL instructions as [`is_hpgl`] checks? When `strict` is false, any of them +/// may come first: a file picked by its extension may start with one a plot file seldom starts +/// with (`SI`, `DI`). Either way, at least one of the instructions checked must be one the reader +/// knows. +fn holds_instructions(bytes: &[u8], strict: bool) -> bool { + let mut lex = Lexer::new(bytes); + let mut terminator = lex::ETX; + let mut first = 0; + let mut any_known = false; + for n in 0..8 { + if n > 0 && lex.pos() > first + 4096 { + return any_known; + } + let Some(op) = lex.op(true) else { return n > 0 && any_known && lex.done() }; + if n == 0 { + first = lex.pos(); + } + if strict && n == 0 && !FIRST.contains(&op) { + return false; + } + let known = PLAYED.contains(&op) || IGNORED.contains(&op); + any_known |= known; + match &op { + b"CO" => { + if !lex.comment() { + return false; + } + } + b"LB" | b"BL" | b"WD" => lex.label(terminator), + b"DT" => { + terminator = lex.char().unwrap_or(lex::ETX); + lex.params(); + } + b"SM" => { + // The symbol character is read so it isn't taken for an instruction. + let _ = lex.char(); + } + b"PE" => lex.skip_pe(), + _ if known => lex.params(), + // An instruction the reader doesn't know (of an older or a device's own set) counts + // when numbers and a semicolon follow it. + _ => { + if !lex.closed_params() { + return false; + } + } + } + } + any_known +} + +/// Read an HP-GL file. It may start with any instruction the reader knows, or one of another set +/// that a semicolon ends; the rest of [`is_hpgl`]'s check applies, and at least one instruction the +/// reader knows must be among those it checks. +pub fn import(bytes: &[u8]) -> Result { + if !holds_instructions(bytes, false) { + return Err("not an HP-GL file".into()); + } + let mut lex = Lexer::new(bytes); + let mut plot = Plot::new(); + while !plot.full + && let Some(op) = lex.op(false) + { + plot.op(op, &mut lex); + } + Ok(build(plot.finish(lex.raster))) +} + +fn rgb(c: [u8; 3]) -> Paint { + Paint::solid(Color::rgb8(c[0], c[1], c[2])) +} + +/// The plot's shapes as paths on one layer, the artboard round what they paint. +fn build(plot: Plot) -> Imported { + // Plotter units, y up, to points, y down. + let flip = Affine::scale_non_uniform(PT_PER_PLU, -PT_PER_PLU); + let mut nodes: Vec = plot.shapes.iter().map(|s| node(s, flip)).collect(); + // The strokes' caps and miter joins included. + let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()); + let frame = nodes.iter().filter_map(Node::visual_bounds).filter(finite).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); + let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0)); + doc.layers.clear(); + let shift = Affine::translate(-frame.origin().to_vec2()); + for n in &mut nodes { + n.id = doc.alloc_id(); + if let NodeKind::Path { path, .. } = &mut n.kind { + path.transform(shift); + } + } + let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = nodes.into_iter().map(Arc::new).collect(); + } + doc.layers = vec![Arc::new(layer)]; + Imported { document: doc, warnings: plot.warnings } +} + +/// A shape as a path, its plotter units through `to_points`. +fn node(s: &plot::Shape, to_points: Affine) -> Node { + let mut path = s.path.clone(); + path.apply_affine(to_points); + let mut items = vec![]; + if let Some((c, _)) = s.fill { + items.push(AppearanceItem::Fill(FillLayer::new(rgb(c)))); + } + if let Some(st) = &s.stroke { + let mut layer = StrokeLayer::new(rgb(st.color), (st.width * PT_PER_PLU).max(THINNEST)); + (layer.cap, layer.join, layer.miter_limit) = (st.cap, st.join, st.miter); + layer.dash = st.dash.as_ref().map(|d| Dash { + pattern: d.pattern.iter().map(|v| v * PT_PER_PLU).collect(), + offset: d.offset * PT_PER_PLU, + align_corners: d.adaptive, + }); + items.push(AppearanceItem::Stroke(layer)); + } + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&path), Appearance { items, ..Appearance::default() }); + if let (NodeKind::Path { rule, .. }, Some((_, r))) = (&mut n.kind, s.fill) { + *rule = r; + } + n +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/hpgl/lex.rs b/crates/cad/src/hpgl/lex.rs new file mode 100644 index 0000000000..5025d07c6f --- /dev/null +++ b/crates/cad/src/hpgl/lex.rs @@ -0,0 +1,428 @@ +//! The bytes of an HP-GL file as instructions: two-letter mnemonics and the parameters that follow +//! them, read one at a time as the plot asks for them. Printer escape sequences (PCL, HP RTL, the +//! HP-GL device-control escapes) and PJL lines around and between the instructions are skipped, +//! with the data their escapes carry. + +/// The end-of-text character, the default label terminator. +pub(super) const ETX: u8 = 3; +const ESC: u8 = 0x1b; + +/// The most digits of a number that count: longer ones are read as their first digits. +const MAX_DIGITS: usize = 18; + +/// What the bytes being read hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + /// HP-GL instructions. + Hpgl, + /// PCL or HP RTL: escape sequences and their data, nothing to draw. + Pcl, + /// After a Universal Exit Language: PJL lines until a language starts. + Pjl, +} + +/// One item of a PE (Polyline Encoded) instruction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum PeItem { + /// Select pen. + Pen(i64), + /// Rectangle mode: the following coordinates come in fours. + Rect, + /// A coordinate pair, after a pen-up flag (`up`) and an absolute flag (`absolute`). + Point { x: f64, y: f64, up: bool, absolute: bool }, +} + +/// The state of one PE instruction being read. +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Pe { + seven_bit: bool, + fraction_bits: u32, + up: bool, + absolute: bool, +} + +pub(super) struct Lexer<'a> { + b: &'a [u8], + at: usize, + mode: Mode, + /// The file holds HP RTL or PCL raster image data. + pub raster: bool, +} + +/// Is `c` a byte that separates numeric parameters (or white space between instructions)? +fn separator(c: u8) -> bool { + matches!(c, b' ' | b',' | b'\t' | b'\r' | b'\n' | 0) +} + +impl<'a> Lexer<'a> { + pub fn new(b: &'a [u8]) -> Self { + Self { b, at: 0, mode: Mode::Hpgl, raster: false } + } + + fn peek(&self) -> Option { + self.b.get(self.at).copied() + } + + /// Whether everything has been read. + pub fn done(&self) -> bool { + self.at >= self.b.len() + } + + /// How many bytes have been read. + pub fn pos(&self) -> usize { + self.at + } + + /// The next instruction's mnemonic, in upper case; `strict`: only white space, semicolons and + /// escape sequences may come before it (anything else gives `None`, as the end does). + pub fn op(&mut self, strict: bool) -> Option<[u8; 2]> { + loop { + let c = self.peek()?; + if c == ESC { + self.escape(); + continue; + } + match self.mode { + Mode::Pcl => { + self.at += 1; + continue; + } + Mode::Pjl => { + if separator(c) { + self.at += 1; + } else if c == b'@' { + self.pjl_line(); + } else { + self.mode = Mode::Hpgl; + } + continue; + } + Mode::Hpgl => {} + } + let next = self.b.get(self.at + 1).copied(); + if c.is_ascii_alphabetic() + && let Some(n) = next.filter(u8::is_ascii_alphabetic) + { + self.at += 2; + return Some([c.to_ascii_uppercase(), n.to_ascii_uppercase()]); + } + if strict && !(separator(c) || c == b';' || c < 0x20) { + return None; + } + self.at += 1; + } + } + + /// One `@PJL` line: an `ENTER LANGUAGE` command picks what follows. + fn pjl_line(&mut self) { + let rest = &self.b[self.at..]; + let end = rest.iter().position(|&c| c == b'\n').map_or(rest.len(), |n| n + 1); + // A command names its language within its first 256 bytes; longer lines aren't copied. + let line = String::from_utf8_lossy(&rest[..end.min(256)]).to_ascii_uppercase(); + if let Some(lang) = line.split_once("LANGUAGE").map(|(_, l)| l.trim_start_matches([' ', '=']).trim()) { + self.mode = if lang.starts_with("HPGL") { Mode::Hpgl } else { Mode::Pcl }; + } + self.at += end; + } + + /// An escape sequence at the current byte, with the data it carries. + fn escape(&mut self) { + self.at += 1; + let Some(c) = self.peek() else { return }; + self.at += 1; + match c { + // Universal Exit Language (`ESC%-12345X`) and the language switches (`ESC%#B` to HP-GL, + // `ESC%#A` to PCL or HP RTL). + b'%' => { + let (_, end) = self.value(); + match end { + Some(b'X') => self.mode = Mode::Pjl, + Some(b'B') => self.mode = Mode::Hpgl, + Some(b'A') => self.mode = Mode::Pcl, + _ => {} + } + } + // Reset: PCL follows, until a language switch. + b'E' => self.mode = Mode::Pcl, + // HP-GL device control: `ESC.` and a letter, with parameters up to a colon. + b'.' => { + let Some(kind) = self.peek() else { return }; + self.at += 1; + if !b"()YZBEJKLORUX".contains(&kind) { + let rest = &self.b[self.at..self.b.len().min(self.at + 80)]; + if let Some(n) = rest.iter().position(|&c| c == b':') + && rest[..n].iter().all(|c| c.is_ascii_digit() || matches!(c, b';' | b' ' | b',')) + { + self.at += n + 1; + } + } + } + // Parameterized PCL: a group character, then values each ending in a letter (upper case + // ends the sequence); `W`, `*b…V` (raster data by plane) and `&p…X` (transparent print + // data) are followed by that many bytes of data. Without a group character (`ESC(8U`, + // `ESC)0N`), one value and the letter ending it. + 0x21..=0x2f => { + let Some(group) = self.peek().filter(|g| (0x60..=0x7e).contains(g)) else { + self.value(); + return; + }; + self.at += 1; + loop { + let (v, end) = self.value(); + let Some(end) = end else { return }; + let upper = end.to_ascii_uppercase(); + if upper == b'W' || (c, group, upper) == (b'*', b'b', b'V') || (c, group, upper) == (b'&', b'p', b'X') { + let n = v.max(0.0).min((self.b.len() - self.at) as f64) as usize; + if (c, group) == (b'*', b'b') && n > 0 { + self.raster = true; + } + self.at += n; + } + if (c, group, end) == (b'*', b'r', b'A') { + self.raster = true; + } + if !end.is_ascii_lowercase() { + return; + } + } + } + // A two-character escape. + _ => {} + } + } + + /// A PCL value (`[+-]digits[.digits]`, any part missing) and the character that ends it; `None` + /// when that is not a letter or `@` (which is then left unread). + fn value(&mut self) -> (f64, Option) { + let v = self.number_here().unwrap_or(0.0); + match self.peek() { + Some(c) if c.is_ascii_alphabetic() || c == b'@' => { + self.at += 1; + (v, Some(c)) + } + _ => (v, None), + } + } + + /// A number starting at the current byte (`[+-]digits[.digits]`), `None` when it has no digit + /// (its sign and point are read all the same). + fn number_here(&mut self) -> Option { + let mut neg = false; + if let Some(c @ (b'+' | b'-')) = self.peek() { + neg = c == b'-'; + self.at += 1; + } + let (mut v, mut digits, mut scale) = (0.0f64, 0usize, 1.0f64); + let mut point = false; + while let Some(c) = self.peek() { + if c.is_ascii_digit() { + if digits < MAX_DIGITS { + if point { + scale /= 10.0; + v += f64::from(c - b'0') * scale; + } else { + v = v * 10.0 + f64::from(c - b'0'); + } + } else if !point { + // Past the digits that count, an integer part keeps its size. + v *= 10.0; + } + digits += 1; + } else if c == b'.' && !point { + point = true; + } else { + break; + } + self.at += 1; + } + let v = v.min(f64::MAX); + (digits > 0).then_some(if neg { -v } else { v }) + } + + /// The next numeric parameter of the instruction being read, or `None` at its end (a semicolon + /// ending it is read). + pub fn number(&mut self) -> Option { + loop { + let c = self.peek()?; + if separator(c) { + self.at += 1; + } else if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') { + if let Some(v) = self.number_here() { + return Some(v); + } + } else { + if c == b';' { + self.at += 1; + } + return None; + } + } + } + + /// Up to `out.len()` numeric parameters into `out` (the rest are read and dropped) → how many. + pub fn numbers(&mut self, out: &mut [f64]) -> usize { + let mut n = 0; + while let Some(v) = self.number() { + if let Some(slot) = out.get_mut(n) { + *slot = v; + } + n += 1; + } + n.min(out.len()) + } + + /// Read and drop the parameters of the instruction being read: numbers and quoted strings. + pub fn params(&mut self) { + loop { + while self.number().is_some() {} + if self.peek() != Some(b'"') { + return; + } + self.quoted(); + } + } + + /// Read and drop numeric parameters → whether a semicolon ended them (an instruction the reader + /// doesn't know, written out in full). + pub fn closed_params(&mut self) -> bool { + while self.number().is_some() {} + self.at > 0 && self.b.get(self.at - 1) == Some(&b';') + } + + /// A comment (CO): its quoted string or nothing, then the terminator or the end → whether it + /// is one. + pub fn comment(&mut self) -> bool { + while self.peek().is_some_and(separator) { + self.at += 1; + } + match self.peek() { + Some(b'"') => self.quoted(), + Some(b';') => { + self.at += 1; + true + } + None => true, + Some(_) => false, + } + } + + /// The text of a label, up to and including `terminator` (or the end). + pub fn label(&mut self, terminator: u8) { + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == terminator).map_or(rest.len(), |n| n + 1); + } + + /// A quoted string (CO): the parameters up to its closing quote, then the terminator → whether + /// there was one. + pub fn quoted(&mut self) -> bool { + while self.peek().is_some_and(separator) { + self.at += 1; + } + let found = self.peek() == Some(b'"'); + if found { + self.at += 1; + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == b'"').map_or(rest.len(), |n| n + 1); + } + if self.peek() == Some(b';') { + self.at += 1; + } + found + } + + /// The character right after the mnemonic (DT, SM), `None` when it is a semicolon or the end + /// (the semicolon is read). + pub fn char(&mut self) -> Option { + let c = self.peek()?; + self.at += 1; + (c != b';').then_some(c) + } + + /// The next item of a PE instruction, `None` at its end (its semicolon is read). + pub fn pe(&mut self, st: &mut Pe) -> Option { + loop { + let raw = self.peek()?; + let c = raw & 0x7f; + // Spaces, deletes, control characters and bytes 128 to 160 and 255 are ignored. + if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 { + self.at += 1; + continue; + } + match c { + b';' => { + self.at += 1; + return None; + } + // Flags keep their meaning with the eighth bit set; digits are 63 and up (191 and up). + b':' | b'<' | b'>' | b'=' | b'7' | b'9' if raw < 63 || (0xb7..191).contains(&raw) => { + self.at += 1; + match c { + b':' => return self.pe_value(st).map(PeItem::Pen).or_else(|| self.end_pe()), + b'<' => st.up = true, + b'>' => match self.pe_value(st) { + Some(n) => st.fraction_bits = n.clamp(0, 52) as u32, + None => return self.end_pe(), + }, + b'=' => st.absolute = true, + b'7' => st.seven_bit = true, + _ => return Some(PeItem::Rect), + } + } + _ => { + let scale = 0.5f64.powi(st.fraction_bits as i32); + let (Some(x), Some(y)) = (self.pe_value(st), self.pe_value(st)) else { return self.end_pe() }; + let item = PeItem::Point { x: x as f64 * scale, y: y as f64 * scale, up: st.up, absolute: st.absolute }; + (st.up, st.absolute) = (false, false); + return Some(item); + } + } + } + } + + /// The rest of a PE instruction that can't be decoded: see [`Lexer::skip_pe`]. + fn end_pe(&mut self) -> Option { + self.skip_pe(); + None + } + + /// The data of a PE instruction, up to and including its semicolon, unread: its data is never + /// read as instructions. + pub fn skip_pe(&mut self) { + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == b';').map_or(rest.len(), |n| n + 1); + } + + /// One encoded PE number, `None` when the instruction ends inside it (or a byte that is no + /// digit comes first, which is read). + fn pe_value(&mut self, st: &Pe) -> Option { + let (base, low, high): (u64, std::ops::RangeInclusive, std::ops::RangeInclusive) = + if st.seven_bit { (32, 63..=94, 95..=126) } else { (64, 63..=126, 191..=254) }; + let (mut v, mut place) = (0u64, 1u64); + loop { + let raw = self.peek()?; + let c = if st.seven_bit { raw & 0x7f } else { raw }; + if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 { + self.at += 1; + continue; + } + if c == b';' { + return None; + } + self.at += 1; + let (digit, last) = if low.contains(&c) { + (u64::from(c - 63), false) + } else if high.contains(&c) { + (u64::from(c - high.start()), true) + } else { + return None; + }; + v = v.saturating_add(digit.saturating_mul(place)); + place = place.saturating_mul(base); + if last { + // The low bit is the sign. + let n = i64::try_from(v >> 1).unwrap_or(i64::MAX); + return Some(if v & 1 == 1 { -n } else { n }); + } + } + } +} diff --git a/crates/cad/src/hpgl/plot.rs b/crates/cad/src/hpgl/plot.rs new file mode 100644 index 0000000000..2ee70da75c --- /dev/null +++ b/crates/cad/src/hpgl/plot.rs @@ -0,0 +1,1331 @@ +//! The HP-GL/2 instructions played on a model of the plotter: the coordinate system (scaling +//! points, user units, rotation), the pen (position, up or down, number, width, color), the line +//! and fill attributes and the polygon buffer. What the pen draws is collected as shapes in +//! rotated plotter units, y up. + +use std::collections::HashMap; + +use kurbo::{Affine, BezPath, PathEl, Point, Shape as _, Vec2}; +use vectorcraft_doc::{LineCap, LineJoin}; +use vectorcraft_geom::FillRule; + +use super::lex::{ETX, Lexer, Pe, PeItem}; + +/// Plotter units per millimeter (0.025 mm each). +pub(super) const PLU_PER_MM: f64 = 40.0; +/// The hard-clip limits taken for P1 and P2 when the file sets none (plotter units): 11 × 8.5 in. +pub(super) const HARD_CLIP: Point = Point::new(11176.0, 8636.0); +/// The pen width after IN, in millimeters. +const DEFAULT_WIDTH_MM: f64 = 0.35; +/// The default line pattern length, in percent of the P1-P2 diagonal. +const DEFAULT_PATTERN: f64 = 4.0; +/// How far from the origin art may lie (plotter units, twice the range the format defines). +pub(super) const LIMIT: f64 = 16_777_216.0; +/// The widest pen (plotter units): 409.6 mm, the least a device must take before it draws its own +/// widest pen instead. +const MAX_WIDTH: f64 = 16_384.0; +/// The most objects an import makes. +const MAX_SHAPES: usize = 1 << 20; +/// The most path elements an import makes. +const MAX_ELEMENTS: usize = if cfg!(test) { 200_000 } else { 4_000_000 }; +/// The most pens a palette holds. +const MAX_PENS: i64 = 32_768; + +pub(super) const LABELS: &str = "labels were left out"; +pub(super) const SYMBOLS: &str = "symbols drawn at plotted points (symbol mode) were left out"; +pub(super) const RASTER: &str = "raster images in the file were left out"; +pub(super) const HATCHES: &str = "hatched and patterned fills are filled solid"; +pub(super) const WINDOW: &str = "the input window doesn't clip the art"; +pub(super) const PAGES: &str = "the art of later pages is drawn over the first page"; +pub(super) const FAR_AWAY: &str = "objects far outside the drawing were left out"; +pub(super) const TOO_MANY: &str = "the file has more than 1,048,576 objects: the rest are left out"; +pub(super) const TOO_MANY_POINTS: &str = "the file has more than 4,000,000 path points: the rest are left out"; + +/// The instructions polygon mode plays (the others are ignored there). +const POLYGON_MODE: &[[u8; 2]] = + &[*b"AA", *b"AR", *b"AT", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"DF", *b"IN", *b"PA", *b"PD", *b"PE", *b"PM", *b"PR", *b"PU", *b"RT"]; + +/// The colors of pens 0 to 7 until PC sets others. +const PALETTE: [[u8; 3]; 8] = [[255, 255, 255], [0, 0, 0], [255, 0, 0], [0, 255, 0], [255, 255, 0], [0, 0, 255], [255, 0, 255], [0, 255, 255]]; + +/// Line types 1 to 8: pen-down and pen-up lengths in percent of the pattern length. +const LINE_TYPES: [&[f64]; 8] = [ + &[0.0, 100.0], + &[50.0, 50.0], + &[70.0, 30.0], + &[80.0, 10.0, 0.0, 10.0], + &[70.0, 10.0, 10.0, 10.0], + &[50.0, 10.0, 10.0, 10.0, 10.0, 10.0], + &[70.0, 10.0, 0.0, 10.0, 0.0, 10.0], + &[50.0, 10.0, 0.0, 10.0, 10.0, 10.0, 0.0, 10.0], +]; + +/// A stroke as the pen draws it (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Stroke { + pub color: [u8; 3], + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + pub dash: Option, +} + +/// A line type as dashes (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct LinePattern { + /// Dash, gap, dash, gap…: an even number of lengths. + pub pattern: Vec, + /// How far into the pattern each line starts. + pub offset: f64, + /// The pattern stretches to fit each line, half its first dash at each end (adaptive line + /// types). + pub adaptive: bool, +} + +impl LinePattern { + /// The pattern of `percent` (pen-down and pen-up stretches, in turn, starting pen down) of + /// `length`. A pen-down stretch of zero is a dot, a vector one plotter unit long that the gap + /// after it gives up. An odd count repeats with its last pen-down stretch running into the + /// first one, which the pattern then starts within. + fn new(percent: &[f64], length: f64, adaptive: bool) -> Self { + let mut pattern: Vec = percent.iter().map(|p| p / 100.0 * length).collect(); + for pair in pattern.chunks_mut(2) { + if let [dash, rest @ ..] = pair + && *dash == 0.0 + { + *dash = 1.0; + if let Some(gap) = rest.first_mut() { + *gap = (*gap - 1.0).max(0.0); + } + } + } + let mut offset = 0.0; + if pattern.len() % 2 == 1 { + match pattern.len() { + // One pen-down stretch: a solid line. + 1 => pattern.push(0.0), + _ => { + let last = pattern.pop().unwrap_or(0.0); + if let Some(first) = pattern.first_mut() { + *first += last; + } + offset = last; + } + } + } + Self { pattern, offset, adaptive } + } +} + +/// What the plot draws: an outline with a fill, a stroke or both. +#[derive(Clone, Debug)] +pub(super) struct Shape { + pub path: BezPath, + pub fill: Option<([u8; 3], FillRule)>, + pub stroke: Option, +} + +/// A piece of a line the pen moves along, by its end. +#[derive(Clone, Copy, Debug)] +enum Piece { + Line(Point), + Curve(Point, Point, Point), +} + +impl Piece { + fn end(self) -> Point { + match self { + Self::Line(p) | Self::Curve(_, _, p) => p, + } + } + + fn push(self, bp: &mut BezPath) { + match self { + Self::Line(p) => bp.line_to(p), + Self::Curve(a, b, c) => bp.curve_to(a, b, c), + } + } +} + +/// User units: an SC instruction in force. +#[derive(Clone, Copy, Debug)] +enum Scaling { + /// The user ranges mapped onto P1 and P2; isotropic with the left and bottom percentages. + Fit { x0: f64, x1: f64, y0: f64, y1: f64, iso: Option<(f64, f64)> }, + /// P1's user coordinates and the plotter units per user unit. + Factor { x0: f64, fx: f64, y0: f64, fy: f64 }, +} + +/// How areas are filled. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FillKind { + Solid, + /// The pen's color at this percentage, on white. + Shade(f64), + /// Hatching and patterns, filled solid. + Pattern, +} + +/// The polygon buffer: its outline for filling (every edge) and for edging (pen-down edges only). +#[derive(Clone, Debug, Default)] +struct Poly { + fill: BezPath, + edge: BezPath, + /// A subpolygon is open, from `start`; its last point. + open: bool, + start: Point, + last: Point, + /// The open subpolygon's points, and where its elements start in `fill` and `edge`. + points: usize, + marks: (usize, usize), + /// Where the edge outline ends (`None`: it has to move before drawing). + edge_at: Option, + /// After PM1 or a circle: the next move starts a subpolygon. + fresh: bool, +} + +impl Poly { + fn add(&mut self, a: Point, pieces: &[Piece], down: bool) { + let Some(end) = pieces.last().map(|p| p.end()) else { return }; + // The move after PM1 goes to the next subpolygon's first point with the pen up, whatever + // the pen's state (an arc's first chord aside): it is neither filled nor edged. + if std::mem::take(&mut self.fresh) && matches!(pieces, [Piece::Line(_)]) { + (self.start, self.last) = (end, end); + return; + } + if !self.open { + self.marks = (self.fill.elements().len(), self.edge.elements().len()); + self.fill.move_to(a); + (self.start, self.open, self.points) = (a, true, 1); + } + pieces.iter().for_each(|p| p.push(&mut self.fill)); + self.points += pieces.len(); + if down { + if self.edge_at != Some(a) { + self.edge.move_to(a); + } + pieces.iter().for_each(|p| p.push(&mut self.edge)); + self.edge_at = Some(end); + } else { + self.edge_at = None; + } + self.last = end; + } + + /// Close the open subpolygon, adding a point at its start when it ends elsewhere; the edge to + /// that point is drawn when `down`. A subpolygon of fewer than three points (that one + /// included) is neither filled nor edged. → The point added. + fn close(&mut self, down: bool) -> Option { + if !self.open { + return None; + } + let closure = self.last != self.start; + if self.points + usize::from(closure) < 3 { + self.fill.truncate(self.marks.0); + self.edge.truncate(self.marks.1); + } else { + if closure && down { + if self.edge_at != Some(self.last) { + self.edge.move_to(self.last); + } + self.edge.line_to(self.start); + } + self.fill.close_path(); + } + (self.open, self.edge_at) = (false, None); + closure.then_some(self.start) + } + + /// A closed outline of its own (a circle): the open subpolygon is closed with the pen up + /// first, and the next move starts a subpolygon. + fn closed(&mut self, path: &BezPath) { + self.close(false); + self.fill.extend(path.iter()); + self.edge.extend(path.iter()); + self.fresh = true; + } +} + +/// The line drawn so far with the pen down, not yet a shape. +struct Run { + path: BezPath, + stroke: Stroke, + start: Point, + end: Point, +} + +pub(super) struct Plot { + pub shapes: Vec, + pub warnings: Vec, + /// A budget ran out: nothing more is drawn. + pub full: bool, + elements: usize, + // The coordinate system. + p1: Point, + p2: Point, + scaling: Option, + /// User units to plotter units (identity when scaling is off). + user: Affine, + /// The RO rotation (rotated plotter units to where the art lies) and its angle. + rot: Affine, + ro: u16, + // The pen. + relative: bool, + down: bool, + /// The pen's position, in plotter units before rotation. + pos: Point, + /// The pen SP selected (`None`: none yet, which draws with pen 1). + pen: Option, + pens: i64, + colors: HashMap, + ranges: [(f64, f64); 3], + width: f64, + widths: HashMap, + relative_widths: bool, + // Line and fill attributes. + line_type: Option, + saved_line_type: Option, + pattern: f64, + pattern_mm: bool, + user_types: [Option>; 8], + cap: LineCap, + join: LineJoin, + miter: f64, + fill: FillKind, + // Polygons. + poly: Option, + buffer: Option, + run: Option, + terminator: u8, + /// Instructions of kinds the reader doesn't know. + pub unknown: usize, + /// How many shapes there were at the first page advance. + page_end: Option, +} + +impl Plot { + pub fn new() -> Self { + let mut p = Self { + shapes: vec![], + warnings: vec![], + full: false, + elements: 0, + p1: Point::ZERO, + p2: HARD_CLIP, + scaling: None, + user: Affine::IDENTITY, + rot: Affine::IDENTITY, + ro: 0, + relative: false, + down: false, + pos: Point::ZERO, + pen: None, + pens: 8, + colors: HashMap::new(), + ranges: [(0.0, 255.0); 3], + width: DEFAULT_WIDTH_MM * PLU_PER_MM, + widths: HashMap::new(), + relative_widths: false, + line_type: None, + saved_line_type: None, + pattern: DEFAULT_PATTERN, + pattern_mm: false, + user_types: Default::default(), + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 5.0, + fill: FillKind::Solid, + poly: None, + buffer: None, + run: None, + terminator: ETX, + unknown: 0, + page_end: None, + }; + p.init(); + p + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + // ---------- defaults ---------- + + /// DF: the line and fill attributes, scaling, plotting mode and polygon mode as they start. + fn defaults(&mut self) { + self.flush(); + self.relative = false; + (self.line_type, self.saved_line_type, self.pattern, self.pattern_mm) = (None, None, DEFAULT_PATTERN, false); + self.user_types = Default::default(); + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + self.fill = FillKind::Solid; + (self.poly, self.buffer) = (None, None); + self.scaling = None; + self.ranges = [(0.0, 255.0); 3]; + self.terminator = ETX; + self.update_user(); + } + + /// IN: DF, and the pen up at the origin, default widths, palette and scaling points, no + /// rotation. The selected pen stays. + fn init(&mut self) { + self.defaults(); + (self.pos, self.down) = (Point::ZERO, false); + (self.width, self.relative_widths) = (DEFAULT_WIDTH_MM * PLU_PER_MM, false); + self.widths.clear(); + (self.rot, self.ro) = (Affine::IDENTITY, 0); + (self.p1, self.p2) = (Point::ZERO, HARD_CLIP); + self.pens = 8; + self.colors.clear(); + self.update_user(); + } + + // ---------- coordinates ---------- + + /// User units to plotter units for the scaling in force; scaling that maps everything onto one + /// point is ignored. + fn update_user(&mut self) { + let (p1, p2) = (self.p1, self.p2); + let user = match self.scaling { + None => Affine::IDENTITY, + Some(Scaling::Factor { x0, fx, y0, fy }) => { + Affine::translate(p1.to_vec2()) * Affine::scale_non_uniform(fx, fy) * Affine::translate((-x0, -y0)) + } + Some(Scaling::Fit { x0, x1, y0, y1, iso }) => { + let (w, h, ux, uy) = (p2.x - p1.x, p2.y - p1.y, x1 - x0, y1 - y0); + let (mut sx, mut sy, mut off) = (w / ux, h / uy, Vec2::ZERO); + if let Some((left, bottom)) = iso { + let s = sx.abs().min(sy.abs()); + let (extra_x, extra_y) = (w.abs() - ux.abs() * s, h.abs() - uy.abs() * s); + off = Vec2::new(extra_x * left / 100.0 * w.signum(), extra_y * bottom / 100.0 * h.signum()); + (sx, sy) = (s * sx.signum(), s * sy.signum()); + } + Affine::translate(p1.to_vec2() + off) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-x0, -y0)) + } + }; + let det = user.determinant(); + self.user = if det.is_finite() && det != 0.0 && user.as_coeffs().iter().all(|c| c.is_finite()) { user } else { Affine::IDENTITY }; + } + + /// A point in current units, in plotter units. + fn to_plu(&self, x: f64, y: f64) -> Point { + self.user * Point::new(x, y) + } + + /// An increment in current units, in plotter units. + fn delta(&self, x: f64, y: f64) -> Vec2 { + self.to_plu(x, y) - self.to_plu(0.0, 0.0) + } + + /// The pen's position in current units. + fn pos_user(&self) -> Point { + self.user.inverse() * self.pos + } + + /// Plotter units (before rotation) to where the art lies. + fn dev(&self, p: Point) -> Point { + self.rot * p + } + + /// User units to where the art lies. + fn user_dev(&self) -> Affine { + self.rot * self.user + } + + /// The far corner of the hard-clip limits in the current orientation (their sides swap at 90 + /// and 270 degrees). + fn hard_clip(&self) -> Point { + if self.ro % 180 == 90 { Point::new(HARD_CLIP.y, HARD_CLIP.x) } else { HARD_CLIP } + } + + /// RO: plotter units rotated `angle` degrees (0, 90, 180 or 270) to where the art lies, shifted + /// so the hard-clip limits stay where they were. + fn rotation(angle: u16) -> Affine { + let (w, h) = (HARD_CLIP.x, HARD_CLIP.y); + match angle { + 90 => Affine::new([0.0, 1.0, -1.0, 0.0, w, 0.0]), + 180 => Affine::new([-1.0, 0.0, 0.0, -1.0, w, h]), + 270 => Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]), + _ => Affine::IDENTITY, + } + } + + fn diagonal(&self) -> f64 { + (self.p2 - self.p1).hypot().max(1.0) + } + + // ---------- pens and attributes ---------- + + /// The palette entry the selected pen draws with (`None`: pen 0, no pen). + fn pen_index(&self) -> Option { + let n = self.pen.unwrap_or(1); + if n <= 0 { + return None; + } + Some((n - 1) % (self.pens - 1).max(1) + 1) + } + + fn color(&self, i: i64) -> [u8; 3] { + let default = usize::try_from(i).ok().and_then(|i| PALETTE.get(i)).copied().unwrap_or([0; 3]); + self.colors.get(&i).copied().unwrap_or(default) + } + + /// The stroke the selected pen draws (`None` for no pen). + fn stroke(&self) -> Option { + let i = self.pen_index()?; + let width = self.widths.get(&i).copied().unwrap_or(self.width).clamp(0.0, MAX_WIDTH); + let dash = self.line_type.filter(|k| *k != 0).map(|k| { + let index = k.unsigned_abs().clamp(1, 8) as usize - 1; + let length = if self.pattern_mm { self.pattern * PLU_PER_MM } else { self.pattern / 100.0 * self.diagonal() }; + match &self.user_types[index] { + Some(pct) => LinePattern::new(pct, length, k < 0), + None => LinePattern::new(LINE_TYPES[index], length, k < 0), + } + }); + Some(Stroke { color: self.color(i), width, cap: self.cap, join: self.join, miter: self.miter, dash }) + } + + /// The color the selected pen fills with (`None` for no pen). + fn fill_color(&mut self) -> Option<[u8; 3]> { + let c = self.color(self.pen_index()?); + match self.fill { + FillKind::Solid => Some(c), + FillKind::Pattern => { + self.warn(HATCHES); + Some(c) + } + FillKind::Shade(level) => { + let t = (level / 100.0).clamp(0.0, 1.0); + Some(c.map(|v| (255.0 - (255.0 - f64::from(v)) * t).round() as u8)) + } + } + } + + // ---------- output ---------- + + /// Count `n` path elements against the budget → whether they fit. + fn spend(&mut self, n: usize) -> bool { + self.elements += n; + if self.elements > MAX_ELEMENTS { + self.warn(TOO_MANY_POINTS); + self.full = true; + } + !self.full + } + + fn push(&mut self, shape: Shape) { + if shape.path.elements().len() < 2 { + return; + } + if self.shapes.len() >= MAX_SHAPES { + self.warn(TOO_MANY); + self.full = true; + return; + } + let b = shape.path.bounding_box(); + if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) { + return self.warn(FAR_AWAY); + } + self.shapes.push(shape); + } + + /// End the line being drawn. + fn flush(&mut self) { + let Some(run) = self.run.take() else { return }; + let mut els = run.path.into_elements(); + // Vectors of no length alone draw a dot: a vector one plotter unit long. + if els.len() == 1 { + els.push(PathEl::LineTo(run.start + Vec2::new(1.0, 0.0))); + } + // A line that ends where it started is closed, so its ends join. + if els.len() > 3 && run.end == run.start { + if matches!(els.last(), Some(PathEl::LineTo(p)) if *p == run.start) { + els.pop(); + } + els.push(PathEl::ClosePath); + } + self.push(Shape { path: BezPath::from_vec(els), fill: None, stroke: Some(run.stroke) }); + } + + /// A dot (a vector one plotter unit long) at `at`, drawn with the selected pen. + fn dot(&mut self, at: Point) { + if let Some(stroke) = self.stroke() { + let mut path = BezPath::new(); + path.move_to(at); + path.line_to(at + Vec2::new(1.0, 0.0)); + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + + /// The pen moves from `a` along `pieces` (where the art lies), drawing when `down`; in polygon + /// mode the move goes into the polygon buffer. + fn trace(&mut self, a: Point, pieces: &[Piece], down: bool) { + // Only what goes into a path counts against the budget: outside polygon mode, moves with the + // pen up or with no pen (SP0) make nothing. + let draws = down && self.pen_index().is_some(); + if pieces.is_empty() || self.full || (!draws && self.poly.is_none()) || !self.spend(pieces.len()) { + return; + } + if let Some(poly) = &mut self.poly { + return poly.add(a, pieces, down); + } + let Some(stroke) = self.stroke() else { return }; + if self.line_type == Some(0) { + // Line type 0 draws a dot at each point given (an arc's or a curve's end). + self.flush(); + if let Some(p) = pieces.last() { + self.dot(p.end()); + } + return; + } + if self.run.as_ref().is_some_and(|r| r.stroke != stroke || r.end != a) { + self.flush(); + } + let run = self.run.get_or_insert_with(|| { + let mut path = BezPath::new(); + path.move_to(a); + Run { path, stroke, start: a, end: a } + }); + for p in pieces { + // A vector of no length adds nothing to a line (a line of nothing else is a dot). + if matches!(p, Piece::Line(b) if *b == run.end) { + continue; + } + p.push(&mut run.path); + run.end = p.end(); + } + } + + /// Move the pen to `target` (plotter units), drawing when it is down. + fn line_to(&mut self, target: Point) { + if !(target.x.is_finite() && target.y.is_finite()) { + return; + } + let (a, b) = (self.dev(self.pos), self.dev(target)); + let down = self.down; + self.trace(a, &[Piece::Line(b)], down); + self.pos = target; + } + + /// The curves of an arc around `center` (user units) from `start` through `sweep` degrees, + /// where the art lies, and its end (user units). `None` when it has no radius or sweep. + fn arc(&self, center: Point, start: Point, sweep: f64) -> Option<(Vec, Point)> { + let r = (start - center).hypot(); + let sweep = sweep.clamp(-32768.0, 32767.0).to_radians(); + if !(r > 0.0 && r.is_finite() && sweep != 0.0 && sweep.is_finite()) { + return None; + } + let a0 = (start - center).atan2(); + let arc = kurbo::Arc { center, radii: Vec2::new(r, r), start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + let m = self.user_dev(); + let pieces = arc + .append_iter(r * 1e-6) + .filter_map(|el| match el { + PathEl::CurveTo(a, b, c) => Some(Piece::Curve(m * a, m * b, m * c)), + PathEl::LineTo(p) => Some(Piece::Line(m * p)), + _ => None, + }) + .collect(); + Some((pieces, center + Vec2::from_angle(a0 + sweep) * r)) + } + + /// An arc from the pen around `center` (user units), drawn with the pen as it is. + fn arc_from_pen(&mut self, center: Point, sweep: f64) { + let start = self.pos_user(); + match self.arc(center, start, sweep) { + Some((pieces, end)) => { + let (a, down) = (self.dev(self.pos), self.down); + self.trace(a, &pieces, down); + self.pos = self.user * end; + } + None => { + let at = self.dev(self.pos); + let down = self.down; + self.trace(at, &[Piece::Line(at)], down); + } + } + } + + /// A closed outline through `pieces` from `a` (where the art lies). + fn outline(a: Point, pieces: &[Piece]) -> BezPath { + let mut path = BezPath::new(); + path.move_to(a); + pieces.iter().for_each(|p| p.push(&mut path)); + path.close_path(); + path + } + + /// A circle of `radius` (current units) around the pen, starting at 0 degrees (180 for a + /// negative radius), where the art lies. + fn circle(&self, radius: f64) -> Option { + let c = self.pos_user(); + let start = c + Vec2::new(radius, 0.0); + let (pieces, _) = self.arc(c, start, 360.0)?; + Some(Self::outline(self.user_dev() * start, &pieces)) + } + + /// A wedge of `radius` (current units) around the pen, from `start` through `sweep` degrees. + fn wedge(&self, radius: f64, start: f64, sweep: f64) -> Option { + let c = self.pos_user(); + let reference = if radius < 0.0 { 180.0 } else { 0.0 }; + let sweep = sweep.clamp(-360.0, 360.0); + let first = c + Vec2::from_angle((reference + start.clamp(-32768.0, 32767.0) % 360.0).to_radians()) * radius.abs(); + let m = self.user_dev(); + if sweep.abs() >= 360.0 { + let (pieces, _) = self.arc(c, first, sweep)?; + return Some(Self::outline(m * first, &pieces)); + } + let mut pieces = vec![Piece::Line(m * first)]; + if let Some((arc, _)) = self.arc(c, first, sweep) { + pieces.extend(arc); + } + (radius != 0.0).then(|| Self::outline(m * c, &pieces)) + } + + /// The rectangle from the pen to `corner` (plotter units), where the art lies. + fn rect(&self, corner: Point) -> BezPath { + let (a, b) = (self.pos_user(), self.user.inverse() * corner); + let m = self.user_dev(); + let pieces = [Point::new(b.x, a.y), b, Point::new(a.x, b.y)].map(|p| Piece::Line(m * p)); + Self::outline(m * a, &pieces) + } + + /// `path` as the polygon buffer, filled (`fill`) or edged. + fn buffer_and_draw(&mut self, path: Option, fill: bool) { + self.flush(); + if self.poly.is_some() { + return; + } + let Some(path) = path else { + return self.dot(self.dev(self.pos)); + }; + if !self.spend(path.elements().len()) { + return; + } + let mut poly = Poly::default(); + poly.closed(&path); + self.buffer = Some(poly); + if fill { self.fill_polygon(FillRule::EvenOdd) } else { self.edge_polygon() } + } + + /// FP: the polygon buffer filled (each fill is a copy of the buffer, counted against the + /// budget). + fn fill_polygon(&mut self, rule: FillRule) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.fill.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(c) = self.fill_color() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.fill.clone()) else { return }; + self.push(Shape { path, fill: Some((c, rule)), stroke: None }); + } + + /// EP: the pen-down edges of the polygon buffer stroked (counted as FP's are). + fn edge_polygon(&mut self) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.edge.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(stroke) = self.stroke() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.edge.clone()) else { return }; + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + + // ---------- instructions ---------- + + /// Read and play the instruction `op`. + pub fn op(&mut self, op: [u8; 2], lex: &mut Lexer) { + if self.poly.is_some() && !POLYGON_MODE.contains(&op) { + return self.skip_op(op, lex); + } + match &op { + b"IN" | b"BP" | b"DF" | b"PU" | b"PD" | b"PA" | b"PR" | b"SP" => self.setup_op(op, lex), + b"PW" | b"WU" | b"NP" | b"PC" | b"CR" => self.pen_op(op, lex), + b"LT" | b"UL" => self.line_type_op(op, lex), + b"LA" | b"FT" | b"SC" => self.attribute_op(op, lex), + b"IP" | b"IR" | b"RO" | b"CI" => self.frame_op(op, lex), + b"AA" | b"AR" | b"AT" | b"RT" | b"BZ" | b"BR" => self.curve_op(op, lex), + b"EA" | b"ER" | b"RA" | b"RR" | b"EW" | b"WG" | b"PM" | b"FP" | b"EP" | b"PE" => self.area_op(op, lex), + _ => self.other_op(op, lex), + } + } + + /// An instruction polygon mode ignores: its parameters (or text) are read. + fn skip_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + match &op { + b"LB" | b"BL" | b"WD" => lex.label(self.terminator), + b"DT" => { + lex.char(); + lex.params(); + } + b"SM" => { + lex.char(); + } + _ => lex.params(), + } + } + + /// Initialization, pen moves and pen selection. + fn setup_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IN" | b"BP" => { + lex.params(); + self.init(); + } + b"DF" => { + lex.params(); + self.defaults(); + } + b"PU" | b"PD" => { + self.down = op[1] == b'D'; + self.points(lex); + } + b"PA" | b"PR" => { + self.relative = op[1] == b'R'; + self.points(lex); + } + b"SP" => { + let n = lex.numbers(&mut v); + self.flush(); + self.pen = Some(if n == 0 { 0 } else { v[0].round().clamp(-1.0, 1e9) as i64 }); + } + _ => {} + } + } + + /// Pen widths, the palette and its colors. + fn pen_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"PW" => { + let n = lex.numbers(&mut v); + self.flush(); + let width = |s: &Self, w: f64| if s.relative_widths { w / 100.0 * s.diagonal() } else { w * PLU_PER_MM }; + let default = if self.relative_widths { 0.1 } else { DEFAULT_WIDTH_MM }; + let w = width(self, if n == 0 { default } else { v[0].clamp(0.0, 32767.0) }); + if n >= 2 { + let pen = v[1].round().clamp(0.0, 1e9) as i64; + if pen > 0 && pen < self.pens.max(8) { + self.widths.insert(pen, w); + } + } else { + self.width = w; + self.widths.clear(); + } + } + b"WU" => { + let n = lex.numbers(&mut v); + self.flush(); + self.relative_widths = n > 0 && v[0].round() == 1.0; + self.width = if self.relative_widths { 0.1 / 100.0 * self.diagonal() } else { DEFAULT_WIDTH_MM * PLU_PER_MM }; + self.widths.clear(); + } + b"NP" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + self.pens = 8; + } else if v[0] >= 2.0 { + self.pens = i64::from((v[0].min(MAX_PENS as f64).ceil() as u32).next_power_of_two()).min(MAX_PENS); + } + } + b"PC" => { + let n = lex.numbers(&mut v); + self.flush(); + let pen = v[0].round().clamp(0.0, 1e9) as i64; + match n { + 0 => self.colors.clear(), + 1 => { + self.colors.remove(&pen); + } + // Two or three parameters: the instruction is ignored. + 2 | 3 => {} + _ if pen < MAX_PENS => { + let channel = |i: usize| { + let (b, w) = self.ranges[i]; + let t = if w == b { 0.0 } else { ((v[i + 1] - b) / (w - b)).clamp(0.0, 1.0) }; + (t * 255.0).round() as u8 + }; + let c = [channel(0), channel(1), channel(2)]; + self.colors.insert(pen, c); + } + _ => {} + } + } + b"CR" => { + let n = lex.numbers(&mut v); + if n == 0 { + self.ranges = [(0.0, 255.0); 3]; + } else if n >= 6 { + self.ranges = [(v[0], v[1]), (v[2], v[3]), (v[4], v[5])]; + } + } + _ => {} + } + } + + /// Line types and their patterns. + fn line_type_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LT" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + if self.line_type.is_some() { + self.saved_line_type = self.line_type; + } + self.line_type = None; + return; + } + let k = v[0].round().clamp(-100.0, 100.0) as i32; + if k == 99 { + if self.line_type.is_none() { + self.line_type = self.saved_line_type; + } + return; + } + if !(-8..=8).contains(&k) { + return; + } + // A pattern length of 0 or less, or a mode other than 0 and 1, makes the instruction ignored. + if (n >= 2 && !(v[1] > 0.0 && v[1] <= 32767.0)) || (n >= 3 && !matches!(v[2].round() as i32, 0 | 1)) { + return; + } + self.line_type = Some(k); + if n >= 2 { + self.pattern = v[1]; + } + if n >= 3 { + self.pattern_mm = v[2].round() == 1.0; + } + } + b"UL" => { + let mut gaps = [0.0f64; 21]; + let n = lex.numbers(&mut gaps); + self.flush(); + let index = gaps[0].round().abs(); + if n == 0 { + self.user_types = Default::default(); + } else if (1.0..=8.0).contains(&index) { + let slot = index as usize - 1; + let values = &gaps[1..n]; + let sum: f64 = values.iter().sum(); + self.user_types[slot] = if n == 1 { + None + } else if values.iter().all(|g| *g >= 0.0) && sum > 0.0 { + Some(values.iter().map(|g| g / sum * 100.0).collect()) + } else { + return; + }; + } + } + _ => {} + } + } + + /// Line ends and joins, the fill type and scaling. + fn attribute_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LA" => { + let n = lex.numbers(&mut v[..6]); + self.flush(); + if n == 0 { + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + } + for pair in v[..n - n % 2].chunks(2) { + let value = pair[1].round() as i32; + match (pair[0].round() as i32, value) { + (1, 1) => self.cap = LineCap::Butt, + (1, 2) => self.cap = LineCap::Square, + (1, 3 | 4) => self.cap = LineCap::Round, + (2, 1 | 2) => self.join = LineJoin::Miter, + (2, 4) => self.join = LineJoin::Round, + (2, 3 | 5 | 6) => self.join = LineJoin::Bevel, + (3, _) => self.miter = pair[1].clamp(1.0, 32767.0), + _ => {} + } + } + } + b"FT" => { + let n = lex.numbers(&mut v[..3]); + let kind = if n == 0 { 1 } else { v[0].round() as i32 }; + self.fill = match kind { + 1 | 2 => FillKind::Solid, + 10 => FillKind::Shade(if n >= 2 { v[1] } else { 100.0 }), + 3 | 4 | 11 | 21 | 22 => FillKind::Pattern, + _ => self.fill, + }; + } + b"SC" => { + let n = lex.numbers(&mut v[..7]); + self.flush(); + let kind = if n >= 5 { v[4].round() as i32 } else { 0 }; + self.scaling = match (n, kind) { + (0, _) => None, + (5, 2) if v[1] != 0.0 && v[3] != 0.0 => Some(Scaling::Factor { x0: v[0], fx: v[1], y0: v[2], fy: v[3] }), + (4..=7, 0 | 1) if v[0] != v[1] && v[2] != v[3] => { + let iso = (kind == 1).then(|| if n >= 7 { (v[5].clamp(0.0, 100.0), v[6].clamp(0.0, 100.0)) } else { (50.0, 50.0) }); + Some(Scaling::Fit { x0: v[0], x1: v[1], y0: v[2], y1: v[3], iso }) + } + _ => return, + }; + self.update_user(); + } + _ => {} + } + } + + /// The scaling points, the rotation and circles. + fn frame_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IP" | b"IR" => { + let n = lex.numbers(&mut v[..4]); + self.flush(); + // IR's percentages and the defaults are of the hard-clip limits in the current + // orientation. Scaling points past the art's range are held at its edge, so lengths + // taken from them stay finite. + let clip = self.hard_clip(); + let at = |x: f64, y: f64| { + let p = if op[1] == b'R' { Point::new(x / 100.0 * clip.x, y / 100.0 * clip.y) } else { Point::new(x, y) }; + Point::new(p.x.clamp(-LIMIT, LIMIT), p.y.clamp(-LIMIT, LIMIT)) + }; + match n { + 0 => (self.p1, self.p2) = (Point::ZERO, clip), + 2 | 3 => { + let p1 = at(v[0], v[1]); + (self.p1, self.p2) = (p1, p1 + (self.p2 - self.p1)); + } + 4 => { + let (p1, mut p2) = (at(v[0], v[1]), at(v[2], v[3])); + // A coordinate of P2 equal to P1's is one plotter unit more. + if p2.x == p1.x { + p2.x += 1.0; + } + if p2.y == p1.y { + p2.y += 1.0; + } + (self.p1, self.p2) = (p1, p2); + } + _ => {} + } + self.update_user(); + } + b"RO" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + let angle = if n == 0 { 0.0 } else { v[0].round().rem_euclid(360.0) }; + if [0.0, 90.0, 180.0, 270.0].contains(&angle) { + let ro = angle as u16; + let rot = Self::rotation(ro); + // The pen keeps its place on the page, its coordinates changing with the axes; + // P1 and P2 keep their coordinates, and the polygon buffer turns with the axes. + let turn = rot * self.rot.inverse(); + self.pos = rot.inverse() * (self.rot * self.pos); + if let Some(b) = &mut self.buffer { + b.fill.apply_affine(turn); + b.edge.apply_affine(turn); + } + (self.rot, self.ro) = (rot, ro); + } + } + b"CI" => { + let n = lex.numbers(&mut v[..2]); + if n == 0 { + return; + } + let path = self.circle(v[0]); + // In polygon mode the circle is a subpolygon of its own; the pen stays at its + // center. + if self.poly.is_some() { + if let Some(path) = path + && self.spend(path.elements().len()) + && let Some(poly) = &mut self.poly + { + poly.closed(&path); + } + return; + } + self.flush(); + // Line type 0 draws a dot at the center. + if self.line_type == Some(0) { + return self.dot(self.dev(self.pos)); + } + let Some(path) = path else { return self.dot(self.dev(self.pos)) }; + if self.spend(path.elements().len()) + && let Some(stroke) = self.stroke() + { + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + _ => {} + } + } + + /// Arcs and Bézier curves from the pen. + fn curve_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"AA" | b"AR" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let center = if op[1] == b'R' { self.pos_user() + Vec2::new(v[0], v[1]) } else { Point::new(v[0], v[1]) }; + self.arc_from_pen(center, v[2]); + } + b"AT" | b"RT" => { + if lex.numbers(&mut v[..5]) < 4 { + return; + } + let a = self.pos_user(); + let off = if op[0] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let (b, c) = (Point::new(v[0], v[1]) + off, Point::new(v[2], v[3]) + off); + self.three_point_arc(a, b, c); + } + b"BZ" | b"BR" => { + while let Some(x) = lex.number() { + let mut p = [x, 0.0, 0.0, 0.0, 0.0, 0.0]; + for slot in &mut p[1..] { + let Some(n) = lex.number() else { return }; + *slot = n; + } + let a = self.pos_user(); + let off = if op[1] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let [c1, c2, end] = [0, 2, 4].map(|i| Point::new(p[i], p[i + 1]) + off); + let m = self.user_dev(); + let (from, down) = (self.dev(self.pos), self.down); + self.trace(from, &[Piece::Curve(m * c1, m * c2, m * end)], down); + self.pos = self.user * end; + } + } + _ => {} + } + } + + /// Rectangles, wedges, polygon mode and encoded polylines. + fn area_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"EA" | b"ER" | b"RA" | b"RR" => { + if lex.numbers(&mut v[..2]) < 2 { + return; + } + let corner = if op[1] == b'R' { self.pos + self.delta(v[0], v[1]) } else { self.to_plu(v[0], v[1]) }; + let path = self.rect(corner); + self.buffer_and_draw(Some(path), op[0] == b'R'); + } + b"EW" | b"WG" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let path = self.wedge(v[0], v[1], v[2]); + self.buffer_and_draw(path, op[0] == b'W'); + } + b"PM" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + // The point that closes a subpolygon moves the pen there: PM1 closes it with the + // pen up, PM2 with the pen as it is. + let mut closure = None; + match if n == 0 { 0 } else { v[0].round() as i32 } { + 0 if self.poly.is_none() => { + let at = self.dev(self.pos); + self.poly = Some(Poly { start: at, last: at, ..Poly::default() }); + self.buffer = None; + } + 1 => { + if let Some(p) = &mut self.poly { + closure = p.close(false); + p.fresh = true; + } + } + 2 => { + if let Some(mut p) = self.poly.take() { + closure = p.close(self.down); + self.buffer = Some(p); + } + } + _ => {} + } + if let Some(at) = closure { + self.pos = self.rot.inverse() * at; + } + } + b"FP" => { + let n = lex.numbers(&mut v[..1]); + self.fill_polygon(if n > 0 && v[0].round() == 1.0 { FillRule::NonZero } else { FillRule::EvenOdd }); + } + b"EP" => { + lex.params(); + self.edge_polygon(); + } + b"PE" => self.polyline_encoded(lex), + _ => {} + } + } + + /// Labels, comments, pages, the input window, and instructions that draw nothing. + fn other_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 4]; + match &op { + b"LB" | b"BL" | b"WD" => { + lex.label(self.terminator); + self.warn(LABELS); + } + b"DT" => { + self.terminator = lex.char().unwrap_or(ETX); + lex.params(); + } + b"SM" => { + // The symbol character is read so it isn't taken for an instruction; symbols + // aren't drawn. + if lex.char().is_some() { + self.warn(SYMBOLS); + } + } + b"CO" => { + lex.quoted(); + } + b"PG" | b"FR" => { + lex.params(); + self.flush(); + self.page_end.get_or_insert(self.shapes.len()); + // The next page starts with the pen up at the origin (FR also empties the + // polygon buffer). + (self.pos, self.down) = (Point::ZERO, false); + if op[0] == b'F' { + self.buffer = None; + } + } + b"IW" => { + if lex.numbers(&mut v) == 4 { + self.warn(WINDOW); + } + } + _ if super::IGNORED.contains(&op) => lex.params(), + _ => { + lex.params(); + self.unknown += 1; + } + } + } + + /// The coordinate pairs of PU, PD, PA and PR, plotted in the current mode. + fn points(&mut self, lex: &mut Lexer) { + while let Some(x) = lex.number() { + let Some(y) = lex.number() else { return }; + let target = if self.relative { self.pos + self.delta(x, y) } else { self.to_plu(x, y) }; + self.line_to(target); + if self.full { + return; + } + } + } + + /// AT and RT: the arc from `a` through `b` to `c` (user units). + fn three_point_arc(&mut self, a: Point, b: Point, c: Point) { + let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + let center = if a == c { + // Back to the start: the circle whose diameter ends at the start and the middle point. + Some(a.midpoint(b)) + } else if d.abs() > 1e-12 * (b - a).hypot2().max((c - a).hypot2()).max(1.0) { + let (a2, b2, c2) = (a.to_vec2().hypot2(), b.to_vec2().hypot2(), c.to_vec2().hypot2()); + Some(Point::new( + (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, + (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d, + )) + } else { + None + }; + let Some(center) = center.filter(|p| p.x.is_finite() && p.y.is_finite()) else { + // Points in a line: a line to the end. + return self.line_to(self.user * c); + }; + let tau = std::f64::consts::TAU; + let angle = |p: Point| (p - center).atan2(); + let sweep = if a == c { + tau + } else { + let (s, t) = ((angle(c) - angle(a)).rem_euclid(tau), (angle(b) - angle(a)).rem_euclid(tau)); + if t < s { s } else { s - tau } + }; + self.arc_from_pen(center, sweep.to_degrees()); + if a != c { + self.pos = self.user * c; + } + } + + /// PE: encoded pen moves, pen selections and rectangles. + fn polyline_encoded(&mut self, lex: &mut Lexer) { + let mut st = Pe::default(); + let (mut rect, mut corner) = (false, false); + while let Some(item) = lex.pe(&mut st) { + if self.full { + continue; + } + match item { + PeItem::Pen(n) => { + if self.poly.is_none() { + self.flush(); + self.pen = Some(n.clamp(-1, 1_000_000_000)); + } + } + PeItem::Rect => (rect, corner) = (true, false), + PeItem::Point { x, y, up, absolute } => { + if up { + rect = false; + } + let target = if absolute { self.to_plu(x, y) } else { self.pos + self.delta(x, y) }; + if rect && corner { + // The second pair of a rectangle: its opposite corner, relative. It is filled + // as RR fills one, so polygon mode, which doesn't allow RR, ignores it. + let path = self.rect(self.pos + self.delta(x, y)); + self.buffer_and_draw(Some(path), true); + corner = false; + } else if rect { + self.down = false; + self.line_to(target); + corner = true; + } else { + self.down = !up; + self.line_to(target); + } + } + } + } + if rect { + self.down = false; + } + } + + /// The plot's end: the last line drawn, and notes on what wasn't read. + pub fn finish(mut self, raster: bool) -> Self { + self.flush(); + if let Some(n) = self.page_end + && self.shapes.len() > n + { + self.warn(PAGES); + } + if raster { + self.warn(RASTER); + } + if self.unknown > 0 { + let n = self.unknown; + self.warnings.push(format!( + "{n} instruction{} of kinds VectorCraft doesn't read {} skipped", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" } + )); + } + self + } +} diff --git a/crates/cad/src/hpgl/tests.rs b/crates/cad/src/hpgl/tests.rs new file mode 100644 index 0000000000..9a350707a6 --- /dev/null +++ b/crates/cad/src/hpgl/tests.rs @@ -0,0 +1,630 @@ +//! HP-GL import: units and the y axis, pens, runs, curves, filled and edged areas, polygon mode, +//! line types, scaling, rotation, encoded polylines, escapes around the instructions, what is +//! skipped with a warning, which files are taken to be HP-GL, and hostile files. + +use kurbo::{BezPath, PathEl, Point, Shape as _}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::FillRule; + +use super::plot::{FAR_AWAY, HATCHES, LABELS, PAGES, RASTER, SYMBOLS, TOO_MANY_POINTS, WINDOW}; +use super::*; + +/// Points per millimeter. +const MM: f64 = 72.0 / 25.4; + +fn plot(s: &str) -> Imported { + plot_bytes(s.as_bytes()) +} + +fn plot_bytes(b: &[u8]) -> Imported { + match import(b) { + Ok(i) => i, + Err(e) => panic!("{e}: {:?}", String::from_utf8_lossy(b)), + } +} + +/// Every object, in paint order. +fn leaves(doc: &Document) -> Vec { + let mut out = vec![]; + doc.walk(|n| { + if n.children().is_none() { + out.push(n.clone()); + } + }); + out +} + +fn bez(n: &Node) -> BezPath { + match &n.kind { + NodeKind::Path { path, .. } => path.to_bezpath(), + k => panic!("a path, got {k:?}"), + } +} + +fn stroke(n: &Node) -> StrokeLayer { + n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).expect("a stroke") +} + +fn fill(n: &Node) -> Option { + n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None }) +} + +fn rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } => *rule, + k => panic!("a path, got {k:?}"), + } +} + +fn rgb(r: u8, g: u8, b: u8) -> Paint { + Paint::solid(Color::rgb8(r, g, b)) +} + +/// The end points of `bp`'s elements. +fn ends(bp: &BezPath) -> Vec { + bp.elements().iter().filter_map(|e| e.end_point()).collect() +} + +fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-6 +} + +fn assert_points(got: &[Point], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + assert!(near(*g, Point::new(w.0, w.1)), "{got:?} != {want:?}"); + } +} + +/// A PE number as the reference guide's encoding writes it (base 64, or base 32 for 7-bit data). +fn pe_number(v: i64, seven_bit: bool) -> Vec { + let mut n = if v >= 0 { 2 * v as u64 } else { 2 * v.unsigned_abs() + 1 }; + let (base, high) = if seven_bit { (32, 95) } else { (64, 191) }; + let mut out = vec![]; + while n >= base { + out.push(63 + (n % base) as u8); + n /= base; + } + out.push(high + n as u8); + out +} + +#[test] +fn plotter_units_become_points_with_y_down() { + // An inch right and an inch up, with the thinnest pen (0.25 pt): the artboard reaches 0.125 pt + // past the line at the corner's miter, and stops at the line's butt ends. + let i = plot("IN;SP1;PW0;PU0,0;PD1016,0,1016,1016;PU;"); + let doc = &i.document; + let r = doc.artboards[0].rect; + assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}"); + let shapes = leaves(doc); + assert_eq!(shapes.len(), 1); + assert_points(&ends(&bez(&shapes[0])), &[(0.0, 72.0), (72.0, 72.0), (72.0, 0.0)]); + let s = stroke(&shapes[0]); + assert_eq!((s.width, s.paint.clone(), s.cap, s.join, s.miter_limit), (0.25, rgb(0, 0, 0), LineCap::Butt, LineJoin::Miter, 5.0)); + assert!(fill(&shapes[0]).is_none() && i.warnings.is_empty(), "{:?}", i.warnings); + + // The artboard holds what the strokes paint, the tip of a sharp miter join included. + let i = plot("IN;SP1;PW2;PD2032,0,0,1000;"); + let r = i.document.artboards[0].rect; + let painted = leaves(&i.document)[0].visual_bounds().unwrap(); + let path = bez(&leaves(&i.document)[0]).bounding_box(); + assert!(painted.x1 - path.x1 > 2.0 * MM, "the miter reaches past half the width: {painted:?} {path:?}"); + let inside = r.x0 <= painted.x0 + 1e-9 && r.y0 <= painted.y0 + 1e-9 && r.x1 >= painted.x1 - 1e-9 && r.y1 >= painted.y1 - 1e-9; + assert!(inside, "{r:?} {painted:?}"); +} + +#[test] +fn pens_pick_colors_and_widths() { + // Before any SP the pen is pen 1; SP0 draws nothing; pens past the palette wrap round it. + let i = plot("IN;PD0,0,100,0;PU;SP2;PD0,100,100,100;PU;SP0;PD0,200,100,200;PU;SP9;PD0,300,100,300;PU;SP3;PD0,400,100,400;PU;"); + let colors: Vec = leaves(&i.document).iter().map(|n| stroke(n).paint).collect(); + assert_eq!(colors, [rgb(0, 0, 0), rgb(255, 0, 0), rgb(255, 0, 0), rgb(0, 255, 0)]); + + // PC gives a pen a color in the color range; PC with the pen alone gives back its default. + let i = plot("IN;CR0,1,0,1,0,1;PC3,0,0,1;SP3;PD0,0,10,0;PU;PC3;PD0,10,10,10;PU;"); + let colors: Vec = leaves(&i.document).iter().map(|n| stroke(n).paint).collect(); + assert_eq!(colors, [rgb(0, 0, 255), rgb(0, 255, 0)]); + + // PW in millimeters for every pen, or for one; IN gives back 0.35 mm. + let i = plot("IN;SP1;PW1;PD0,0,10,0;PU;PW2,2;SP2;PD0,10,10,10;PU;SP1;PD0,20,10,20;PU;IN;SP1;PD0,30,10,30;"); + let widths: Vec = leaves(&i.document).iter().map(|n| stroke(n).width).collect(); + let want = [MM, 2.0 * MM, MM, 0.35 * MM]; + assert!(widths.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{widths:?}"); +} + +#[test] +fn each_run_with_the_pen_down_is_one_path() { + // Relative moves; a run that comes back to its start is closed; a pen-up move elsewhere starts + // another path, and one back to where the line ended continues it. + let i = plot("IN;SP1;PW0;PA0,0;PR;PD1016,0,0,1016,-1016,0,0,-1016;PU2032,0;PD0,1016;PU0,1016;PU0,-1016;PD508,0;PU0,500;PD0,500;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3, "{shapes:#?}"); + let NodeKind::Path { path: square, .. } = &shapes[0].kind else { panic!("a path") }; + assert_eq!((square.subpaths.len(), square.subpaths[0].anchors.len(), square.subpaths[0].closed), (1, 4, true)); + assert_eq!(ends(&bez(&shapes[1])).len(), 3, "the run went on from where it ended"); + assert_eq!(ends(&bez(&shapes[2])).len(), 2); + + // A pen-down move of no length draws a dot. + let i = plot("IN;SP1;PA500,500;PD;PA500,500;PU;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 72.0 / 1016.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn circles_arcs_and_curves_are_drawn_as_curves() { + // A circle of an inch round the pen, which stays at its center (the next line starts there). + let i = plot("IN;SP1;PW0;PA2032,2032;CI1016;PD3048,2032;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + let c = bez(&shapes[0]); + assert!(c.elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))) && matches!(c.elements().last(), Some(PathEl::ClosePath))); + // The curves stay within a thousandth of a point of the circle. + let b = c.bounding_box(); + assert!((b.width() - 144.0).abs() < 1e-3 && (b.height() - 144.0).abs() < 1e-3, "{b:?}"); + let line = ends(&bez(&shapes[1])); + assert!((line[0] - b.center()).hypot() < 1e-3, "{line:?} {b:?}"); + + // AA: a quarter turn counterclockwise round (0, 0) from (1016, 0) ends at (0, 1016), and the + // pen goes on from there. AR takes its center relative to the pen. + for s in ["IN;SP1;PW0;PA1016,0;PD;AA0,0,90;PA0,2032;", "IN;SP1;PW0;PA1016,0;PD;AR-1016,0,90;PA0,2032;"] { + let shapes = leaves(&plot(s).document); + assert_eq!(shapes.len(), 1, "{s}"); + let p = bez(&shapes[0]); + let pts = ends(&p); + // y down: (1016, 0) is at the bottom right, the arc's end on the left a quarter turn up. + let (start, end) = (pts[0], pts[pts.len() - 2]); + assert!(near(start, Point::new(72.125, 144.0)) && near(end, Point::new(0.125, 72.0)), "{s}: {pts:?}"); + assert!(near(*pts.last().unwrap(), Point::new(0.125, 0.0)), "{s}: {pts:?}"); + } + + // AT: the arc from the pen through a point to an end; points in a line give a line. + let shapes = leaves(&plot("IN;SP1;PW0;PA1016,0;PD;AT0,1016,-1016,0;").document); + let pts = ends(&bez(&shapes[0])); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 144.0).abs() < 1e-6 && (b.height() - 72.0).abs() < 1e-6, "a half circle: {b:?}"); + assert!(near(pts[0], Point::new(144.125, 72.125)) && near(*pts.last().unwrap(), Point::new(0.125, 72.125)), "{pts:?}"); + let shapes = leaves(&plot("IN;SP1;PW0;PA0,0;PD;AT508,0,1016,0;").document); + assert_eq!(ends(&bez(&shapes[0])).len(), 2); + + // BZ and BR: cubic Béziers, BR's points relative to each curve's start. + for s in [ + "IN;SP1;PW0;PA0,0;PD;BZ0,1016,1016,1016,1016,0,1016,-1016,2032,-1016,2032,0;", + "IN;SP1;PW0;PA0,0;PD;BR0,1016,1016,1016,1016,0,0,-1016,1016,-1016,1016,0;", + ] { + let shapes = leaves(&plot(s).document); + let els = bez(&shapes[0]).elements().to_vec(); + assert_eq!(els.iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2, "{s}"); + // The curves reach 762 plotter units (three quarters of their control points) up and down. + assert!(near(els.last().and_then(PathEl::end_point).unwrap(), Point::new(144.125, 54.125)), "{s}: {els:?}"); + } +} + +#[test] +fn rectangles_wedges_and_polygons_fill_and_edge() { + // RA fills, EA edges; neither moves the pen. + let i = plot("IN;SP2;PW0;PA0,0;RA1016,1016;EA1016,1016;PD508,0;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3); + assert_eq!((fill(&shapes[0]), shapes[0].appearance.items.len()), (Some(rgb(255, 0, 0)), 1)); + assert!(fill(&shapes[1]).is_none() && stroke(&shapes[1]).paint == rgb(255, 0, 0)); + assert!(near(ends(&bez(&shapes[2]))[0], Point::new(0.125, 72.125)), "the line starts where the pen was"); + + // WG fills a wedge, EW edges one; a whole turn is a circle. + let i = plot("IN;SP1;PA0,0;WG1016,0,90;EW1016,0,360;"); + let shapes = leaves(&i.document); + let wedge = bez(&shapes[0]); + assert!(fill(&shapes[0]).is_some() && ends(&wedge).len() >= 3); + let ring = bez(&shapes[1]).bounding_box(); + assert!((ring.width() - 144.0).abs() < 1e-6); + + // Polygon mode: two squares, one inside the other; FP fills even-odd by default, FP1 + // non-zero; EP edges only what was drawn with the pen down. + let poly = "IN;SP1;PW0;PA0,0;PM0;PD3048,0,3048,3048,0,3048,0,0;PM1;PU1016,1016;PD2032,1016;PU2032,2032;PD1016,2032,1016,1016;PM2;"; + let i = plot(&format!("{poly}FP;FP1;EP;")); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3); + assert_eq!((rule(&shapes[0]), rule(&shapes[1])), (FillRule::EvenOdd, FillRule::NonZero)); + let filled = bez(&shapes[0]); + assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 2); + let edged = bez(&shapes[2]); + assert_eq!(edged.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(..))).count(), 3, "the pen-up side isn't edged: {edged:?}"); + // Nothing is drawn in polygon mode itself. + assert!(leaves(&plot(poly).document).is_empty()); + + // A circle in polygon mode is a subpolygon of its own; CI and RA after PM2 work as before. + let i = plot("IN;SP1;PA2032,2032;PM0;CI1016;PM2;FP;"); + assert_eq!(leaves(&i.document).len(), 1); + + // Fill types: shading mixes the pen with white; hatching fills solid with a warning. + let i = plot("IN;SP5;PA0,0;FT10,50;RA100,100;FT3,10,45;RA200,200;"); + let shapes = leaves(&i.document); + assert_eq!((fill(&shapes[0]), fill(&shapes[1])), (Some(rgb(128, 128, 255)), Some(rgb(0, 0, 255)))); + assert_eq!(i.warnings, [HATCHES]); +} + +#[test] +fn line_types_become_dash_patterns() { + // A 10 mm pattern of line type 2 is 5 mm on, 5 mm off. + let i = plot("IN;SP1;LT2,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!(d.pattern.iter().all(|v| (v - 5.0 * MM).abs() < 1e-9) && d.pattern.len() == 2 && !d.align_corners, "{d:?}"); + // The default length is 4% of the P1-P2 diagonal; IP sets P1 and P2. + let i = plot("IN;IP0,0,3000,4000;SP1;LT3;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + let total = 200.0 * 72.0 / 1016.0; + assert!((d.pattern[0] - 0.7 * total).abs() < 1e-9 && (d.pattern[1] - 0.3 * total).abs() < 1e-9, "{d:?}"); + // Adaptive types stretch to each line; a zero length is a dot one plotter unit long. + let i = plot("IN;SP1;LT-4,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!(d.align_corners && (d.pattern[2] - 72.0 / 1016.0).abs() < 1e-12, "{d:?}"); + // UL redefines a type; LT; draws solid and LT99 gives the type back. + let i = plot("IN;SP1;UL2,1,3;LT2,10,1;PD0,0,4000,0;PU;LT;PD0,100,4000,100;PU;LT99;PD0,200,4000,200;"); + let shapes = leaves(&i.document); + let d = stroke(&shapes[0]).dash.expect("dashed"); + assert!((d.pattern[0] - 2.5 * MM).abs() < 1e-9 && (d.pattern[1] - 7.5 * MM).abs() < 1e-9, "{d:?}"); + assert!(stroke(&shapes[1]).dash.is_none() && stroke(&shapes[2]).dash.is_some()); + // Line type 0 draws a dot at each point. + let i = plot("IN;SP1;LT0;PD0,0,1000,0,2000,0;"); + assert_eq!(leaves(&i.document).len(), 3); + // LA sets the caps, joins and miter limit. + let s = stroke(&leaves(&plot("IN;SP1;LA1,4,2,4,3,9;PD0,0,100,0;").document)[0]); + assert_eq!((s.cap, s.join, s.miter_limit), (LineCap::Round, LineJoin::Round, 9.0)); +} + +#[test] +fn scaling_maps_user_units_onto_p1_and_p2() { + // Anisotropic: 100 user units across an inch. + let size = |s: &str| { + let shapes = leaves(&plot(s).document); + bez(&shapes[0]).bounding_box().size() + }; + let s = size("IN;IP0,0,1016,2032;SC0,100,0,100;SP1;PD0,0,100,100;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 144.0).abs() < 1e-9, "{s:?}"); + // Isotropic: the smaller of the two scales on both axes. + let s = size("IN;IP0,0,1016,2032;SC0,100,0,100,1;SP1;PU0,0;PD100,100;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}"); + // Point factor: 10 plotter units per user unit. + let s = size("IN;SC0,10,0,10,2;SP1;PD0,0,101.6,50.8;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 36.0).abs() < 1e-9, "{s:?}"); + // Without IP, P2 lies 11 × 8.5 in from the origin. + let s = size("IN;SC0,11,0,8.5;SP1;PD0,0,1,1;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}"); + // SC; goes back to plotter units; scaling that makes no area is ignored. + let s = size("IN;SC0,1,0,1;SC;SC0,0,0,1;SP1;PD0,0,1016,1016;"); + assert!((s.width - 72.0).abs() < 1e-9, "{s:?}"); + // RO turns the coordinate system: x runs up. + let shapes = leaves(&plot("IN;RO90;SP1;PW0;PA0,0;PD1016,0;").document); + let b = bez(&shapes[0]).bounding_box(); + assert!(b.width() < 1e-9 && (b.height() - 72.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn polyline_encoded_data_is_read() { + let mut f = b"IN;SP1;PW0;PE".to_vec(); + // Pen 2, a pen-up move to the absolute (1016, 0), then a relative line an inch up. + f.push(b':'); + f.extend(pe_number(2, false)); + f.extend(b"<="); + f.extend(pe_number(1016, false)); + f.extend(pe_number(0, false)); + f.extend(pe_number(0, false)); + f.extend(pe_number(1016, false)); + f.push(b';'); + let i = plot_bytes(&f); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + assert_eq!(stroke(&shapes[0]).paint, rgb(255, 0, 0)); + assert_points(&ends(&bez(&shapes[0])), &[(0.125, 72.0), (0.125, 0.0)]); + + // 7-bit data with two fractional bits: 4064 / 4 = 1016. + let mut f = b"IN;SP1;PW0;PE7>".to_vec(); + f.extend(pe_number(2, true)); + f.extend(pe_number(-4064, true)); + f.extend(pe_number(0, true)); + f.push(b';'); + let b = bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box(); + assert!((b.width() - 72.0).abs() < 1e-9, "{b:?}"); + + // Data that can't be decoded ends the instruction at its semicolon: none of it is read as + // instructions. + let shapes = leaves(&plot("IN;SP1;PE#PD5000,5000;PD1016,0;").document); + assert_eq!(shapes.len(), 1); + assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9); + + // Rectangle mode: a pen-up move, then a filled rectangle relative to it. + let mut f = b"IN;SP3;PE9".to_vec(); + for v in [100, 100, 1016, 508] { + f.extend(pe_number(v, false)); + } + f.push(b';'); + let shapes = leaves(&plot_bytes(&f).document); + assert_eq!(fill(&shapes[0]), Some(rgb(0, 255, 0))); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 72.0).abs() < 1e-9 && (b.height() - 36.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn printer_escapes_and_job_lines_are_skipped() { + let mut f = b"\x1b%-12345X@PJL JOB\r\n@PJL ENTER LANGUAGE=PCL\r\n\x1bE\x1b%0BIN;SP1;PW0;PD0,0,1016,0;PU;\x1b%0A".to_vec(); + // An HP RTL raster image whose data spells out instructions. + f.extend(b"\x1b*r1A\x1b*b11WPD9999,9999\x1b*rC"); + f.extend(b"\x1b%1BPD1016,1016;\x1b%-12345X@PJL EOJ\r\n"); + assert!(is_hpgl(&f)); + let i = plot_bytes(&f); + let shapes = leaves(&i.document); + // The pen went up and came down where the line ended, so the line goes on. + assert_eq!(shapes.len(), 1, "{shapes:#?}"); + assert_eq!(ends(&bez(&shapes[0])).len(), 3); + assert_eq!(i.warnings, [RASTER], "{:?}", i.warnings); + let r = i.document.artboards[0].rect; + assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}"); + + // The device-control escapes of older plotters. + let f = b"\x1b.(\x1b.I81;;17:\x1b.N;19:IN;SP1;PD0,0,1016,0;\x1b.)"; + assert!(is_hpgl(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1); +} + +#[test] +fn labels_comments_and_unknown_instructions_are_skipped() { + // A label's text, up to its terminator (ETX, or the one DT sets), isn't read as instructions. + let i = plot("IN;SP1;LBPD9999,9999\x03DT*,1;LBPU0,0;PD5000,5000*CO\"PD8000,8000\";PD1016,0;PU;ZZ1,2;QQ;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + assert_eq!(i.warnings, [LABELS.to_string(), "2 instructions of kinds VectorCraft doesn't read were skipped".to_string()]); + // Symbol mode's symbols aren't drawn, with a warning; its lines are. + let i = plot("IN;SP1;SM*;PD100,0;SM;"); + assert_eq!((i.warnings.clone(), leaves(&i.document).len()), (vec![SYMBOLS.to_string()], 1)); + // Later pages and the input window are warned about. + let i = plot("IN;IW0,0,10,10;SP1;PD100,0;PU;PG;PD0,100;PG;"); + assert_eq!(i.warnings, [WINDOW, PAGES]); + // A page advance raises the pen at the origin: nothing joins the pages. + let i = plot("IN;SP1;PD100,0;PG;PA200,0;PD300,0;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + assert_eq!(ends(&bez(&shapes[1])).len(), 2, "the second page's line starts at (200, 0)"); + // A page advance at the end changes nothing. + assert!(plot("IN;SP1;PD100,0;PG;").warnings.is_empty()); + // A file that draws nothing opens as a letter-size page. + let i = plot("IN;SP1;PU100,100;"); + assert!(leaves(&i.document).is_empty() && i.document.artboards[0].rect.size() == kurbo::Size::new(612.0, 792.0)); +} + +#[test] +fn only_plot_files_are_taken_for_hpgl() { + for f in [ + &b"IN;SP1;PU0,0;PD100,100;"[..], + b"in;sp1;pu0,0;pd100,100;", + b";IN;\r\nSP1;\r\nPA100,100;", + b"\x03IN;", + b"SP1PD0,0,10,10PU", + b"IN", + b"CO\"made by hand\";IN;", + b"IN;SP1;DT*;LBhello world*PA0,0;SMx;PA1,1;SM;", + b"IN;SP1;PE<=\xbf\xbf;PU;", + b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\nIN;SP1;", + ] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + } + for f in [ + &b""[..], + b"IN THE BEGINNING", + b"set terminal png\nplot sin(x)\n", + b"Paris, 10 to 12", + b"SPDX-License-Identifier: MIT", + b"CONTENTS", + b"CO \"a comment\";hello world", + b"DT*;LBtext*nope", + b"\x89PNG\r\n\x1a\n", + b"IN;SP1;PD0,0;hello world", + b"\x1b%-12345X@PJL ENTER LANGUAGE=PCL\n\x1bE\x1b*r1A", + ] { + assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } + // A comment needs no string; instructions of an older or a device's own set count when a + // semicolon ends them. + for f in [&b"CO;IN;SP1;PD0,0,10,10;"[..], b"IN;CO;SP1;PD0,0,10,10;", b"IN;SP1;VA;VS10;PU0,0;PD1016,0;", b"IN;GC5;SP1;PD0,0,10,0;"] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + } + assert!(!is_hpgl(b"IN;GC5") && !is_hpgl(b"IN;Hello, world;")); + // A printer prolog of any length comes before the first instruction. + let long = [&b"\x1b%-12345X"[..], &b"@PJL COMMENT a job line of some length\r\n".repeat(400), b"\x1bE\x1b%0BIN;SP1;PD100,0;"].concat(); + assert!(long.len() > 16_000 && is_hpgl(&long)); + assert_eq!(leaves(&plot_bytes(&long).document).len(), 1); + // Instructions of any kind may follow the first eight, garbage included. + assert!(is_hpgl(b"IN;SP1;PU;PD;PU;PD;PU;PD;hello world")); +} + +/// A file read as HP-GL (picked by its extension) may start with any instruction the reader knows, +/// or one of another set that a semicolon ends; its content alone doesn't make it HP-GL. +#[test] +fn a_plot_file_may_start_with_any_instruction() { + for f in [&b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"[..], b"DI1,0;SP1;PA0,0;PD1016,0;", b"GC5;SP1;PA0,0;PD1016,0;"] { + assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f)); + } + for f in [&b"GC5"[..], b"SIhello world", b"set terminal png\n"] { + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } + // Instructions of another set alone, however many or long, are not taken for HP-GL. + let long = [&b"GC"[..], "1".repeat(5000).as_bytes(), b";ZZ1;"].concat(); + for f in [&b"GC5;"[..], b"GC5;ZZ1;", b"GC5;ZZ1;YY2;XX3;WW4;VV5;UU6;TT7;", &long] { + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } +} + +#[test] +fn escapes_that_carry_values_or_data_are_skipped_whole() { + // A reset leaves HP-GL until a language switch; symbol-set escapes carry a value and a letter. + for f in [ + &b"\x1bE\x1b(8U\x1b%1BIN;SP1;PD0,0,1016,0;"[..], + b"\x1bE\x1b(10U\x1b)0N\x1b%1BIN;SP1;PD0,0,1016,0;", + b"\x1bEHello\x1b%1BIN;SP1;PD0,0,1016,0;", + ] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f)); + } + // A letter left over from an escape doesn't join the next mnemonic. + let i = plot_bytes(b"IN;SP1;\x1b(8UPD0,0,1016,0;"); + assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{:?}", i.warnings); + // Raster data by plane and transparent print data are skipped by their byte counts, however + // they read. + let line = |data_escape: &[u8]| { + let data = b"\x1b%1BPD0,5000;"; + let mut f = b"IN;SP1;PW0;PD0,0,0,1016;\x1b%0A".to_vec(); + f.extend(data_escape); + f.extend(data.len().to_string().as_bytes()); + f.extend(if data_escape.ends_with(b"b") { &b"V"[..] } else { b"X" }); + f.extend(data); + f.extend(b"\x1b*rC\x1b%1BPD0,0;"); + bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box() + }; + for escape in [&b"\x1b*r1A\x1b*b"[..], b"\x1b&p"] { + let b = line(escape); + assert!((b.height() - 72.0).abs() < 1e-9, "{:?}: {b:?}", String::from_utf8_lossy(escape)); + } +} + +#[test] +fn polygon_mode_follows_its_own_rules() { + // The point after a circle starts a new subpolygon: the triangle after it doesn't reach back + // to the circle's center, filled or edged. + let i = plot("IN;SP1;PW0;PA1000,1000;PM0;CI200;PD2000,1000,2000,2000,1000,2000;PM2;FP;EP;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + for s in &shapes { + let p = bez(s); + let first = p.elements().iter().position(|e| matches!(e, PathEl::ClosePath)).unwrap_or(p.elements().len()); + let circle = BezPath::from_vec(p.elements()[..first].to_vec()).bounding_box(); + let center = circle.center(); + assert!(ends(&p).iter().all(|e| !near(*e, center)), "{p:?}"); + } + // PM1 closes with the pen up: EP draws two sides of the triangle, not the third; PM2 closes + // with the pen as it is. + let sides = |s: &str| { + let p = bez(&leaves(&plot(s).document)[0]); + p.elements().iter().filter(|e| matches!(e, PathEl::LineTo(..))).count() + }; + assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PM2;EP;"), 2); + assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM2;EP;"), 3); + // The closing point moves the pen: a relative move after PM1 starts from the start. + let i = plot("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PU;PR0,2000;PD1000,0,0,1000;PM2;FP;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.height() - 3000.0 * PT_PER_PLU).abs() < 1e-9, "{b:?}"); + // A subpolygon of fewer than three points (the closing point counted) is neither filled nor + // edged. + assert!(leaves(&plot("IN;SP1;PA0,0;PM0;PD0,0;PM2;FP;EP;").document).is_empty()); + let i = plot("IN;SP1;PA0,0;PM0;PR0,0;PM1;PA;PD1000,0,0,1000,0,0;PM2;FP;"); + let filled = bez(&leaves(&i.document)[0]); + assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 1, "{filled:?}"); + // Polygon mode ignores the instructions it doesn't allow: pens, scaling and labels. + let i = plot("IN;SP1;PA0,0;PM0;SP2;SC0,1,0,1;LBPD9999,9999\x03PD1016,0,1016,1016;PM2;FP;"); + let shapes = leaves(&i.document); + assert_eq!((shapes.len(), fill(&shapes[0])), (1, Some(rgb(0, 0, 0)))); + assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9 && i.warnings.is_empty(), "{:?}", i.warnings); +} + +#[test] +fn rotation_keeps_the_pen_and_turns_the_defaults() { + // After RO90, IP's default P2 is the rotated hard-clip corner: the user square spans 8.5 in + // across its rotated x axis and 11 in up it, which lie up and across the page. + let i = plot("IN;RO90;IP;SC0,100,0,100;SP1;PW0;PA0,0;PD100,0,100,100,0,100,0,0;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}"); + // IR's percentages are of the rotated limits too. + let i = plot("IN;RO90;IR0,0,100,100;SC0,1,0,1;SP1;PW0;PA0,0;PD1,1;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}"); + // The pen stays where it was on the page: a line after RO starts where the one before ended. + let i = plot("IN;SP1;PW0;PD0,0,1016,0;PU;RO90;PD;PR1016,0;"); + let all = leaves(&i.document).iter().map(|n| bez(n).bounding_box()).reduce(|a, b| a.union(b)).unwrap(); + assert!((all.width() - 72.0).abs() < 1e-9 && (all.height() - 72.0).abs() < 1e-9, "{all:?}"); +} + +#[test] +fn line_type_zero_and_odd_patterns_follow_the_reference() { + // Line type 0: one dot for an arc (at its end) and one for a circle (at its center). + for s in ["IN;SP1;LT0;PA0,0;PD;AA1016,0,180;", "IN;SP1;LT0;PA0,0;CI500;"] { + let shapes = leaves(&plot(s).document); + assert_eq!(shapes.len(), 1, "{s}"); + assert!(bez(&shapes[0]).bounding_box().width() < 0.1, "{s}"); + } + // An odd number of gaps repeats with the last pen-down stretch running into the first. + let i = plot("IN;SP1;UL3,50,25,25;LT3,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!((d.pattern[0] - 7.5 * MM).abs() < 1e-9 && (d.pattern[1] - 2.5 * MM).abs() < 1e-9 && d.pattern.len() == 2, "{d:?}"); + assert!((d.offset - 2.5 * MM).abs() < 1e-9, "{d:?}"); + // One pen-down stretch is a solid line; a dot keeps the pattern's length. + let d = stroke(&leaves(&plot("IN;SP1;UL3,100;LT3,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed"); + assert_eq!(d.pattern[1], 0.0); + let d = stroke(&leaves(&plot("IN;SP1;LT4,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed"); + assert!((d.pattern.iter().sum::() - 10.0 * MM).abs() < 1e-9, "{d:?}"); +} + +#[test] +fn pen_colors_ignore_two_or_three_parameters() { + let i = plot("IN;PC3,0,0,255;PC3,255;PC3,255,0;SP3;PD0,0,10,0;"); + assert_eq!(stroke(&leaves(&i.document)[0]).paint, rgb(0, 0, 255)); +} + +#[test] +fn hostile_files_import_without_panics() { + let mut cases: Vec> = vec![ + // Coordinates past any plotter's range, and numbers with too many digits. + b"IN;SP1;PD99999999999999999999999999999,0;PU0,0;PD1016,1016;".to_vec(), + b"IN;SP1;PD0.000000000000000000000000001,1e999,--,++,..;".to_vec(), + // Unterminated labels, comments and encoded polylines. + b"IN;SP1;LBno end".to_vec(), + b"IN;CO\"no end".to_vec(), + b"IN;SP1;PE<=\xff\xff\xff\xfe\xfe".to_vec(), + b"IN;SP1;PE>\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\xfe\x40\xc0;".to_vec(), + // Arcs and wedges of every degenerate kind, with huge sweeps. + b"IN;SP1;PD;AA0,0,32767;AR0,0,90;AT0,0,0,0;RT1,1,2,2;CI0;CI-5;WG0,0,0;EW5,99999,-99999;WG-5,0,360;CI99999999999999;".to_vec(), + // Scaling onto one point, rotations the format doesn't define, huge palettes and pens. + b"IN;IP5,5,5,5;SC0,1,0,1,1;SP1;PD1,1;SC0,0,0,0,2;RO45;NP99999999;SP99999999999;PC99999999,1,2,3;PW99999,99999;PD2,2;".to_vec(), + // Polygon mode left open, PM1 and PM2 without PM0, FP and EP with nothing buffered. + b"IN;SP1;FP;EP;PM1;PM2;PM0;PD10,10;PM1;PM1;PM1;CI5;PE<\xc0\xc0;".to_vec(), + // Escapes cut short, and data lengths past the end. + b"\x1b%-12345X@PJL".to_vec(), + b"IN;SP1;PD0,0;\x1b*b99999999999W".to_vec(), + b"IN;\x1b".to_vec(), + b"IN;\x1b.".to_vec(), + ]; + // A job line with no end. + cases.push([&b"\x1b%-12345X@PJL "[..], &b"x".repeat(1 << 20)].concat()); + // Bytes of every value after a valid start. + cases.push([&b"IN;SP1;PD"[..], &(0..=255u8).collect::>()].concat()); + for f in &cases { + let r = std::panic::catch_unwind(|| import(f)); + assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(f)); + } + let far = import(&cases[0]).unwrap(); + assert!(far.warnings.iter().any(|w| w == FAR_AWAY), "{:?}", far.warnings); + assert_eq!(leaves(&far.document).len(), 1, "the line in range is kept"); + + // Past the budget for path points, the rest is left out with a warning; pen-up moves, which + // make no path, don't count. + let many = format!("IN;SP1;PR;PD{}", "1,0,".repeat(300_000)); + let i = plot(&many); + assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", i.warnings); + // Scaling points past any range give relative pen widths no wider than the widest pen. + let i = plot("IN;IP-99999999999999999999,0,99999999999999999999,0;WU1;PW32767;SP1;PD100,0;"); + let r = i.document.artboards[0].rect; + assert!(r.width().is_finite() && r.height() <= 16384.0 * 72.0 / 1016.0 + 1e-6, "{r:?}"); + assert!((stroke(&leaves(&i.document)[0]).width - 16384.0 * 72.0 / 1016.0).abs() < 1e-9); + + // Each fill of the polygon buffer is a copy of it, and counts. + let big = format!("IN;SP1;PM0;PR;PD{}PM2;{}", "1,1,".repeat(1000), "FP;EP;".repeat(1000)); + let i = plot(&big); + assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS) && leaves(&i.document).len() < 400, "{:?}", i.warnings); + for pen in ["SP1;PU", "SP0;PD"] { + let moves = format!("IN;{pen}{};SP1;PU0,0;PD1016,0;", "1,0,".repeat(300_000)); + let i = plot(&moves); + assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{pen}: {:?}", i.warnings); + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 6035211600..525e9b6734 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,4 +1,5 @@ -//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader. +//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an +//! HP-GL/2 plot file reader. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -11,13 +12,16 @@ //! clipping) is approximated or left out, and each such loss comes back as a warning. //! //! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. +//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. //! - `writer`: group codes, handles and the file's sections, tables and objects. //! - `scene`: the document walk that turns objects into entities. //! - `import`: the reader, entities to art, blocks to symbols. +//! - `hpgl`: the HP-GL reader, the pen's moves to paths. mod aci; +pub mod hpgl; pub mod import; mod scene; mod writer; diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 03d0f5670c..c5803e3224 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -15,20 +15,42 @@ pub enum GradientKind { Radial, /// Freeform gradients: colour points and lines (see [`crate::freeform`]). Freeform, + /// Conic (angle) gradients: the stops sweep once around the center (`start`), clockwise on + /// the page from the vector, with a seam where the last stop meets the first. A negative + /// `aspect` mirrors the sweep (see [`GradientGeom::transform`]). + Conic, + /// Diamond gradients: the stops run from the center (`start`) out to a square whose + /// half-diagonal is the vector (a diamond when the vector is level), stretched across the + /// vector by `aspect` (see [`GradientGeom::diamond_frame`]). + Diamond, } impl GradientKind { - /// Parse a kind name (`linear`, `radial`, `freeform`; any case). + /// Every kind, in the order the Gradient panel lists them. + pub const ALL: [GradientKind; 5] = + [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond, GradientKind::Freeform]; + /// Parse a kind name (`linear`, `radial`, `conic`, `diamond`, `freeform`; any case). `angle` + /// and `conical` also mean conic. pub fn parse(s: &str) -> Option { - [GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s)) + if s.eq_ignore_ascii_case("angle") || s.eq_ignore_ascii_case("conical") { + return Some(GradientKind::Conic); + } + Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s)) } pub fn label(self) -> &'static str { match self { GradientKind::Linear => "Linear", GradientKind::Radial => "Radial", GradientKind::Freeform => "Freeform", + GradientKind::Conic => "Conic", + GradientKind::Diamond => "Diamond", } } + /// Is it placed by a center (`start`) and an extent around it (radial, conic and diamond), + /// rather than by a line? + pub fn is_centered(self) -> bool { + matches!(self, GradientKind::Radial | GradientKind::Conic | GradientKind::Diamond) + } } /// How a gradient mixes the colours between its stops (the Gradient panel's Method). @@ -642,19 +664,32 @@ pub struct GradientGeom { /// How far out a focal point may sit, as a fraction of the extent ellipse. const FOCAL_MAX: f64 = 0.99; +/// Most wedges [`GradientGeom::conic_wedges`] draws a conic gradient as. +pub const MAX_CONIC_WEDGES: usize = 4096; + fn one64() -> f64 { 1.0 } impl GradientGeom { /// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`, - /// radial is centred with radius = half the larger dimension and its vector at `angle_deg`. + /// radial and conic are centered with radius = half the larger dimension and their vector at + /// `angle_deg`, and diamond is centered with its vector at `angle_deg`, its square's edge + /// running through the box's farthest corner (at 0 or 90 degrees, through all four). pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self { let c = b.center(); let a = angle_deg.to_radians(); let d = Vec2::new(a.cos(), -a.sin()); match kind { - GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None }, + GradientKind::Radial | GradientKind::Conic => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None }, + GradientKind::Diamond => { + // The square through the box's farthest corner: the largest reach along the vector + // plus across it, at one corner. + let (w, h) = (b.width() / 2.0, b.height() / 2.0); + let level = |x: f64, y: f64| (x * d.x + y * d.y).abs() + (y * d.x - x * d.y).abs(); + let half = level(w, h).max(level(w, -h)); + Self { start: c, end: c + d * half, aspect: 1.0, focal: None } + } _ => { // Project the box corners onto the direction to cover the whole box. let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; @@ -668,7 +703,16 @@ impl GradientGeom { /// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the /// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse /// (the radius along the vector, `aspect` × the radius across it); its principal axes give the - /// new end (on the axis nearest the mapped vector) and aspect. + /// new end (on the axis nearest the mapped vector) and aspect. Conic gradients keep their + /// vector exactly (where the sweep starts, so the seam stays in place) and take the mapped + /// extent across it as their aspect, its sign flipping under a mirror so the sweep turns the + /// other way: exact for moves, rotations, uniform scales and mirrors, and the nearest frame + /// that keeps the seam under a shear or a non-uniform scale. A conic whose vector has no + /// length, or whose aspect is 0, still has the sign of its aspect flipped by a mirror. + /// Diamond gradients keep their vector (a corner of the square) the same way, with the size + /// of the mapped extent across it as their aspect: exact for moves, rotations, scales along or + /// across the vector and mirrors, and the nearest square stretched across its vector under a + /// shear or another scale. pub fn transform(&mut self, a: Affine, kind: GradientKind) { // The focal point is a point like any other. self.focal = self.focal.map(|f| a * f); @@ -677,9 +721,34 @@ impl GradientGeom { let perp = |v: Vec2| Vec2::new(-v.y, v.x); let start = a * self.start; let u = self.end - self.start; + let mirror = m0 * m3 - m1 * m2 < 0.0; if u.hypot2() < 1e-18 { self.start = start; self.end = start; + if kind == GradientKind::Conic && mirror { + self.aspect = -self.aspect; + } + return; + } + if kind == GradientKind::Conic { + let (lu, lw) = (lin(u), lin(perp(u) * self.aspect)); + self.start = start; + self.end = start + lu; + let aspect = lw.dot(perp(lu)) / lu.hypot2(); + self.aspect = match (lu.hypot2() > 1e-18 && aspect.is_finite(), aspect == 0.0) { + (false, _) => 1.0, + // A flat extent keeps its sign, so a mirror still turns the sweep the other way. + (true, true) => 0.0f64.copysign(if self.aspect.is_sign_negative() != mirror { -1.0 } else { 1.0 }), + (true, false) => aspect, + }; + return; + } + if kind == GradientKind::Diamond { + let (lu, lw) = (lin(u), lin(perp(u) * self.aspect)); + self.start = start; + self.end = start + lu; + let aspect = (lw.dot(perp(lu)) / lu.hypot2()).abs(); + self.aspect = if lu.hypot2() > 1e-18 && aspect.is_finite() { aspect } else { 1.0 }; return; } if kind != GradientKind::Radial { @@ -724,8 +793,24 @@ impl GradientGeom { /// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial, /// measured from the focal point (each level is the ellipse scaled about the line from the - /// focal point to the centre). + /// focal point to the centre). For conic gradients it is the turn from the vector in + /// [`Self::conic_frame`], clockwise on the page (counterclockwise when mirrored), in 0..=1. + /// For diamond gradients it is the size of the square through `p`, as a fraction of the one + /// [`Self::diamond_frame`] gives (1 everywhere for a diamond without a frame). pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { + if kind == GradientKind::Conic { + let q = (self.conic_frame().inverse() * p).to_vec2(); + let t = q.y.atan2(q.x) / std::f64::consts::TAU; + let t = if t < 0.0 { t + 1.0 } else { t }; + return if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + } + if kind == GradientKind::Diamond { + // Without a frame the square has no inside: every point is past its edge. + let Some(m) = self.diamond_frame() else { return 1.0 }; + let q = (m.inverse() * p).to_vec2(); + let t = q.x.abs() + q.y.abs(); + return if t.is_nan() { 1.0 } else { t }; + } let u = self.end - self.start; if u.hypot2() < 1e-18 { return 0.0; @@ -746,13 +831,92 @@ impl GradientGeom { (-b - (b * b - a * d.hypot2()).max(0.0).sqrt()) / a } - /// The map from the unit circle onto a radial's extent ellipse: (1, 0) to the end, (0, 1) to - /// `aspect` × the radius across the vector. None when the ellipse is flat (no inverse). + /// The map from the unit circle onto a radial's (or conic's) extent ellipse: (1, 0) to the + /// end, (0, 1) to `aspect` × the radius across the vector (a conic's negative aspect mirrors + /// it). None when the ellipse is flat (no inverse). pub fn unit_frame(&self) -> Option { let u = self.end - self.start; (u.hypot2() * self.aspect.abs() > 1e-18).then(|| Affine::new([u.x, u.y, -u.y * self.aspect, u.x * self.aspect, self.start.x, self.start.y])) } + /// The frame a conic gradient sweeps in: its [`Self::unit_frame`], or, when the extent + /// ellipse is flat or not finite (a vector without length, an aspect of 0, or a file's + /// out-of-range numbers), the unit circle about the start turning from the vector's direction + /// (from the right when the vector has none), mirrored by a negative aspect. The canvas, + /// Expand and every export draw a conic in this frame. + pub fn conic_frame(&self) -> Affine { + if let Some(m) = self.unit_frame().filter(|m| m.is_finite() && m.determinant().abs() > 1e-18) { + return m; + } + if !self.start.is_finite() { + return Affine::IDENTITY; + } + let u = self.end - self.start; + let len = u.hypot(); + let d = if len.is_finite() && len > 1e-9 { u / len } else { Vec2::new(1.0, 0.0) }; + let s = if self.aspect.is_sign_negative() && !self.aspect.is_nan() { -1.0 } else { 1.0 }; + Affine::new([d.x, d.y, -d.y * s, d.x * s, self.start.x, self.start.y]) + } + + /// Is the sweep mirrored (counterclockwise on the page): a conic's negative aspect? + pub fn is_mirrored(&self) -> bool { + self.conic_frame().determinant() < 0.0 + } + + /// A conic gradient as `n` solid wedges around its center (at most [`MAX_CONIC_WEDGES`]), for + /// formats without conic gradients: each wedge's outline (the center, then an arc that + /// reaches past every corner of `b`) and the turn its color is sampled at (its middle). With + /// `overlap` (opaque stops), each wedge runs on to the seam and the next is drawn over it, so + /// no hairline of what lies below shows between neighbors and the seam stays sharp. + pub fn conic_wedges(&self, b: Rect, n: usize, overlap: bool) -> Vec<(Vec, f64)> { + let n = n.clamp(1, MAX_CONIC_WEDGES); + let frame = self.conic_frame(); + let r = fan_radius(frame, b); + (0..n) + .map(|k| { + let from = k as f64 / n as f64; + let to = if overlap { 1.0 } else { (k + 1) as f64 / n as f64 }; + (fan(frame, r, from, to), (k as f64 + 0.5) / n as f64) + }) + .collect() + } + + /// The frame a diamond gradient is drawn in: its [`Self::unit_frame`], which maps the square + /// with corners (±1, 0) and (0, ±1) onto the diamond (its first corner at the end of the + /// vector). `None` when the square is flat or not finite (a vector without length, an aspect + /// of 0, or a file's out-of-range numbers): such a diamond has no inside, and every point + /// takes its last stop. + pub fn diamond_frame(&self) -> Option { + self.unit_frame().filter(|m| m.is_finite() && m.determinant().abs() > 1e-18) + } + + /// A diamond gradient as four linear gradients, one for each quadrant of its + /// [`Self::diamond_frame`], for formats without diamond gradients: each quadrant's outline (the + /// center, then an arc that reaches past every corner of `b`) and the linear gradient that + /// equals the diamond there (its stops run across the square's edge in that quadrant), in the + /// order they are drawn. Each of the first three runs on past its last edge under the next, + /// and the first also back past its first edge under the last, so every edge drawn lies on + /// the quadrant before it and no hairline of what lies below shows between them; where they + /// overlap, translucent stops would add up, so the stops must be opaque. `None` for a diamond + /// without a frame. + pub fn diamond_quadrants(&self, b: Rect) -> Option<[(Vec, GradientGeom); 4]> { + let frame = self.diamond_frame()?; + let r = fan_radius(frame, b); + Some(std::array::from_fn(|k| { + let first = k as f64 / 4.0; + let (from, to) = match k { + 0 => (-0.125, 0.375), + 3 => (0.75, 1.0), + _ => (first, first + 0.375), + }; + // The quadrant's corner of the square, halfway along whose edge t is 1/2. + let (sx, sy) = [(1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0)][k]; + let mut geom = GradientGeom { start: Point::ORIGIN, end: Point::new(sx / 2.0, sy / 2.0), aspect: 1.0, focal: None }; + geom.transform(frame, GradientKind::Linear); + (fan(frame, r, from, to), geom) + })) + } + /// The map that squashes a radial gradient's circle (centre `start`, radius the vector's /// length) into its extent ellipse: by `aspect` across the vector, about the centre. pub fn radial_squash(&self) -> Affine { @@ -799,6 +963,34 @@ impl GradientGeom { } } +/// Arc points of [`fan`] are at most this much of a turn apart. +const FAN_ARC: f64 = 1.0 / 32.0; + +/// The radius of the unit circle of `frame` at which the chords of [`fan`]'s arcs stay outside +/// every corner of `b`. +fn fan_radius(frame: Affine, b: Rect) -> f64 { + let to_unit = frame.inverse(); + let corners = [b.origin(), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; + let reach = corners.iter().map(|&c| (to_unit * c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6); + let reach = if reach.is_finite() { reach * 1.01 } else { 1.0 }; + reach / (std::f64::consts::PI * FAN_ARC).cos() +} + +/// The sector of `frame`'s unit circle, scaled to radius `r`, from turn `from` to turn `to` +/// (counted from the frame's x axis toward its y axis) as an outline: the center, then points on +/// the arc at most [`FAN_ARC`] of a turn apart. +fn fan(frame: Affine, r: f64, from: f64, to: f64) -> Vec { + let at = |turn: f64| { + let a = turn * std::f64::consts::TAU; + frame * Point::new(r * a.cos(), r * a.sin()) + }; + let steps = ((to - from) / FAN_ARC).ceil().max(1.0) as usize; + let mut pts = Vec::with_capacity(steps + 2); + pts.push(frame * Point::ORIGIN); + pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64))); + pts +} + /// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side /// `from` has no extent on, its offset from the centre). fn rebase_point(p: Point, from: Rect, to: Rect) -> Point { @@ -901,7 +1093,7 @@ impl GradientPaint { pub fn draws_as_pixels(&self, fill: bool) -> bool { self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic() } - /// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of + /// A linear, radial, conic or diamond gradient placed on box `bounds` as a `cols` × `rows` grid of /// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours /// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a /// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets @@ -932,7 +1124,7 @@ impl GradientPaint { ) -> impl Iterator + use<> { self.grid(Ramp::new(&self.gradient, rgb), bounds, area, cols, rows, cells, fill, false) } - /// [`Self::pixels`] of a linear or radial gradient whose stops are all CMYK, as C, M, Y, K + /// [`Self::pixels`] of a linear, radial, conic or diamond gradient whose stops are all CMYK, as C, M, Y, K /// and opacity levels mixed from the stops' inks; `None` when a stop is in another model, /// there are no stops, or a Perceptual curve has too many stops for [`Gradient::ink_mixes`]. /// Dither's noise adds no ink where the ramp has none. diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 87372a1c24..0709d3068b 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -697,3 +697,360 @@ fn ink_pixels_stop_at_the_ink_mixes_limit() { assert!(GradientPaint::new(perceptual).ink_pixels(b, b, 2, 1, false).is_none()); assert!(GradientPaint::new(classic).ink_pixels(b, b, 2, 1, false).is_some()); } + +/// A conic placed at `center` with its vector `dx` long to the right and aspect `aspect`. +fn conic_at(center: Point, dx: f64, aspect: f64) -> GradientGeom { + GradientGeom { start: center, end: center + Vec2::new(dx, 0.0), aspect, focal: None } +} + +#[test] +fn a_conic_turns_clockwise_on_the_page_from_its_vector() { + // The page's y runs down: a quarter turn clockwise from a vector pointing right is straight + // below the center. + let g = conic_at(Point::new(100.0, 100.0), 50.0, 1.0); + let t = |x: f64, y: f64| g.param_at(GradientKind::Conic, Point::new(x, y)); + assert!(t(200.0, 100.0).abs() < 1e-12); + assert!((t(100.0, 130.0) - 0.25).abs() < 1e-12); + assert!((t(10.0, 100.0) - 0.5).abs() < 1e-12); + assert!((t(100.0, 3.0) - 0.75).abs() < 1e-12); + // Either side of the seam: the start of the ramp just below the vector, its end just above. + assert!(t(200.0, 100.01) < 1e-3); + let before = t(200.0, 99.99); + assert!(before > 0.999 && before <= 1.0, "{before}"); + // A flatter extent ellipse turns faster near the vector's line: half as tall, the point 1/8 + // of a turn around is half as far below it. + let squashed = conic_at(Point::new(100.0, 100.0), 50.0, 0.5); + let p = Point::new(150.0, 125.0); + assert!((squashed.param_at(GradientKind::Conic, p) - 0.125).abs() < 1e-12); + assert!(g.param_at(GradientKind::Conic, p) < 0.1); + // Mirrored (a negative aspect), it turns counterclockwise. + let mirrored = conic_at(Point::new(100.0, 100.0), 50.0, -1.0); + assert!(mirrored.is_mirrored() && !g.is_mirrored()); + assert!((mirrored.param_at(GradientKind::Conic, Point::new(100.0, 70.0)) - 0.25).abs() < 1e-12); +} + +#[test] +fn a_conic_follows_moves_turns_scales_and_mirrors() { + let c = Point::new(10.0, 20.0); + let g0 = conic_at(c, 30.0, 1.0); + let probes = [c + Vec2::new(5.0, 7.0), c + Vec2::new(-9.0, 2.0), c + Vec2::new(3.0, -11.0)]; + for a in [ + Affine::rotate(0.7), + Affine::scale(2.5), + Affine::translate((4.0, -9.0)) * Affine::rotate(-2.0), + Affine::FLIP_X, + Affine::FLIP_Y * Affine::rotate(1.1), + Affine::scale_non_uniform(2.0, 0.5), + ] { + let mut g = g0; + g.transform(a, GradientKind::Conic); + assert!(g.start.distance(a * c) < 1e-9, "{a:?}"); + for p in probes { + let (want, got) = (g0.param_at(GradientKind::Conic, p), g.param_at(GradientKind::Conic, a * p)); + assert!((want - got).abs() < 1e-9, "{a:?} at {p:?}: {want} vs {got}"); + } + } + // A mirror and a mirror back: the same sweep. + let mut g = g0; + g.transform(Affine::FLIP_X, GradientKind::Conic); + assert!(g.is_mirrored()); + g.transform(Affine::FLIP_X, GradientKind::Conic); + assert!(!g.is_mirrored() && (g.aspect - 1.0).abs() < 1e-12); + // A conic without a vector, or with a flat extent, turns the other way under a mirror too. + for flat in [conic_at(c, 0.0, 1.0), conic_at(c, 30.0, 0.0)] { + let mut g = flat; + g.transform(Affine::FLIP_Y, GradientKind::Conic); + assert!(g.is_mirrored(), "{g:?}"); + g.transform(Affine::rotate(0.3), GradientKind::Conic); + assert!(g.is_mirrored(), "{g:?}"); + g.transform(Affine::FLIP_X, GradientKind::Conic); + assert!(!g.is_mirrored(), "{g:?}"); + } + // Under a shear the seam stays where the vector maps. + let shear = Affine::new([1.0, 0.0, 0.6, 1.0, 0.0, 0.0]); + let mut g = g0; + g.transform(shear, GradientKind::Conic); + let on_seam = shear * (c + Vec2::new(20.0, 0.0)); + assert!(g.param_at(GradientKind::Conic, on_seam) < 1e-9, "{g:?}"); +} + +#[test] +fn conic_kind_names_and_fit() { + for s in ["conic", "Conic", "angle", "CONICAL"] { + assert_eq!(GradientKind::parse(s), Some(GradientKind::Conic), "{s}"); + } + assert_eq!(GradientKind::parse("spiral"), None); + assert_eq!(GradientKind::Conic.label(), "Conic"); + assert!(GradientKind::Conic.is_centered() && GradientKind::Radial.is_centered() && !GradientKind::Linear.is_centered()); + let fit = GradientGeom::fit(GradientKind::Conic, Rect::new(0.0, 0.0, 200.0, 100.0), 90.0); + assert_eq!(fit.start, Point::new(100.0, 50.0)); + assert!(fit.end.distance(Point::new(100.0, -50.0)) < 1e-9); +} + +/// Is `p` inside polygon `poly` (non-zero winding)? +fn inside(poly: &[Point], p: Point) -> bool { + let mut w = 0i32; + for (i, &a) in poly.iter().enumerate() { + let c = poly[(i + 1) % poly.len()]; + let cross = (c - a).cross(p - a); + if a.y <= p.y && c.y > p.y && cross > 0.0 { + w += 1; + } else if a.y > p.y && c.y <= p.y && cross < 0.0 { + w -= 1; + } + } + w != 0 +} + +#[test] +fn conic_wedges_cover_the_box_and_meet_at_the_seam() { + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + let g = conic_at(Point::new(60.0, 40.0), 30.0, 0.5); + let wedges = g.conic_wedges(b, 36, false); + assert_eq!(wedges.len(), 36); + // Each point of the box lies in the wedge whose share of the turn holds it. + for (x, y) in [(1.0, 1.0), (199.0, 99.0), (150.0, 30.0), (10.0, 90.0), (61.0, 39.0), (59.0, 41.0)] { + let p = Point::new(x, y); + let t = g.param_at(GradientKind::Conic, p); + let k = ((t * 36.0).floor() as usize).min(35); + assert!(inside(&wedges[k].0, p), "({x}, {y}) at {t} is not in wedge {k}"); + assert!((wedges[k].1 - (k as f64 + 0.5) / 36.0).abs() < 1e-12); + } + // Overlapping wedges run on from their own start to the seam. + let over = g.conic_wedges(b, 36, true); + let frame = g.unit_frame().unwrap(); + let on = |turn: f64| frame * Point::new(0.5 * (turn * std::f64::consts::TAU).cos(), 0.5 * (turn * std::f64::consts::TAU).sin()); + assert!(inside(&over[0].0, on(0.5)) && inside(&over[0].0, on(35.9 / 36.0))); + assert!(inside(&over[20].0, on(35.9 / 36.0)) && !inside(&over[20].0, on(19.9 / 36.0))); + // The count is kept to what a file can hold. + assert_eq!(g.conic_wedges(b, 0, false).len(), 1); + assert_eq!(g.conic_wedges(b, usize::MAX, false).len(), MAX_CONIC_WEDGES); +} + +/// A conic whose extent can't be drawn (a vector without length, an aspect of 0, or numbers out +/// of range, as a file can hold them) sweeps from the right about its start, or the origin; the +/// parameter, the frame and the wedges stay finite. +#[test] +fn a_conic_with_no_extent_or_bad_numbers_still_sweeps() { + let c = Point::new(50.0, 50.0); + for g in [ + conic_at(c, 0.0, 1.0), + conic_at(c, 30.0, 0.0), + conic_at(c, 30.0, f64::NAN), + conic_at(c, 30.0, f64::INFINITY), + conic_at(c, f64::INFINITY, 1.0), + GradientGeom { end: Point::new(f64::NAN, 3.0), ..conic_at(c, 30.0, 1.0) }, + ] { + assert!(g.conic_frame().as_coeffs().iter().all(|v| v.is_finite()), "{g:?}"); + let t = |x: f64, y: f64| g.param_at(GradientKind::Conic, Point::new(x, y)); + assert!(t(90.0, 50.0).abs() < 1e-12, "{g:?}"); + assert!((t(50.0, 90.0) - 0.25).abs() < 1e-12, "{g:?}"); + let wedges = g.conic_wedges(Rect::new(0.0, 0.0, 100.0, 100.0), 4, false); + assert!(wedges.iter().flat_map(|(pts, _)| pts).all(|p| p.is_finite()), "{g:?}"); + } + // A flat extent keeps the vector's direction and the sign of its aspect. + let down = GradientGeom { end: Point::new(50.0, 80.0), ..conic_at(c, 30.0, 0.0) }; + assert!(down.param_at(GradientKind::Conic, Point::new(50.0, 90.0)).abs() < 1e-12); + assert!((down.param_at(GradientKind::Conic, Point::new(10.0, 50.0)) - 0.25).abs() < 1e-12); + for g in [conic_at(c, 0.0, -1.0), conic_at(c, 30.0, -0.0), conic_at(c, 30.0, f64::NEG_INFINITY)] { + assert!(g.is_mirrored(), "{g:?}"); + assert!((g.param_at(GradientKind::Conic, Point::new(50.0, 10.0)) - 0.25).abs() < 1e-12, "{g:?}"); + } + let lost = GradientGeom { start: Point::new(f64::NAN, 0.0), ..conic_at(c, 30.0, 1.0) }; + assert_eq!(lost.conic_frame(), Affine::IDENTITY); + for p in [Point::new(3.0, 4.0), Point::new(f64::NAN, 1.0), Point::new(f64::INFINITY, 0.0)] { + let t = lost.param_at(GradientKind::Conic, p); + assert!((0.0..=1.0).contains(&t), "{t}"); + } +} + +/// A conic's pixels (what SVG and PDF draw a dithered or Perceptual one as) follow the sweep, +/// with one stop as well as many. +#[test] +fn conic_pixels_follow_the_sweep() { + let b = Rect::new(0.0, 0.0, 4.0, 4.0); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Conic, Gradient::default().stops)); + g.geom = Some(conic_at(Point::new(2.0, 2.0), 1.0, 1.0)); + let px: Vec<[u8; 4]> = g.pixels(b, b, 4, 4, true, &Color::to_rgb).collect(); + // Below right of the center, early in the sweep: light. Above right, late: dark. + assert!(px[3 * 4 + 3][0] > 180 && px[3][0] < 75, "{px:?}"); + let one = GradientPaint { gradient: Gradient::new(GradientKind::Conic, vec![GradientStop::new(0.3, Color::rgb(1.0, 0.0, 0.0))]), ..g.clone() }; + assert!(one.pixels(b, b, 4, 4, true, &Color::to_rgb).all(|p| p == [255, 0, 0, 255])); + let many = (0..=1000).map(|i| GradientStop::new(i as f32 / 1000.0, Color::gray((i % 2) as f32))).collect(); + let many = GradientPaint { gradient: Gradient::new(GradientKind::Conic, many), ..g }; + assert_eq!(many.pixels(b, b, 4, 4, true, &Color::to_rgb).count(), 16); +} + +/// A diamond placed at `center` with its vector `dx` long to the right and aspect `aspect`. +fn diamond_at(center: Point, dx: f64, aspect: f64) -> GradientGeom { + GradientGeom { start: center, end: center + Vec2::new(dx, 0.0), aspect, focal: None } +} + +#[test] +fn a_diamond_runs_from_its_center_out_to_a_square_with_a_corner_at_the_end_of_its_vector() { + let g = diamond_at(Point::new(100.0, 100.0), 50.0, 1.0); + let t = |x: f64, y: f64| g.param_at(GradientKind::Diamond, Point::new(x, y)); + assert!(t(100.0, 100.0).abs() < 1e-12); + // The square's corners: at the end of the vector and a quarter turn on from it each way. + for (x, y) in [(150.0, 100.0), (100.0, 150.0), (50.0, 100.0), (100.0, 50.0)] { + assert!((t(x, y) - 1.0).abs() < 1e-12, "({x}, {y})"); + } + // Halfway along an edge, and halfway out along a diagonal (the middle of the edge's half size). + assert!((t(125.0, 125.0) - 1.0).abs() < 1e-12); + assert!((t(112.5, 87.5) - 0.5).abs() < 1e-12); + // Past the square: more than 1 (the last stop pads on). + assert!((t(200.0, 200.0) - 4.0).abs() < 1e-12); + // The aspect stretches the square across the vector into a rhombus. + let tall = diamond_at(Point::new(100.0, 100.0), 50.0, 2.0); + assert!((tall.param_at(GradientKind::Diamond, Point::new(100.0, 200.0)) - 1.0).abs() < 1e-12); + assert!((tall.param_at(GradientKind::Diamond, Point::new(150.0, 100.0)) - 1.0).abs() < 1e-12); + // Turned: the corner follows the vector. + let turned = GradientGeom { end: Point::new(130.0, 140.0), ..g }; + assert!((turned.param_at(GradientKind::Diamond, Point::new(130.0, 140.0)) - 1.0).abs() < 1e-12); + assert!((turned.param_at(GradientKind::Diamond, Point::new(115.0, 120.0)) - 0.5).abs() < 1e-12); +} + +#[test] +fn diamond_kind_names_and_fit() { + for s in ["diamond", "Diamond", "DIAMOND"] { + assert_eq!(GradientKind::parse(s), Some(GradientKind::Diamond), "{s}"); + } + assert_eq!(GradientKind::Diamond.label(), "Diamond"); + assert!(GradientKind::Diamond.is_centered()); + assert!(GradientKind::ALL.contains(&GradientKind::Diamond)); + // Level: the square's edges pass through the box's corners, so it covers the box exactly. + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + let fit = GradientGeom::fit(GradientKind::Diamond, b, 0.0); + assert_eq!(fit.start, Point::new(100.0, 50.0)); + assert!(fit.end.distance(Point::new(250.0, 50.0)) < 1e-9, "{fit:?}"); + for c in [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)] { + assert!((fit.param_at(GradientKind::Diamond, c) - 1.0).abs() < 1e-9, "{c:?}"); + } + // At any angle the square covers the box and runs through its farthest corner, on a square + // box too. + for b in [b, Rect::new(0.0, 0.0, 100.0, 100.0)] { + for angle in [30.0, 45.0, 90.0, -120.0] { + let fit = GradientGeom::fit(GradientKind::Diamond, b, angle); + let levels = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] + .map(|c| fit.param_at(GradientKind::Diamond, c)); + assert!(levels.iter().all(|&t| t <= 1.0 + 1e-9), "{angle}: {levels:?}"); + assert!(levels.iter().any(|&t| (t - 1.0).abs() < 1e-9), "{angle}: {levels:?}"); + } + } +} + +#[test] +fn a_diamond_follows_moves_turns_scales_and_mirrors() { + let c = Point::new(10.0, 20.0); + let g0 = diamond_at(c, 30.0, 0.5); + let probes = [c + Vec2::new(5.0, 7.0), c + Vec2::new(-9.0, 2.0), c + Vec2::new(3.0, -11.0), c + Vec2::new(-20.0, -4.0)]; + for a in [ + Affine::rotate(0.7), + Affine::scale(2.5), + Affine::translate((4.0, -9.0)) * Affine::rotate(-2.0), + Affine::FLIP_X, + Affine::FLIP_Y * Affine::rotate(1.1), + // Along and across the vector. + Affine::scale_non_uniform(2.0, 0.5), + Affine::scale_non_uniform(-3.0, 0.25), + ] { + let mut g = g0; + g.transform(a, GradientKind::Diamond); + assert!(g.start.distance(a * c) < 1e-9, "{a:?}"); + // A mirror changes nothing a diamond shows: its aspect stays a size. + assert!(g.aspect > 0.0, "{a:?}: {g:?}"); + for p in probes { + let (want, got) = (g0.param_at(GradientKind::Diamond, p), g.param_at(GradientKind::Diamond, a * p)); + assert!((want - got).abs() < 1e-9, "{a:?} at {p:?}: {want} vs {got}"); + } + } + // Under a shear the corner stays where the vector maps. + let shear = Affine::new([1.0, 0.0, 0.6, 1.0, 0.0, 0.0]); + let mut g = g0; + g.transform(shear, GradientKind::Diamond); + assert!((g.param_at(GradientKind::Diamond, shear * (c + Vec2::new(30.0, 0.0))) - 1.0).abs() < 1e-9, "{g:?}"); + // Out-of-range numbers: a finite aspect after any transform. + for bad in [diamond_at(c, 30.0, f64::NAN), diamond_at(c, f64::INFINITY, 1.0), diamond_at(c, 0.0, 1.0)] { + let mut g = bad; + g.transform(Affine::rotate(0.4), GradientKind::Diamond); + assert!(g.aspect.is_finite(), "{g:?}"); + } +} + +/// A diamond without a frame (no vector, a flat or a not-finite extent) has no inside: every +/// point is past its edge and takes the last stop. Points that aren't numbers do too. +#[test] +fn a_diamond_with_no_extent_or_bad_numbers_is_its_last_stop() { + let c = Point::new(50.0, 50.0); + for g in [ + diamond_at(c, 0.0, 1.0), + diamond_at(c, 30.0, 0.0), + diamond_at(c, 30.0, f64::NAN), + diamond_at(c, f64::INFINITY, 1.0), + GradientGeom { start: Point::new(f64::NAN, 0.0), ..diamond_at(c, 30.0, 1.0) }, + ] { + assert!(g.diamond_frame().is_none(), "{g:?}"); + assert!(g.diamond_quadrants(Rect::new(0.0, 0.0, 100.0, 100.0)).is_none(), "{g:?}"); + assert_eq!(g.param_at(GradientKind::Diamond, Point::new(60.0, 40.0)), 1.0, "{g:?}"); + } + let g = diamond_at(c, 30.0, 1.0); + assert_eq!(g.param_at(GradientKind::Diamond, Point::new(f64::NAN, 1.0)), 1.0); + assert!(g.param_at(GradientKind::Diamond, Point::new(f64::INFINITY, 1.0)) >= 1.0); +} + +/// The four linear gradients a format without diamond gradients gets, drawn in order, equal the +/// diamond everywhere, and each edge drawn lies on the quadrant drawn before it. +#[test] +fn diamond_quadrants_are_linear_gradients_that_equal_the_diamond() { + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + let probes: Vec = (0..=20).flat_map(|i| (0..=10).map(move |j| Point::new(i as f64 * 10.0 + 0.3, j as f64 * 10.0 + 0.7))).collect(); + // Squashed and turned, stretched twice as tall as wide, and stretched by a shear. + let mut turned = diamond_at(Point::new(60.0, 40.0), 30.0, 0.5); + turned.transform(Affine::rotate_about(0.4, Point::new(60.0, 40.0)), GradientKind::Diamond); + let mut sheared = diamond_at(Point::new(90.0, 50.0), 25.0, 1.0); + sheared.transform(Affine::new([2.0, 0.0, 0.7, 1.0, -60.0, 0.0]), GradientKind::Diamond); + for g in [turned, diamond_at(Point::new(100.0, 50.0), 20.0, 2.0), sheared] { + let quadrants = g.diamond_quadrants(b).expect("a frame"); + for p in &probes { + // The last quadrant drawn that holds the point paints it. + let Some((_, linear)) = quadrants.iter().rev().find(|(poly, _)| inside(poly, *p)) else { panic!("{p:?} is in no quadrant") }; + let (want, got) = (g.param_at(GradientKind::Diamond, *p), linear.param_at(GradientKind::Linear, *p)); + assert!((want - got).abs() < 1e-9, "{g:?} at {p:?}: {want} vs {got}"); + } + } + let g = turned; + let quadrants = g.diamond_quadrants(b).expect("a frame"); + // Next to each edge of a quadrant drawn later, on either side, the quadrant drawn before it + // lies underneath (the first quadrant's edges are covered by the second and the last). + let frame = g.diamond_frame().expect("a frame"); + for (unit, under) in [ + // The second quadrant's first edge, on the first. + (Point::new(0.01, 0.5), 0), + (Point::new(-0.01, 0.5), 0), + // The third's, on the second. + (Point::new(-0.5, 0.01), 1), + (Point::new(-0.5, -0.01), 1), + // The last's two, on the third and on the first. + (Point::new(-0.01, -0.5), 2), + (Point::new(0.01, -0.5), 2), + (Point::new(0.5, -0.01), 0), + (Point::new(0.5, 0.01), 0), + ] { + assert!(inside(&quadrants[under].0, frame * unit), "{unit:?} lies on quadrant {under}"); + } +} + +/// A diamond's pixels (what SVG and PDF draw a dithered or Perceptual one as, and what the canvas +/// draws) follow the squares. +#[test] +fn diamond_pixels_follow_the_squares() { + let b = Rect::new(0.0, 0.0, 5.0, 5.0); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, Gradient::default().stops)); + g.geom = Some(diamond_at(Point::new(2.5, 2.5), 2.5, 1.0)); + let px: Vec<[u8; 4]> = g.pixels(b, b, 5, 5, false, &Color::to_rgb).collect(); + // White at the center, black in the corners, and the same on each side of it. + assert!(px[2 * 5 + 2][0] > 200 && px[0][0] < 10 && px[24][0] < 10, "{px:?}"); + assert_eq!(px[2 * 5 + 1], px[2 * 5 + 3]); + assert_eq!(px[5 + 2], px[3 * 5 + 2]); + assert_eq!(px[2 * 5 + 1], px[5 + 2]); +} diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index b331d43e6a..800785f6f7 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -189,10 +189,39 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { let value = match &w.paint { Paint::Solid { color, .. } => css_color(color, 1.0), Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => { + // A mirrored conic turns counterclockwise, and CSS only turns clockwise: its stops + // are written mirrored about the seam instead. + let mirrored = g.gradient.kind == GradientKind::Conic && g.geom.is_some_and(|geom| geom.is_mirrored()); + let stop = |s: &crate::GradientStop| { + let at = if mirrored { 1.0 - s.offset } else { s.offset }; + format!("{} {}%", css_color(&s.color, s.opacity), at * 100.0) + }; let stops: Vec = - g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), s.offset * 100.0)).collect(); + if mirrored { g.gradient.stops.iter().rev().map(stop).collect() } else { g.gradient.stops.iter().map(stop).collect() }; match g.gradient.kind { GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")), + // CSS sweeps clockwise from straight up; the angle is counted counterclockwise + // from the right. + GradientKind::Conic => { + format!("conic-gradient(from {}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")) + } + // CSS has no diamond gradient: four layers, each a quarter of the box with + // the stops running from the center out to its corner at a diagonal angle, + // make the diamond its fit gives a box of any shape: the level at a point + // is its distance from the center along both axes over the box's + // half-width plus half-height + // (https://www.w3.org/TR/css-images-3/#linear-gradients). Turned by an angle + // that is not a multiple of 90 degrees, its corners leave the box's axes, + // and a circle stands in for it, as for a radial swatch. + GradientKind::Diamond if (1e-9..90.0 - 1e-9).contains(&g.angle.rem_euclid(90.0)) => { + format!("radial-gradient(circle, {})", stops.join(", ")) + } + GradientKind::Diamond => { + let stops = stops.join(", "); + [("top left", 315), ("top right", 45), ("bottom right", 135), ("bottom left", 225)] + .map(|(corner, deg)| format!("linear-gradient({deg}deg, {stops}) {corner} / 50% 50% no-repeat")) + .join(", ") + } _ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")), } } diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs index b0342d7a52..eaee50dd59 100644 --- a/crates/color/src/palette_io/tests.rs +++ b/crates/color/src/palette_io/tests.rs @@ -166,6 +166,75 @@ fn css_escapes_names_and_writes_gradients() { assert!(read(&text, "x").is_err(), "CSS is written only"); } +/// A conic gradient swatch is a CSS conic gradient starting where its angle points (CSS turns +/// clockwise from straight up); a mirrored one, which turns counterclockwise, has its stops mirrored +/// about the seam so each color lies on the same side. +#[test] +fn css_writes_conic_gradient_swatches() { + let one = |name: &str, g: GradientPaint| SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { name: name.into(), paint: Paint::Gradient(Box::new(g)), global: false, spot: false }], + groups: vec![], + }; + let mut conic = GradientPaint::new(Gradient { kind: GradientKind::Conic, ..Gradient::default() }); + conic.angle = 90.0; + let text = written(&one("Sweep", conic.clone()), PaletteFormat::Css); + assert!(text.contains("--sweep: conic-gradient(from 0deg, #ffffff 0%, #000000 100%);"), "{text}"); + conic.gradient.stops[0].offset = 0.2; + conic.geom = Some(crate::GradientGeom { start: kurbo::Point::ZERO, end: kurbo::Point::new(0.0, -10.0), aspect: -1.0, focal: None }); + let text = written(&one("Back", conic), PaletteFormat::Css); + assert!(text.contains("--back: conic-gradient(from 0deg, #000000 0%, #ffffff 80%);"), "{text}"); +} + +/// A diamond gradient swatch (CSS has none) is four layered linear gradients, one a quarter of the +/// box running from the center out to its corner, for the `background` shorthand. +#[test] +fn css_writes_diamond_gradient_swatches_as_four_corner_layers() { + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { + name: "Gem".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() }))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + // Diagonal angles, not `to `: on a box that isn't square, `to ` would turn + // each layer's lines to run through the quarter's other two corners. + let layer = |corner: &str, deg: u32| format!("linear-gradient({deg}deg, #ffffff 0%, #000000 100%) {corner} / 50% 50% no-repeat"); + let want = + format!("--gem: {}, {}, {}, {};", layer("top left", 315), layer("top right", 45), layer("bottom right", 135), layer("bottom left", 225)); + assert!(text.contains(&want), "{text}"); + // CSS's gradient line for a diagonal angle on a quarter box w × h is (w + h) / √2 long and + // starts at the quarter's corner at the box's center: the level at a point x, y from the + // center is (|x| + |y|) / (w + h), the level the diamond fitted to the whole box has there. + let (w, h) = (100.0f64, 50.0f64); + let length = (w + h) / std::f64::consts::SQRT_2; + let fit = crate::GradientGeom::fit(GradientKind::Diamond, kurbo::Rect::new(-w, -h, w, h), 0.0); + for (x, y) in [(0.0, -50.0), (-100.0, 0.0), (-30.0, -20.0), (-100.0, -50.0)] { + // The top-left quarter's layer runs toward (-1, -1). + let css = (-x - y) / std::f64::consts::SQRT_2 / length; + let model = fit.param_at(GradientKind::Diamond, kurbo::Point::new(x, y)); + assert!((css - model).abs() < 1e-9, "({x}, {y}): {css} vs {model}"); + } + // Turned a quarter or a half turn, it is the same diamond; turned 30 degrees, a circle stands + // in for it. + let turned = |angle: f64| { + let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() }); + gp.angle = angle; + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { name: "Gem".into(), paint: Paint::Gradient(Box::new(gp)), global: false, spot: false }], + groups: vec![], + }; + written(&lib, PaletteFormat::Css) + }; + assert!(turned(90.0).contains(&want) && turned(-180.0).contains(&want), "{}", turned(90.0)); + assert!(turned(30.0).contains("--gem: radial-gradient(circle, #ffffff 0%, #000000 100%);"), "{}", turned(30.0)); +} + #[test] fn css_preserves_fractional_gradient_stop_positions() { for kind in [GradientKind::Linear, GradientKind::Radial] { diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 2e19ad44a3..b1aa9727ba 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -444,13 +444,14 @@ impl Appearance { } } /// Map placed gradients through a warp, given its local affine approximation at a point (taken - /// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform - /// points each follow the warp at their own position. + /// at each gradient's center: the start of a radial, conic or diamond, the middle of a linear + /// vector). + /// Freeform points each follow the warp at their own position. pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) { for g in self.gradients_mut() { let freeform = g.freeform.take(); if let Some(geom) = g.geom { - let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) }; + let c = if g.gradient.kind.is_centered() { geom.start } else { geom.start.midpoint(geom.end) }; g.transform(near(c)); } g.freeform = freeform.map(|mut f| { diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 0887961730..407780177e 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -26,11 +26,16 @@ impl WidthProfile { pub fn at(&self, t: f64) -> (f64, f64) { Self::at_points(&self.points, t) } - /// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through - /// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point - /// without a kink, turns flat at a point wider or narrower than both neighbours, and never - /// goes past either point of the span it is in (#5714). Points on one straight line stay on - /// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it. + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + /// + /// Each side runs through its points on a cubic Hermite curve, its slope at each one the + /// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1 continuous + /// and monotone between points, so it never overshoots them, a point eases into its neighbours + /// instead of making a corner, and moving one reshapes the curve as far as its neighbours' + /// neighbours (their slopes depend on it). The ends of a run use the one-sided PCHIP slope + /// from its first two secants, or the secant when it has only two points, so a two-point + /// profile stays a straight taper and the lens is a parabola. Two points at one `t` are still + /// a step: each run either side ends independently at it. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { if p.is_empty() { return (1.0, 1.0); @@ -71,9 +76,10 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; - /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each - /// side stays between the widths of the two points of each span ([`Self::at_points`]), but the - /// two sides needn't peak together, so a span counts its wider left and its wider right. + /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. + /// Each side's smooth curve stays between the widths of the two points of each span + /// ([`Self::at_points`]), but the two sides needn't peak together, so a span counts its wider + /// left and its wider right. pub fn widest(&self) -> f64 { let p = &self.points; if p.is_empty() { @@ -249,11 +255,27 @@ impl AngleProfile { /// How close two width points' positions are to make a step ([`WidthProfile::around`]). const STEP: f64 = 1e-7; +/// The one-sided three-point PCHIP slope at the start of a run, from the first two secants. +/// A sign change caps the slope, and a slope that turns back on the first secant is flattened. +/// Runs of only two points use their secant instead (see [`tangent`]). +fn end_slope(h0: f64, h1: f64, d0: f64, d1: f64) -> f64 { + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if !m.is_finite() || m * d0 <= 0.0 { + return 0.0; + } + if d0 * d1 < 0.0 { + return if d0 > 0.0 { m.min(3.0 * d0) } else { m.max(3.0 * d0) }; + } + m +} + /// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in /// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where -/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes -/// of the spans on either side elsewhere (never more than three times either, so no span -/// overshoots its points), and the span's own slope at an end of the profile or beside a step. +/// the point is wider or narrower than both neighbours, and the weighted harmonic mean of the +/// slopes of the spans on either side elsewhere (never more than three times either, so no span +/// overshoots its points). The ends of a run take the one-sided three-point PCHIP slope, or their +/// secant when the run has only two points; a step ends the run on each side, so the points either +/// side of it do not see across it. fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 { let Some(q) = p.get(j) else { return 0.0 }; let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP); @@ -268,8 +290,18 @@ fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f let (h0, h1) = (q.0 - a.0, b.0 - q.0); 3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1) } - (Some(a), None) => slope(a, q), - (None, Some(b)) => slope(q, b), + // The end of a run: its previous two points set a one-sided three-point slope, or its + // secant when the run has only two points (the point before `a` is on the other side of a + // step, or does not exist). + (Some(a), None) => { + let Some(prev) = j.checked_sub(2).and_then(|k| p.get(k)).filter(|z| a.0 - z.0 > STEP) else { return slope(a, q) }; + end_slope(q.0 - a.0, a.0 - prev.0, slope(a, q), slope(prev, a)) + } + // The start of a run, symmetrically. + (None, Some(b)) => { + let Some(next) = p.get(j + 2).filter(|z| z.0 - b.0 > STEP) else { return slope(q, b) }; + end_slope(b.0 - q.0, next.0 - b.0, slope(q, b), slope(b, next)) + } (None, None) => 0.0, } } @@ -424,12 +456,12 @@ mod tests { #[test] fn widths_run_smoothly_through_width_points() { // A swell in the middle: flat at its widest, and a quarter of the way along above the - // straight ramp from 1 to 3 (2). + // straight ramp from 1 to 3 (2) because the end eases out of the first point. let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] }; let (before, after) = slopes(&swell, 0.5); assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}"); let (l, r) = swell.at(0.25); - assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}"); + assert!((l - 2.5).abs() < 1e-12 && (r - 1.75).abs() < 1e-12, "{l} {r}"); // Rising on both sides of a point: the slope is the same either side of it. let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] }; let (before, after) = slopes(&rise, 0.3); diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 0a1b3e1a5d..aa2ba0100c 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -197,12 +197,85 @@ fn strokes_without_pressure_round_trip_unchanged() { fn profile_interp() { let p = WidthProfile::lens(); assert_eq!(p.at(0.5), (1.0, 1.0)); - // Half way up the lens the width curves above the straight ramp (0.5), flattening into its - // widest point (#5714). - assert_eq!(p.at(0.25), (0.625, 0.625)); + // The lens is a parabola between its ends, so a quarter of the way along is 0.75: above the + // 0.5 a straight taper would give. + assert_eq!(p.at(0.25), (0.75, 0.75)); assert_eq!(p.at(2.0), (0.0, 0.0)); } +/// The width runs smoothly between points: it passes through them, has one slope either side +/// of one, and never overshoots the widths its points set. +#[test] +fn profile_is_smooth_between_points() { + let p = [(0.0, 1.0, 2.0), (0.5, 0.2, 2.0), (1.0, 1.0, 2.0)]; + assert!((WidthProfile::at_points(&p, 0.5).0 - 0.2).abs() < 1e-12); + let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6; + // A local minimum at the middle point is flat there, so the two sides agree. + assert!(slope(0.5).abs() < 1e-3, "{}", slope(0.5)); + for k in 0..=100 { + let w = WidthProfile::at_points(&p, k as f64 / 100.0).0; + assert!((0.2..=1.0).contains(&w), "{w} at {k}"); + } + // The side with no points of its own stays flat. + for k in 0..=100 { + assert!((WidthProfile::at_points(&p, k as f64 / 100.0).1 - 2.0).abs() < 1e-12); + } +} + +/// A run with only two points takes its secant as both end slopes, so it stays a straight +/// taper on each side instead of becoming a smoothstep. +#[test] +fn profile_two_point_runs_are_linear() { + let p = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)]; + for k in 0..=100 { + let t = k as f64 / 100.0; + let (l, r) = WidthProfile::at_points(&p, t); + assert!((l - t).abs() < 1e-12, "left {l} at {t}"); + assert!((r - (1.0 - t)).abs() < 1e-12, "right {r} at {t}"); + } +} + +/// The lens's one-sided end slopes make it a parabola: at a quarter of the way it is 0.75, +/// above the 0.5 a straight line between its points would give. +#[test] +fn profile_lens_is_convex() { + let p = WidthProfile::lens(); + assert!((p.at(0.25).0 - 0.75).abs() < 1e-12); + assert!((p.at(0.75).0 - 0.75).abs() < 1e-12); + for k in 1..25 { + let t = k as f64 / 100.0; + assert!(p.at(t).0 > 2.0 * t, "{} at {t}", p.at(t).0); + assert!(p.at(1.0 - t).0 > 2.0 * t, "{} at {}", p.at(1.0 - t).0, 1.0 - t); + } +} + +/// A step still steps, and the smooth runs either side meet it flat. +#[test] +fn profile_steps_stay_steps() { + let p = [(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)]; + assert_eq!(WidthProfile::at_points(&p, 0.5), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 + 1e-6), (2.0, 2.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 - 1e-6), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.25), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.75), (2.0, 2.0)); +} + +/// Moving a point only reshapes the segments beside it (local influence), not the whole path. +#[test] +fn profile_points_only_affect_the_curve_around_them() { + // A monotone run, so no neighbouring slope is held flat by a local extremum. + let a = [(0.0, 0.0, 0.0), (0.25, 0.3, 0.3), (0.5, 0.5, 0.5), (0.75, 0.7, 0.7), (1.0, 1.0, 1.0)]; + let mut b = a; + b[3] = (0.75, 0.9, 0.9); + // Moving p[3] changes the slopes at p[2] and p[3], so p[1] and p[4] also shape the + // curve: nothing before p[1] may move. + for k in 0..=24 { + let t = k as f64 / 100.0; + assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}"); + } + assert_ne!(WidthProfile::at_points(&a, 0.3), WidthProfile::at_points(&b, 0.3)); +} + #[test] fn profile_presets_round_trip_their_ids() { for p in WidthProfile::PRESETS { diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index e4f267a8b6..491b79f158 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -15,7 +15,8 @@ pub struct BeyondCanvas { /// Objects removed whole (a layer or group keeps its children within the canvas). pub objects: usize, pub artboards: usize, - /// Guides beyond the canvas, and the artboard guides of the artboards removed. + /// Guides beyond the canvas, the artboard guides of the artboards removed, and the layer + /// guides of the layers removed. pub guides: usize, /// The ruler origin was beyond the canvas and is back at the origin. pub ruler_origin: bool, @@ -100,11 +101,20 @@ fn layers_in(n: &Node, layers: &mut Vec) { } } +/// How many ruler guides the layers in `n` hold, if it is a layer or group. +fn guides_in(n: &Node) -> usize { + match clipping(n) { + None => 0, + Some(_) => n.children().into_iter().flatten().map(|c| if c.is_ruler_guide() { 1 } else { guides_in(c) }).sum(), + } +} + /// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its /// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything /// else goes whole. Only containers holding such an object are copied. Recursion follows the -/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`. -fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec) { +/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and +/// the ruler guides they held to `guides`. +fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec, guides: &mut usize) { if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) { return; } @@ -114,10 +124,11 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut return true; } if clipping(c).is_some() && !clips_beyond(c) { - drop_children_beyond(c, dropped, layers); + drop_children_beyond(c, dropped, layers, guides); return true; } layers_in(c, layers); + *guides += guides_in(c); *dropped += 1; false }); @@ -159,13 +170,14 @@ impl Document { for c in children.iter() { layers_in(c, &mut layers); } - // The layer's own ruler guides stay; only its art goes. + // The layer's own ruler guides stay; only its art goes, with its sublayers' guides. gone.objects += children.iter().filter(|c| !c.is_ruler_guide()).count(); + gone.guides += children.iter().filter(|c| !c.is_ruler_guide()).map(|c| guides_in(c)).sum::(); children.retain(|c| c.is_ruler_guide()); *clip = false; } } else { - drop_children_beyond(layer, &mut gone.objects, &mut layers); + drop_children_beyond(layer, &mut gone.objects, &mut layers, &mut gone.guides); } } if gone.objects > 0 { @@ -184,7 +196,7 @@ impl Document { let guides = self.saved_guides.len(); self.saved_guides .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); - gone.guides = guides - self.saved_guides.len(); + gone.guides += guides - self.saved_guides.len(); let out = |n: &Node| match n.kind { NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), _ => false, @@ -324,6 +336,21 @@ mod tests { let layer = d2.layers[0].id.0; assert_eq!(d2.drop_beyond_canvas().objects, 1); assert_eq!(ids(&d2), [layer, 10]); + // The guides of a sublayer emptied out of a top-level layer go with it, counted. + let probe = Document::new(1.0, 1.0); + let sub = sublayer(&probe, 40, vec![rect(41, 0.0)], false); + let mut d3 = doc(vec![rect(10, 1e17), sub]); + if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d3.layers[0]).kind { + *clip = true; + } + d3.saved_guides = + vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d3.layers[0].id), ..Guide::new(true, 5.0) }]; + let mut nodes = d3.clone(); + assert_eq!(nodes.guides_into_nodes(), 2); + let gone = d3.drop_beyond_canvas(); + assert_eq!((gone.objects, gone.guides), (2, 1), "the clipping path, the sublayer and its guide; the layer's own guide stays"); + assert_eq!(nodes.drop_beyond_canvas(), gone); + assert_eq!(nodes.ruler_guides().len(), 1); assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default()); } @@ -353,8 +380,13 @@ mod tests { ]; d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); + // The same guides as nodes, as a document has them once read. + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 2); let gone = d.drop_beyond_canvas(); assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide"); + assert_eq!(nodes.drop_beyond_canvas(), gone, "a guide node goes with its sublayer, counted as a saved one is"); + assert_eq!(nodes.ruler_guides().len(), 1); assert_eq!(d.saved_guides.len(), 1, "the guide of a layer that stays, stays"); assert_eq!(d.saved_selections[0].objects, [NodeId(10)]); assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 94fbb5ff52..6eae9c1488 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -795,6 +795,10 @@ thread_local! { static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// What a click at `p` hits on leaf object `n`: its outline, strokes and fills, its frame or its +/// bounds, by kind. Raster effects (drop shadows, glows, blurs) do not take clicks: the area they +/// paint beyond the object is left out here and in [`cull_reach`], so a click there reaches the +/// object under it. fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { #[cfg(test)] LEAVES_TESTED.with(|c| c.set(c.get() + 1)); diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 8f9b197048..8abe961841 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -78,7 +78,9 @@ pub struct EnvelopeOptions { /// Rasters: Preserve Shape Using. pub preserve_shape: PreserveShape, /// Distort Appearance: strokes and live effects are applied before the distortion, so they - /// bend with the art (off: they apply to the distorted paths). + /// bend with the art (off: they apply to the distorted paths). Type's strokes are the exception: + /// they paint along its distorted glyph outlines at their own weight either way, as `.ai` files + /// draw them (#6644). pub distort_appearance: bool, /// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art. pub distort_linear_gradients: bool, @@ -638,6 +640,7 @@ pub(super) fn expand_envelope( hooks, options, depth: 0, + type_outlines: false, }; content.iter().map(|c| map_node(c, &w)).collect() } diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 1cc8ba0863..8675dcc011 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -21,6 +21,11 @@ pub struct Hooks<'a> { /// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None` /// when it has none to bake). pub appearance: Outliner<'a>, + /// Distort Appearance for the pieces of type's glyph outlines: their geometry effects baked + /// as [`Hooks::appearance`] bakes them, their strokes left as strokes, which then paint along + /// the bent outlines at their own weight (#6644). `None` when a piece has nothing to bake, and + /// without this hook nothing in type's outlines is baked. + pub type_appearance: Outliner<'a>, /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). pub pattern: Option>, /// The document whose symbols' art counts toward the instance budget of a Repeat of their @@ -61,6 +66,12 @@ pub(super) struct Warper<'a> { pub(super) options: EnvelopeOptions, /// How many hooks the art being mapped came through. pub(super) depth: u32, + /// Are groups marked as type's glyph outlines ([`crate::NodeExtras::type_outlines`]) in the art + /// being mapped type that live evaluation outlined: the type of the envelope's contents, or of + /// a live object in them, such as an envelope inside the envelope? Their strokes then paint + /// along the bent outlines at their own weight (#6644). Groups so marked in the contents as + /// they are, art an earlier Expand made of type, are plain art. + pub(super) type_outlines: bool, } impl Warper<'_> { @@ -117,6 +128,13 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { *live = None; } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + // Type's glyph outlines: with Distort Appearance too, their strokes paint along the + // bent outlines at their own weight, as `.ai` files draw them (#6644). Their pieces are + // baked by `type_appearance`, which keeps strokes as strokes; their geometry effects, + // gradients and pattern fills follow the options as a path's do. The group's opacity + // mask, mapped above, bends as other art does. + let typed = Warper { hooks: Hooks { appearance: w.hooks.type_appearance, ..w.hooks }, ..*w }; + let w = if w.type_outlines && n.extras().type_outlines { &typed } else { w }; for c in children.iter_mut() { *c = Arc::new(map_node(c, w)); } @@ -124,14 +142,17 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { NodeKind::Mesh(m) => m.map(w.f), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { if let Some(d) = &deeper { - return map_node(&expanded_group_hooks(n, w.hooks), d); + // The type's outlines in its art are type (an envelope inside the envelope, #6644). + return map_node(&expanded_group_hooks(n, w.hooks), &Warper { type_outlines: true, ..*d }); } } NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => { if let Some(d) = &deeper && let Some(o) = w.hooks.outline.and_then(|h| h(n)) { - return map_node(&carry_look(n, o), d); + // Type's outlines are type (#6644, the group arm above). + let d = Warper { type_outlines: d.type_outlines || matches!(n.kind, NodeKind::Text(_)), ..*d }; + return map_node(&carry_look(n, o), &d); } } NodeKind::Image(im) => { @@ -439,8 +460,11 @@ pub fn subtree_has_live(n: &Node) -> bool { any } -/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the -/// outsets in the art of the symbols its instances show count too +/// Largest stroke outset in a subtree (for visual bounds of envelopes, blends and repeats, and the +/// reach of symbol art), type's character strokes included as type's own visual bounds measure them +/// ([`crate::TextObject::char_stroke_outset`]): in an envelope they paint at their own weight +/// around the bent glyphs, past its grid where the glyphs reach its edge (#6644). With +/// `symbols`, the outsets in the art of the symbols its instances show count too /// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made /// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad /// does. @@ -449,6 +473,9 @@ pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxe for n in nodes { n.walk(&mut |c| { o = o.max(c.appearance.outset()); + if let NodeKind::Text(t) = &c.kind { + o = o.max(t.char_stroke_outset()); + } if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind) && let Some(art) = s.art(symbol) { diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index e2d27650b5..12d233ba37 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -46,7 +46,7 @@ impl Document { return (0, 0); } let own = size_of::() + ALLOC + ARC; - let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] }; + let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own], appearances: Default::default() }; w.list(&self.layers, &other.layers); for i in 0..self.symbols.len().max(other.symbols.len()) { match (self.symbols.get(i), other.symbols.get(i)) { @@ -134,6 +134,9 @@ struct Walk<'a> { seen: [Option>; 2], /// For each side, what it keeps that the other doesn't. bytes: [usize; 2], + /// For each side, the appearances counted so far (by address): objects that share one (a + /// graphic style applied to them all) count it once. + appearances: [HashSet; 2], } impl Walk<'_> { @@ -174,8 +177,9 @@ impl Walk<'_> { self.alone(1, b); return; } - self.bytes[0] += node_bytes(a, Some(b)); - self.bytes[1] += node_bytes(b, Some(a)); + let [ca, cb] = &mut self.appearances; + self.bytes[0] += node_bytes(a, Some(b), ca); + self.bytes[1] += node_bytes(b, Some(a), cb); match (a.children(), b.children()) { (Some(x), Some(y)) => self.list(x, y), (x, y) => { @@ -198,8 +202,8 @@ impl Walk<'_> { if self.other_holds(side, n) { return; } - if let Some(b) = self.bytes.get_mut(side) { - *b += node_bytes(n, None); + if let (Some(b), Some(counted)) = (self.bytes.get_mut(side), self.appearances.get_mut(side)) { + *b += node_bytes(n, None, counted); } for c in n.children().into_iter().flatten() { self.alone(side, c); @@ -242,14 +246,15 @@ impl Walk<'_> { } /// One node's own heap: the node, and what it owns besides its children and mask art (counted -/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it. -fn node_bytes(n: &Node, other: Option<&Node>) -> usize { +/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it, or +/// another node of the same side shares it and was counted already (its address is in `counted`). +fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet) -> usize { fn boxed(b: &Option>) -> usize { b.as_ref().map_or(0, |_| ALLOC + size_of::()) } let mut b = ALLOC + ARC + size_of::(); b += n.name.as_ref().map_or(0, String::capacity); - if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { + if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) && counted.insert(&*n.appearance as *const Appearance as usize) { b += ALLOC + ARC + appearance_heap_bytes(&n.appearance); } if let Some(e) = n.extras_box() { @@ -447,6 +452,29 @@ mod tests { assert!(added > kept + 3 * 300, "{added} for three new rectangles"); } + /// Objects given one shared appearance (a graphic style applied to them all) count it once. + #[test] + fn a_shared_appearance_counts_once() { + let n = 100; + let before = doc_with_rects(n); + let ids: Vec<_> = before.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + let ap = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 2.0); + let restyled = |each: bool| { + let mut d = before.clone(); + let one = Shared::new(ap.clone()); + for id in &ids { + d.node_mut(*id).unwrap().appearance = if each { Shared::new(ap.clone()) } else { one.clone() }; + } + d + }; + let (shared, copies) = (restyled(false), restyled(true)); + let (kept, added) = before.retained_bytes_each(&shared); + assert_eq!(shared.retained_bytes_each(&before), (added, kept)); + let (kept_copies, added_copies) = before.retained_bytes_each(&copies); + assert_eq!(kept, kept_copies, "the old version keeps the same either way"); + assert_eq!(added_copies - added, (n - 1) * (ALLOC + ARC + appearance_heap_bytes(&ap))); + } + #[test] fn a_resource_counts_only_when_changed() { let before = doc_with_rects(1); diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index f6b9a4d013..8279507e01 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -80,10 +80,8 @@ fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> { /// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the /// segment is a corner. The stroke draws the sides there straight to the corner's join and inner /// meeting point, which can lie outside the exact width all along the segment. -fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) { +fn profile_at(profile: &WidthProfile, segs: &[PathSeg], lens: &[f64], total: f64, closed: bool, i: usize, t: f64) -> (f64, f64) { let n = segs.len(); - let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); - let total: f64 = lens.iter().sum::().max(1e-12); let before = lens[..i].iter().sum::(); let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total); let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]); @@ -306,7 +304,8 @@ impl StrokeLayer { /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and /// outside strokes of closed paths count on their own side only (and within `tol` of the - /// path); a width profile counts at its width where `p` is nearest. + /// path); a width profile counts at the width of every segment that can reach `p`, as the + /// wider side of a farther one can paint past the nearest. pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { let reach = self.reach() + tol; if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) { @@ -330,17 +329,27 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); - // The nearest point on this subpath. - let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)); - if let Some((i, nr)) = near - && nr.distance_sq.sqrt() <= full + tol - { + // Every segment near enough to reach `p`: with a width profile the wider side of a + // farther segment can paint past the nearest one, so the nearest alone is not enough. + let near = segs.iter().enumerate().filter_map(|(i, s)| { + let nr = s.nearest(p, 1e-9); + (nr.distance_sq.sqrt() <= full + tol).then_some((i, nr)) + }); + // The lengths are only needed when a profiled candidate reaches `p`; compute them + // once for the whole subpath rather than once per candidate. + let mut lengths: Option<(Vec, f64)> = None; + for (i, nr) in near { let d = nr.distance_sq.sqrt(); let factor = if dash_caps.is_some() { full / half.max(1e-12) } else { self.profile.as_ref().map_or(1.0, |pr| { - let (l, rt) = profile_at(pr, &segs, closed, i, nr.t); + let (lens, total) = lengths.get_or_insert_with(|| { + let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); + let total = lens.iter().sum::().max(1e-12); + (lens, total) + }); + let (l, rt) = profile_at(pr, &segs, lens, *total, closed, i, nr.t); // Nearest to a segment's end, `p` is beside a join or past a cap, which // spans the widths of both sides. let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9; diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 781bec84ea..bc558d2702 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -314,6 +314,13 @@ pub struct DocSetup { /// #[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")] pub system_layout: bool, + /// Align New Objects to Pixel Grid (New Document › Advanced Options, Transform panel menu): + /// paths and compound paths drawn from then on are pixel-aligned. Objects already in the + /// document, and objects pasted, duplicated or placed, keep their own setting. Off by default + /// and for documents from before it, and saved only when on. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSetup { @@ -337,6 +344,7 @@ impl Default for DocSetup { export_text: ExportText::Editable, background: Background::Transparent, system_layout: false, + align_new_objects_to_pixel_grid: false, } } } diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb4300..97d46b7cda 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -155,6 +155,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "shadows-glows", name: "Shadows and Glows", make: shadows_glows }, BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules }, BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn }, + BuiltinStyleLibrary { id: "textures", name: "Textures", make: textures }, BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes }, BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, @@ -322,6 +323,44 @@ fn hand_drawn() -> Vec { ] } +/// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and +/// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper). +fn textures() -> Vec { + let surface = |texture: &str, scaling: f64, relief: f64, light: &str| { + fx("texture.texturizer", json!({"texture": texture, "scaling": scaling, "relief": relief, "lightDirection": light, "invert": false})) + }; + vec![ + style( + "Fine Grain", + vec![F(fill("#c9ada7")), S(stroke("#4a4e69", 1.0))], + vec![fx("texture.grain", json!({"intensity": 35.0, "contrast": 55.0, "grainType": "regular"}))], + ), + style("Canvas Weave", vec![F(fill("#e9d8a6"))], vec![surface("canvas", 100.0, 6.0, "topLeft")]), + style("Burlap Sack", vec![F(fill("#bc8a5f")), S(stroke("#6f4518", 1.5))], vec![surface("burlap", 120.0, 8.0, "top")]), + style( + "Cracked Glaze", + vec![F(fill("#94d2bd")), S(stroke("#005f73", 1.0))], + vec![fx("texture.craquelure", json!({"crackSpacing": 12.0, "crackDepth": 5.0, "crackBrightness": 8.0}))], + ), + style( + "Mosaic Floor", + vec![F(fill("#0a9396"))], + vec![fx("texture.mosaicTiles", json!({"tileSize": 10.0, "groutWidth": 3.0, "lightenGrout": 8.0}))], + ), + style("Quilted Squares", vec![F(fill("#ee9b00"))], vec![fx("texture.patchwork", json!({"squareSize": 5.0, "relief": 10.0}))]), + style( + "Leaded Glass", + vec![F(FillLayer::new(linear(-45.0, &[(0.0, "#ae2012", 1.0), (0.5, "#ee9b00", 1.0), (1.0, "#005f73", 1.0)])))], + vec![fx("texture.stainedGlass", json!({"cellSize": 8.0, "borderThickness": 4.0, "lightIntensity": 3.0}))], + ), + style( + "Damp Paper", + vec![F(fill("#4895ef")), S(stroke("#3f37c9", 1.0))], + vec![fx("sketch.waterPaper", json!({"fiberLength": 12.0, "brightness": 55.0, "contrast": 60.0}))], + ), + ] +} + fn gradient_finishes() -> Vec { let rim = |h: &str| S(stroke(h, 1.0)); vec![ @@ -550,6 +589,18 @@ mod tests { assert_eq!(status.name, "Status Effects"); let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect(); assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let textures = builtin_style_library("textures").unwrap(); + assert_eq!(textures.name, "Textures"); + let texture_names: Vec<&str> = textures.styles.iter().map(|g| g.name.as_str()).collect(); + let expected = + ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; + assert_eq!(texture_names, expected); + for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "textures") { + let lib = builtin_style_library(b.id).unwrap(); + assert!(lib.styles.iter().all(|g| !expected.contains(&g.name.as_str())), "{} shares a name with Textures", b.id); + } + let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn"); } #[test] diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0be38d261e..05f9c29041 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -77,10 +77,13 @@ pub struct TextObject { /// them up round the inside. #[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")] pub path_spacing: f64, - /// Type › Dynamic Text (type on a path only): the layout scales the text by one factor so it - /// fills the span between its start and end brackets, and fits it again after each edit. The - /// stored sizes keep their proportions; the layout's `fit_scale` is the factor drawn. Off for new - /// type and for documents from before it, and saved only when on. + /// Type › Dynamic Text. On type on a path, the layout scales the text by one factor so it + /// fills the span between its start and end brackets, and fits it again after each edit; the + /// stored sizes keep their proportions, and the layout's `fit_scale` is the factor drawn. On + /// area type it is Block: after each edit the engine sets each line as typed to the frame's + /// width by its font size and writes the sizes into the characters, with the gaps of + /// [`AreaOptions::word_gap`] and [`AreaOptions::line_gap`]. Point type does not use it. Off for + /// new type and for documents from before it, and saved only when on. #[serde(default, rename = "dynamicText", skip_serializing_if = "crate::skip::is_default")] pub dynamic_text: bool, /// Wrap objects above this area type, resolved by the engine after each edit (text space). diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 7bb742f483..848add8f53 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -167,6 +167,15 @@ pub struct AreaOptions { /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). #[serde(skip_serializing_if = "crate::skip::is_default")] pub fit: AreaFit, + /// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): the Desired word + /// spacing the layout sets every paragraph with ([`super::TextObject::layout_justification`]), in + /// percent of a word space (100, the default, is left out when saved). + #[serde(skip_serializing_if = "AreaOptions::is_default_word_gap")] + pub word_gap: f64, + /// Dynamic Text's Block: the points added to each character's size to give its leading (0, the + /// default, is left out when saved). + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub line_gap: f64, } impl Default for AreaOptions { @@ -182,6 +191,8 @@ impl Default for AreaOptions { vertical_align: VerticalAlign::Top, flow: TextFlow::Columns, fit: AreaFit::None, + word_gap: Self::DEFAULT_WORD_GAP, + line_gap: 0.0, } } } @@ -189,6 +200,21 @@ impl Default for AreaOptions { impl AreaOptions { /// The most rows, and the most columns, area type can have. pub const MAX_ROWS_COLUMNS: usize = 100; + /// Block's word gap range (percent of a word space), the Justification word spacing range. + pub const WORD_GAP: (f64, f64) = (0.0, 1000.0); + /// Block's word gap new type starts with: the font's own word space. + pub const DEFAULT_WORD_GAP: f64 = 100.0; + /// Block's line gap range in points. + pub const LINE_GAP: (f64, f64) = (-1296.0, 1296.0); + fn is_default_word_gap(v: &f64) -> bool { + *v == Self::DEFAULT_WORD_GAP + } + /// Block's gaps kept within their ranges; a gap that is not a number gives its default. + pub fn block_gaps(&self) -> (f64, f64) { + let word = if self.word_gap.is_finite() { self.word_gap.clamp(Self::WORD_GAP.0, Self::WORD_GAP.1) } else { Self::DEFAULT_WORD_GAP }; + let line = if self.line_gap.is_finite() { self.line_gap.clamp(Self::LINE_GAP.0, Self::LINE_GAP.1) } else { 0.0 }; + (word, line) + } } /// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 1092b871f2..d46b45b1f7 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -1,6 +1,6 @@ use super::area::{AreaOptions, PathAlign, PathEffect}; use super::char_style::CharStyle; -use super::para_style::ParaStyle; +use super::para_style::{JustificationOptions, ParaStyle}; use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end}; use super::tabs_lists::{ListCounts, ParaList}; use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; @@ -170,6 +170,19 @@ impl TextObject { pub fn para_at(&self, i: usize) -> &ParaStyle { self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) } + /// The Justification… options the layout sets `para` with: its own, except that with Dynamic + /// Text's Block on area type ([`Self::dynamic_text`]) the Desired word spacing is the word gap + /// ([`AreaOptions::block_gaps`]), and Minimum and Maximum move out to it when it lies outside + /// them. The paragraph keeps its own values, which apply again when Block is off. + pub fn layout_justification(&self, para: &ParaStyle) -> JustificationOptions { + let mut j = para.justification.clone(); + if self.dynamic_text && matches!(self.kind, TextKind::Area { .. }) { + let gap = self.area.block_gaps().0; + let [min, _, max] = j.word_spacing; + j.word_spacing = [min.min(gap), gap, max.max(gap)]; + } + j + } /// Every paragraph's attributes, one per paragraph. pub fn paragraph_styles(&self) -> Vec { (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 5d27ac60af..795fc69bdf 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -552,6 +552,21 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to /// outline nor a geometry effect, so bending first changes nothing. pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { + bake_appearance_as(doc, n, true) +} + +/// [`bake_appearance`] for the glyph outlines of type in an envelope with Distort Appearance: +/// geometry effects are applied as it applies them, and fills, gradients and patterns are left as +/// it leaves them, but every stroke stays a stroke on its piece instead of becoming its filled +/// outline. The envelope then bends the piece's path, and the stroke paints along the bent outline +/// at its own weight, as `.ai` files draw the strokes of type in an envelope (#6644). `None` when +/// it has no geometry effect, so bending first changes nothing. +pub fn bake_type_appearance(doc: Option<&Document>, n: &Node) -> Option { + bake_appearance_as(doc, n, false) +} + +/// [`bake_appearance`], or with `outline_strokes` false [`bake_type_appearance`]. +fn bake_appearance_as(doc: Option<&Document>, n: &Node, outline_strokes: bool) -> Option { if let Some(m) = crate::revolve_art_in(doc, n).or_else(|| crate::solid_art_in(doc, n)) { return Some(m); } @@ -564,10 +579,10 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { if crate::has_transform_art(n) && let Some(mut m) = reshape(doc, n, None) { - bake_appearance_pieces(doc, &mut m); + bake_appearance_pieces(doc, &mut m, outline_strokes); return Some(m); } - let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); + let outlined = |i: &AppearanceItem| outline_strokes && paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))); if !geometric && !n.appearance.items.iter().any(outlined) { return None; @@ -585,7 +600,7 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); pieces.push(match &it { - AppearanceItem::Stroke(st) if st.brush.is_none() => { + AppearanceItem::Stroke(st) if outline_strokes && st.brush.is_none() => { let outline = crate::stroke::outline_region(&ig, rule, st); match st.path_gradient() { // A gradient along or across the stroke: its gradient meshes clipped to the outline. @@ -618,16 +633,16 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { Some(m) } -/// [`bake_appearance`] every path and compound path in `n` that it changes, in place. -fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) { +/// [`bake_appearance_as`] every path and compound path in `n` that it changes, in place. +fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: bool) { if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - if let Some(m) = bake_appearance(doc, n) { + if let Some(m) = bake_appearance_as(doc, n, outline_strokes) { *n = m; } return; } for c in n.children_mut().into_iter().flatten() { - bake_appearance_pieces(doc, Arc::make_mut(c)); + bake_appearance_pieces(doc, Arc::make_mut(c), outline_strokes); } } diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 4751b137c9..2bcc026340 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -575,6 +575,27 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline assert_eq!(ch[1].opacity, 0.5); } +/// Distort Appearance on the pieces of type's glyph outlines (#6644): a stroke's geometry effect +/// still bakes into its piece, but the stroke stays a stroke at its own weight; a piece with no +/// geometry effect is left as it is, its stroke on it (Distort Appearance on a path outlines it). +#[test] +fn type_pieces_bake_their_geometry_effects_and_keep_their_strokes() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + assert!(bake_type_appearance(None, &n).is_none(), "nothing to bake"); + assert!(bake_appearance(None, &n).is_some(), "a path's stroke is outlined"); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.width = 4.0; + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let m = bake_type_appearance(None, &n).unwrap(); + let ch = m.children().unwrap(); + assert_eq!(ch.len(), 2); + let Some(AppearanceItem::Stroke(st)) = ch[1].appearance.items.first() else { panic!("still a stroke: {:?}", ch[1].appearance) }; + assert!(st.width == 4.0 && st.effects.is_empty(), "{st:?}"); + assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5, "the stroke's offset is baked into its path"); + assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6, "the fill keeps the square"); +} + /// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform /// effect `params`. fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node { diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index e9bbaf4bbf..5c18f2d5f0 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -176,29 +176,29 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true}), ), g( EXTRUDE, "Extrude & Bevel…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), ), g( INFLATE, "Inflate…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).", - json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Painted with the top fill (else the top stroke).", + json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), ), g( ROTATE, "Rotate…", &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), ), g( CLASSIC_EXTRUDE, diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 99e9d9e6fe..57f2fc8613 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -719,6 +719,37 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], Some(g) } +/// Whether a sublayer among the members of `n`, or among its groups' members, holds a ruler guide. +fn sublayer_guides(n: &Node) -> bool { + n.children().is_some_and(|ch| ch.iter().any(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c)))) +} + +/// Move the ruler guides of the sublayers among the members of `n`, and among its groups' +/// members, to `out`, in order. A guide taken from a hidden or locked sublayer, or from one inside +/// such a sublayer or group, is hidden or locked itself. Only containers holding one are copied. +fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, hidden: bool, locked: bool) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut().filter(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))) { + let c = Arc::make_mut(c); + let (hidden, locked) = (hidden || !c.visible, locked || c.locked); + if c.is_layer() + && let Some(lc) = c.children_mut() + { + let (taken, art): (Vec<_>, Vec<_>) = std::mem::take(lc).into_iter().partition(|g| g.is_ruler_guide()); + *lc = art; + out.extend(taken.into_iter().map(|mut g| { + if hidden || locked { + let g = Arc::make_mut(&mut g); + g.visible &= !hidden; + g.locked |= locked; + } + g + })); + } + take_sublayer_guides(c, out, hidden, locked); + } +} + /// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects /// reshaping all its members as one piece (the whole art's bounds as their reference box), and its /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below @@ -739,7 +770,12 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } let mut m = n.clone(); // A layer's ruler guides take no part: they go back above the evaluated art at the end. - let guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); + let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); + // So do its sublayers' guides, which would otherwise go with the art the effects replace or + // copy: they stay on this layer, hidden and locked as their sublayers were. + if m.is_layer() && (pathfinder || !fx.is_empty()) { + take_sublayer_guides(&mut m, &mut guides, false, false); + } if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default(); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index bf702b2732..16e893ce99 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -862,3 +862,70 @@ fn a_layers_ruler_guides_stay_last_through_its_effects() { painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides); } + +/// The ruler guides of a sublayer in a layer with a Pathfinder effect, or a Transform effect that +/// copies its art, stay on a layer of the evaluated art, once, hidden and locked as the sublayer is. +#[test] +fn a_sublayers_ruler_guides_survive_its_layers_effects() { + use vectorcraft_doc::LayerColor; + let layer = |effect: &str, params: serde_json::Value, shown: bool| { + let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1)); + (sub.visible, sub.locked) = (shown, !shown); + if let Some(ch) = sub.children_mut() { + *ch = vec![ + sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), + Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })), + ]; + } + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(sub)]; + } + l.appearance.effects.push(Effect { id: effect.into(), params, visible: true }); + l + }; + // The guides found where ruler guides are looked for: directly in a layer or sublayer. + fn listed(n: &Node, out: &mut Vec<(NodeId, bool, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_ruler_guide() { + out.push((c.id, c.visible, c.locked)); + } else if c.is_layer() { + listed(c, out); + } + } + } + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for shown in [true, false] { + let art = evaluate_container(None, &layer(effect, params.clone(), shown)).unwrap(); + let mut found = vec![]; + listed(&art, &mut found); + assert_eq!(found, [(NodeId(20), shown, !shown)], "{effect}: the sublayer's guide, once, on a layer"); + } + } +} + +/// A sublayer's ruler guide inside a hidden or locked group stays hidden or locked when its layer's +/// Pathfinder effect moves it onto the layer, as the group made it. +#[test] +fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() { + use vectorcraft_doc::LayerColor; + for (hidden, locked) in [(true, false), (false, true), (false, false)] { + let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1)); + if let Some(ch) = sub.children_mut() { + *ch = vec![ + sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), + Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })), + ]; + } + let mut group = Node::group(NodeId(12), vec![Arc::new(sub)]); + (group.visible, group.locked) = (!hidden, locked); + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(group)]; + } + l.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); + let art = evaluate_container(None, &l).unwrap(); + let guides: Vec<_> = art.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).map(|c| (c.id, c.visible, c.locked)).collect(); + assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}"); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ae8b7d9e90..67b4b464fd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -68,7 +68,7 @@ pub use adjust::{ ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment, is_channel, }; -pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; pub use clip::clip_outline; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index 03b335f010..2c02f886c0 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -2,8 +2,9 @@ //! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself //! ([`Hooks`]): type inside an envelope distorts as its glyph outlines, run by run //! ([`outline_text`]); symbol instances as their symbol's art; with Distort Appearance strokes and -//! geometry effects bend with the art ([`bake_appearance`]); with Distort Pattern Fills the pattern -//! tiles do. +//! geometry effects bend with the art ([`bake_appearance`]), except type's strokes, which paint +//! along its bent outlines at their own weight ([`bake_type_appearance`], #6644); with Distort +//! Pattern Fills the pattern tiles do. use std::sync::Arc; @@ -11,7 +12,7 @@ use vectorcraft_doc::live::{self, Hooks, Outliner}; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Affine, Rect}; -use crate::{bake_appearance, outline_text}; +use crate::{bake_appearance, bake_type_appearance, outline_text}; /// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the /// run's paint ([`outline_text`]). @@ -44,7 +45,8 @@ fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); let appearance = |n: &Node| bake_appearance(doc, n); - f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc }) + let type_appearance = |n: &Node| bake_type_appearance(doc, n); + f(Hooks { outline: Some(&outline), appearance: Some(&appearance), type_appearance: Some(&type_appearance), pattern: Some(&tiles), symbols: doc }) } /// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 43b339ccf4..4795e8848a 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -112,7 +112,9 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul let b = union_bounds(&shapes).ok_or(EMPTY)?; let options = shared_options(&shapes, revolve_options(p))?; let mut copies = 0; - return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies)); + shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; + let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); + return check_shadow(p, casting.map(|s| &s.path), b, options); } let (path, _) = object_profile(n, "Revolve")?; if !has_two_anchors(&path) { @@ -121,7 +123,18 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul let path = geometry(&path, before, &GeomContext::of(n)); let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); - check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0) + check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?; + check_shadow(p, [&path], b, revolve_options(p)) +} + +/// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast +/// from, about the axis of `b`, are past the limits together. +fn check_shadow<'a>(p: &Value, paths: impl IntoIterator, b: Rect, options: Revolve) -> Result<(), String> { + if crate::solids::shadow::options(p).is_none() { + return Ok(()); + } + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + paths.into_iter().try_for_each(|path| caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options).map(|_| ())) } /// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of @@ -144,14 +157,16 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; - let options = match at.and_then(|at| effects.get(at)) { - _ if i == target => revolve_options(p), - Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)), + let params = match at.and_then(|at| effects.get(at)) { + _ if i == target => p.clone(), + Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params), _ => continue, }; + let options = revolve_options(¶ms); let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + check_shadow(¶ms, [&path], b, options)?; } Ok(()) } @@ -189,9 +204,10 @@ fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> { items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last()) } -/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`. +/// Check Revolve's parameter values `p` alone: its lights, shadow, `edge` and `cap`. pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { crate::lighting::validate(p)?; + crate::solids::shadow::validate(p)?; if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { return Err("Revolve edge must be left or right".into()); } @@ -205,6 +221,43 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } +/// The shadow of Revolve `fx` cast by the surface of `path` in `item`'s paint about the axis of +/// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with +/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when +/// the surface it is cast from is past the limits. +fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result, String> { + let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) }; + let (_, opacity, blend) = paint_of(item); + if opacity <= 0.0 { + return Ok(None); + } + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options)?; + Ok(crate::solids::shadow::art(id, &caster, settings, crate::solids::shadow::Ink { opacity, blend })) +} + +/// The shadow of group Revolve `fx` cast by the surfaces of the members `shapes` that have one, +/// about the axis of `b`, on one plane; each member's part is as dark as its surface is opaque, +/// in its paint's blending mode, and its opacity mask and blending mode apply to that part +/// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the +/// limits. +fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result>, String> { + let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) }; + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + let mut cast = vec![]; + for s in shapes { + let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue }; + // A member whose surface is fully transparent casts nothing. + let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend }; + if ink.opacity <= 0.0 { + continue; + } + let faces = caster.revolve(&s.path, vectorcraft_geom::FillRule::NonZero, b, options)?; + cast.push((s.scopes.as_slice(), ink, faces)); + } + Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) +} + /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts /// the bytes of the paint copies of every surface of the object. #[allow(clippy::too_many_arguments)] @@ -445,7 +498,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?)); + let surface = object_surface(n, &path, fx, item, expand, &mut copies)?; + if let Some(b) = path.bounds() + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + { + children.push(Arc::new(shadow)); + } + children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = n .appearance @@ -458,6 +517,8 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } else { let path = geometry(&path, &n.appearance.effects, &ctx); remaining.retain(|e| !is_geometry(&e.id)); + // Every item's shadow goes behind every item's art. + let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { let fx = item.effects().get(index)?; @@ -465,11 +526,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + if let Some(b) = p.bounds() + && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + { + shadow.appearance.effects = art.appearance.effects.clone(); + shadows.push(Arc::new(shadow)); + } children.push(Arc::new(art)); } else { children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); } } + children.splice(0..0, shadows); } let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; @@ -704,6 +772,9 @@ fn group_art(n: &Node, expand: bool) -> Option { scoped(n.id, runs.collect(), 0) } }; + // One shadow for every member's surface, behind them all. + let mut children = children; + children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); let mut art = n.clone(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index be6c68ba74..ff5b1c9711 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -20,6 +20,8 @@ pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, clas mod source; pub(crate) use source::{resolve, resolve_for}; +pub(crate) mod shadow; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; @@ -192,6 +194,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { classic::validate(p)?; } crate::lighting::validate(p)?; + shadow::validate(p)?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { @@ -214,17 +217,31 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group /// build from the art they show ([`resolve`]). pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_solid_for(doc, n, id, p, None) +} + +/// [`validate_solid_in`] for the effect on the object (`item` `None`) or on its appearance item +/// `item`, whose shadow is cast by that item's solid alone. +fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option) -> Result<(), String> { validate_solid_params(id, p)?; let label = label_of(id); let n = &*source::resolve_for(doc, n, label, id == ROTATE)?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; - return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); + shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?; + return cast_shadow(id, p, &shapes).map(|_| ()); } let (path, rule) = object_profile(n, label)?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; - facets(id, &path, rule, b, p).map(|_| ()) + facets(id, &path, rule, b, p)?; + // With its shadow on, the shadow of the object's solids (or of the item's) must fit too. + let items = match item.and_then(|i| n.appearance.items.get(i)) { + Some(item) => vec![item.clone()], + None => solid_paints(n), + }; + let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; + cast_shadow(id, p, &shapes).map(|_| ()) } /// [`profile`], or the error a command reports for object `n`: type with no outlines to build from @@ -297,7 +314,7 @@ fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc)>) -> Vec> { } /// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal? -fn has_masks(shapes: &[Profile]) -> bool { +pub(crate) fn has_masks(shapes: &[Profile]) -> bool { shapes.iter().any(|s| s.scopes.iter().any(composites)) } @@ -420,7 +437,8 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), - _ => validate_three_d_in(doc, n, id, p), + Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)), + None => validate_three_d_in(doc, n, id, p), } } @@ -516,8 +534,8 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, } /// One part of [`solid_parts`]: a fill or stroke, its shape's opacity, the region it paints and its -/// fill rule, and its shape's scopes. -type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]); +/// fill rule, its shape's scopes and the index of its shape. +type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope], usize); /// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes, /// a group's members from the back up, each with its shape's opacity, the region it paints and its @@ -526,18 +544,128 @@ type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]); fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { let parts: Vec = shapes .iter() - .flat_map(|s| { + .enumerate() + .flat_map(|(member, s)| { let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); - s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { + s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(move |(_, i)| { let (area, rule) = region(&s.path, s.rule, i); - (i, s.opacity, area, rule, s.scopes.as_slice()) + (i, s.opacity, area, rule, s.scopes.as_slice(), member) }) }) .collect(); - let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); + let bounds = parts.iter().filter_map(|(_, _, area, _, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); (parts, bounds) } +/// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's +/// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of +/// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its +/// strokes that covers the fill's whole edge. +struct Placed { + depth: f64, + side: bool, + away: bool, + bounds: Rect, + member: usize, + paints: Paints, +} + +/// The part of its shape a face's solid is built from ([`Placed`]). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Paints { + Fill, + /// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without + /// dashes, a width profile or a brush, painted with a solid color or a gradient without + /// transparent stops, on a shape whose subpaths are all closed. + Stroke, + Other, +} + +impl Paints { + /// What `item` of a shape is, the shape's subpaths all closed when `closed`. + fn of(item: &AppearanceItem, closed: bool) -> Self { + match item { + AppearanceItem::Fill(_) => Paints::Fill, + AppearanceItem::Stroke(s) + if closed + && s.width > 0.0 + && s.width.is_finite() + && s.dash.is_none() + && s.profile.is_none() + && s.brush.is_none() + && match &s.paint { + Paint::Solid { .. } => true, + Paint::Gradient(_) => crate::lighting::opaque_paint(&s.paint).1.is_none(), + _ => false, + } => + { + Paints::Stroke + } + AppearanceItem::Stroke(_) => Paints::Other, + } + } +} + +impl Placed { + /// The face of `f`, of shape `member`'s part `paints`. + fn of(f: &Facet, member: usize, paints: Paints) -> Self { + let mut points = f.contours.iter().flatten(); + let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q))); + Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints } + } +} + +/// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep +/// their depths. +const MAX_OVERLAP_CHECKS: usize = 4_000_000; + +/// The order faces `faces` are drawn in, back to front, as indices into `faces`: by depth (faces +/// at the same depth in the order given), except that with `sealed` (solids with caps) a side face +/// of a shape's fill turned toward the viewer is drawn before each face of the same shape's +/// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own. +/// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under +/// any other face. +fn draw_order(faces: &[Placed], sealed: bool) -> Vec { + // Each face's depth to sort by, and whether it was moved before a stroke face at that depth. + let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect(); + if sealed { + // The stroke faces turned toward the viewer of each shape, nearest last. + let mut strokes: Vec> = vec![]; + for f in faces.iter().filter(|f| f.paints == Paints::Stroke && !f.away) { + if strokes.len() <= f.member { + strokes.resize_with(f.member + 1, Vec::new); + } + strokes[f.member].push(f); + } + for list in &mut strokes { + list.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + } + let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; + let mut checks = 0usize; + for (f, key) in faces.iter().zip(&mut keys) { + if f.paints != Paints::Fill || !f.side || f.away { + continue; + } + let Some(list) = strokes.get(f.member).filter(|l| !l.is_empty()) else { continue }; + for s in list.iter().take_while(|s| s.depth <= f.depth) { + if checks >= MAX_OVERLAP_CHECKS { + break; + } + checks += 1; + if overlap(&s.bounds, &f.bounds) { + *key = (s.depth, true); + break; + } + } + } + } + let mut order: Vec = (0..faces.len()).collect(); + // A stable sort: faces at the same depth keep the order of the appearance, and a fill's side + // face moved to a stroke face's depth goes before it. + order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1))); + order +} + /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { @@ -551,16 +679,100 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile } } +/// A 3D effect's shadow and its surfaces ([`art_for`]). +type ShadowAndSurfaces = (Vec>, Vec>); + /// The 3D art of one effect `fx` on `path` (already through the effects before it). /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite or masks it, their faces are drawn in one back-to-front order (where -/// depths tie, as for the caps turned toward the viewer, in the order of the appearance); +/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with +/// caps, a shape's fill walls before the faces of its covering strokes that overlap them: +/// [`draw_order`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds -/// ([`masked`]). -fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { +/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it +/// off), to be drawn behind every surface of the object, and the surfaces. +fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { + let art = surfaces_for(n, fx, shapes)?; + // A shadow past the limits draws the source path, as a solid past them does; commands report + // the error ([`validate_solid_in`]). + let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? { + Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)), + None => vec![], + }; + Some((shadow, art)) +} + +/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on +/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the +/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow +/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past +/// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error. +/// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to +/// the part of the shadow they cast too ([`shadow::scoped_art`]). +fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result>, String> { + if id == CLASSIC_EXTRUDE { + return Ok(None); + } + let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) }; + let view = view_options(p); + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + // The faces each solid of visible art added, with its member's scopes and its ink (opacity + // and blending mode), in order. A fully transparent fill or stroke (or member) casts nothing + // and does not place the plane. + let mut cast: Vec = vec![]; + let ink = |item: &AppearanceItem, member: f32| { + let (_, opacity, blend) = paint_of(item, member); + shadow::Ink { opacity, blend } + }; + if id == ROTATE { + for s in shapes { + for item in s.items.iter().filter(|i| ink(i, s.opacity).opacity > 0.0) { + let (area, rule) = region(&s.path, s.rule, item); + let faces = caster.plane(&area, rule, b, view)?; + cast.push((s.scopes.as_slice(), ink(item, s.opacity), faces)); + } + } + } else { + let (parts, bounds) = solid_parts(shapes, b); + let inks: Vec = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect(); + // Inflate's eye is placed for every shape together, as the solids are. + let added: Vec, String>> = if id == INFLATE { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); + let casts: Vec = inks.iter().map(|i| i.opacity > 0.0).collect(); + caster.inflate(&shapes, &casts, bounds, inflate_options(p), view) + } else { + let mut added = vec![]; + for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) { + added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); + } + added + }; + for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) { + if ink.opacity <= 0.0 { + continue; + } + // A stroke whose own solid is past the limits is left out; past the shadow's limit for + // all the solids together, the shadow is refused. + let faces = match faces { + Ok(faces) => faces, + Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue, + Err(e) => return Err(e), + }; + cast.push((*scopes, ink, faces)); + } + } + Ok(Some((caster, settings, cast))) +} + +/// The faces a shadow is cast from, its settings, and the faces of each solid of visible art with +/// its member's scopes and ink ([`cast_shadow`]). +type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec>); + +/// [`art_for`] without the shadow: the solids' surfaces. +fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE; @@ -588,17 +800,17 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> let (parts, bounds) = solid_parts(shapes, b); let built = if fx.id == INFLATE { // One eye for every puff, as far away as the highest needs. - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) } else { - parts.iter().map(|(_, _, area, rule, _)| facets(&fx.id, area, *rule, bounds, &p)).collect() + parts.iter().map(|(_, _, area, rule, _, _)| facets(&fx.id, area, *rule, bounds, &p)).collect() }; let mut solids = vec![]; - for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) { + for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None, - Ok(f) => solids.push((item, *opacity, *scopes, f)), + Ok(f) => solids.push((item, *opacity, *scopes, f, *member)), // A stroke's outline past the limits is left out; the fill's solid still draws. Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} // A loaded file or a shape edited past the limits keeps drawing its source path @@ -610,14 +822,16 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> if solids.is_empty() { return None; } - let opaque = solids.iter().all(|(item, member, _, _)| { + let opaque = solids.iter().all(|(item, member, _, _, _)| { let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); - let surfaces: Vec<(Vec, Node, &[Scope])> = solids + let surfaces: Vec<(Vec, Node, &[Scope])> = solids .into_iter() - .map(|(item, member, scopes, f)| { - let depths: Vec = f.iter().map(|f| f.depth).collect(); + .map(|(item, member, scopes, f, shape)| { + let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed)); + let paints = Paints::of(item, closed); + let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); let (paint, opacity, blend) = paint_of(item, member); let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; (depths, art, scopes) @@ -635,15 +849,20 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> if !opaque || !plain { return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect()); } - let mut faces = vec![]; + let (mut placed, mut faces) = (vec![], vec![]); for (depths, s, _) in surfaces { if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children)); + placed.extend(depths); + faces.extend(children.into_iter().map(Some)); } } - // A stable sort: faces at the same depth keep the order of the appearance. - faces.sort_by(|a, b| a.0.total_cmp(&b.0)); - Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))]) + let sealed = match fx.id.as_str() { + INFLATE => inflate_options(&p).cap, + CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, + _ => extrude_options(&p).cap, + }; + let order = draw_order(&placed, sealed); + Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))]) } fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { @@ -676,7 +895,8 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?); + let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; + children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -689,18 +909,24 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } else { let path = geometry(&path, &n.appearance.effects, &ctx); remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + // Every item's shadow goes behind every item's art. + let mut shadows = vec![]; for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? { - Arc::make_mut(&mut art).appearance.effects = - item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); - children.push(art); - } + let (shadow, surfaces) = art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + let later: Vec = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); + let with_later = |mut art: Arc| { + Arc::make_mut(&mut art).appearance.effects = later.clone(); + art + }; + shadows.extend(shadow.into_iter().map(with_later)); + children.extend(surfaces.into_iter().map(with_later)); } else { children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); } } + children.splice(0..0, shadows); } let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; @@ -716,7 +942,8 @@ fn group_solid_art(n: &Node) -> Option { let (index, fx) = solid(&n.appearance.effects)?; let mut shapes = members(n, "").ok()?; reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); - let children = art_for(n, fx, &shapes)?; + let (shadow, surfaces) = art_for(n, fx, &shapes)?; + let children = shadow.into_iter().chain(surfaces).collect(); let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); let mut art = n.clone(); @@ -788,11 +1015,11 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel // solid and its mesh are built as the loop reaches them. let mut inflated = (fx.id == INFLATE).then(|| { - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) }); let extrude = extrude_options(&p); - for (item, member, area, rule, _) in &parts { + for (item, member, area, rule, _, _) in &parts { let item: &AppearanceItem = item; let mesh = match inflated.as_mut() { Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), @@ -906,3 +1133,6 @@ mod tests_mesh; #[cfg(test)] mod tests_sources; + +#[cfg(test)] +mod tests_shadow; diff --git a/crates/effects/src/solids/shadow.rs b/crates/effects/src/solids/shadow.rs new file mode 100644 index 0000000000..3906ac2d3c --- /dev/null +++ b/crates/effects/src/solids/shadow.rs @@ -0,0 +1,101 @@ +//! The shadow of the 3D and Materials effects (Extrude & Bevel, Inflate, Rotate and Revolve): the +//! `shadow` switch with `shadowPosition`, `shadowDistance` and `shadowBounds`, and the shape it +//! draws behind the solids. +use std::ops::Range; +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId}; +use vectorcraft_three_d::{Shadow, ShadowCaster, ShadowPlane, ShadowPosition}; + +use super::Scope; +use crate::util::{flag, num, text}; + +/// The shadow's paint: black at this opacity. VectorCraft's own values. +const SHADOW_OPACITY: f32 = 0.5; + +/// The shadow `p` asks for, or `None` with the switch off or when the first light lights nothing +/// (Shading is off or its intensity is 0), so that no shadow is built. +pub(crate) fn options(p: &Value) -> Option { + let intensity = crate::lighting::lights(p).and_then(|l| l.iter().next().map(|l| l.intensity)).unwrap_or_else(|| num(p, "lightIntensity", 80.0)); + let lit = flag(p, "shade", true) && intensity > 0.0; + (lit && flag(p, "shadow", false)).then(|| Shadow { + position: ShadowPosition::parse(text(p, "shadowPosition", "behind")).unwrap_or_default(), + distance: num(p, "shadowDistance", 0.0) / 100.0, + bounds: num(p, "shadowBounds", 100.0) / 100.0, + }) +} + +/// Check the shadow settings in `p`: an error for a value out of its range or of the wrong kind. +pub(crate) fn validate(p: &Value) -> Result<(), String> { + if p.get("shadow").is_some_and(|v| !v.is_boolean()) { + return Err("shadow must be true or false".into()); + } + if p.get("shadowPosition").is_some_and(|v| v.as_str().and_then(ShadowPosition::parse).is_none()) { + return Err("shadowPosition must be behind or below".into()); + } + for (key, min, max) in [("shadowDistance", 0.0, 100.0), ("shadowBounds", 10.0, 400.0)] { + if p.get(key).is_some_and(|v| !v.as_f64().is_some_and(|n| n.is_finite() && (min..=max).contains(&n))) { + return Err(format!("{key} must be a number from {min} to {max}")); + } + } + Ok(()) +} + +/// The opacity and blending mode of the fill or stroke (and member) that casts part of a shadow. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Ink { + pub(crate) opacity: f32, + pub(crate) blend: BlendMode, +} + +/// The shadow of the faces in `caster` as a path of object `id`, cast by art in `ink`: filled +/// black at [`SHADOW_OPACITY`] times its opacity, in its blending mode, with the nonzero rule; +/// `None` when it casts nothing. +pub(crate) fn art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, ink: Ink) -> Option { + let all = 0..caster.len(); + let all = std::slice::from_ref(&all); + piece(id, caster, &caster.shadow_plane(shadow, all)?, all, ink) +} + +/// [`art`] of the faces in the ranges `faces` of `caster` only, on `plane`. +fn piece(id: NodeId, caster: &ShadowCaster, plane: &ShadowPlane, faces: &[Range], ink: Ink) -> Option { + let path = caster.path_on(plane, faces)?; + let mut fill = FillLayer::new(Paint::solid(Color::BLACK)); + fill.opacity = SHADOW_OPACITY * ink.opacity.clamp(0.0, 1.0); + fill.blend = ink.blend; + Some(Node::path(id, path, Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() })) +} + +/// Whether `a` and `b` are the same objects' scopes (each member holds its own copies). +fn same_scopes(a: &[Scope], b: &[Scope]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.key == b.key) +} + +/// The faces one solid added to a caster, with the scopes of its member and the ink of its art. +pub(crate) type Cast<'a> = (&'a [Scope], Ink, Range); + +/// The faces of the solids that cast one piece of a shadow, with their scopes and ink. +type Run<'a> = (&'a [Scope], Ink, Vec>); + +/// The shadow `cast` casts on one plane: the faces of `caster` that the solids of visible art +/// added, each range with the scopes of the member that cast it and the ink of the art, in order. +/// Each run of solids in the same ink casts a piece in that ink ([`art`]); with `masks` (a member's +/// opacity mask or blending mode applies to its solids) the solids of a run also share their scopes, +/// and each piece is drawn in their opacity masks and blending modes ([`super::masked`]). +pub(crate) fn scoped_art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, cast: Vec>, masks: bool) -> Vec> { + let placing: Vec> = cast.iter().map(|(.., faces)| faces.clone()).collect(); + let Some(plane) = caster.shadow_plane(shadow, &placing) else { return vec![] }; + // Consecutive solids in the same ink (and with masks, the same scopes) share a piece; the pieces + // keep the solids' order. + let mut runs: Vec = vec![]; + for (scopes, ink, faces) in cast { + match runs.last_mut() { + Some(run) if run.1 == ink && (!masks || same_scopes(run.0, scopes)) => run.2.push(faces), + _ => runs.push((scopes, ink, vec![faces])), + } + } + let pieces = runs.into_iter().filter_map(|(scopes, ink, faces)| Some((scopes, Arc::new(piece(id, caster, &plane, &faces, ink)?)))); + if masks { super::masked(id, pieces.collect()) } else { pieces.map(|(_, piece)| piece).collect() } +} diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 7273a44b22..248473f428 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -180,6 +180,137 @@ fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() { } } +/// A C, open to the right: two concave corners where its walls meet the walls of the notch. +fn letter_c() -> BezPath { + let mut b = BezPath::new(); + b.move_to((100.0, 100.0)); + b.line_to((200.0, 100.0)); + b.line_to((200.0, 130.0)); + b.line_to((130.0, 130.0)); + b.line_to((130.0, 170.0)); + b.line_to((200.0, 170.0)); + b.line_to((200.0, 200.0)); + b.line_to((100.0, 200.0)); + b.close_path(); + b +} + +/// The pairs (blue, red) of `walls`, by index, where the blue face is drawn first and the two +/// faces' bounds overlap. +fn red_after_blue(walls: &[&Node]) -> Vec<(usize, usize)> { + let bounds: Vec = walls.iter().map(|f| f.path_data().and_then(PathData::bounds).unwrap()).collect(); + let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; + let mut out = vec![]; + for (i, a) in walls.iter().enumerate() { + for (j, b) in walls.iter().enumerate().skip(i + 1) { + if blue(a) && !blue(b) && overlap(&bounds[i], &bounds[j]) { + out.push((i, j)); + } + } + } + out +} + +/// An opaque stroke's solid holds the fill's edge, so the fill's walls are drawn before every +/// face of the stroke they overlap, and the stroke's walls cover them at the concave corners of a +/// turned C, in Extrude & Bevel and Extrude & Bevel (Classic). The front caps stay last, the +/// fill's below the stroke's. +#[test] +fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() { + let views = [json!({}), json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10})]; + for id in [EXTRUDE, CLASSIC_EXTRUDE] { + for view in &views { + let mut params = view.clone(); + params["depth"] = json!(60); + let mut n = stroked(&letter_c()); + n.appearance.effects.push(fx(id, params)); + let art = solid_art(&n).unwrap(); + let faces = faces(&art); + let [walls @ .., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{id} {view}: the caps") }; + assert!(!blue(fill_cap) && blue(stroke_cap), "{id} {view}: the caps last"); + assert!(walls.iter().any(|f| !blue(f)) && walls.iter().any(|f| blue(f)), "{id} {view}: walls of both"); + let wrong = red_after_blue(walls); + assert!(wrong.is_empty(), "{id} {view}: fill walls after the stroke faces over them: {wrong:?} of {}", walls.len()); + } + } + // Strokes aligned inside or outside keep the fill's walls, in that order. A dashed stroke + // leaves the fill's walls in the gaps between its dashes, and they keep their depths. + use vectorcraft_doc::StrokeAlign; + for (dash, align) in [(true, StrokeAlign::Center), (false, StrokeAlign::Inside), (false, StrokeAlign::Outside)] { + let mut n = stroked(&letter_c()); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.dash = dash.then(|| vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + s.align = align; + } + n.appearance.effects.push(fx(EXTRUDE, views[1].clone())); + let art = solid_art(&n).unwrap(); + let faces = faces(&art); + let [walls @ .., _, _] = faces.as_slice() else { panic!("{align:?}: the caps") }; + assert!(walls.iter().any(|f| !blue(f)), "{align:?}: the fill's walls"); + if !dash { + assert!(red_after_blue(walls).is_empty(), "{align:?}"); + } + } +} + +/// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a +/// face of the same shape's stroke drawn before it: the wall then goes just before that face. A +/// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an +/// effect without caps keep the order of the depths. +#[test] +fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { + let at = |depth: f64, x: f64, member: usize, paints: Paints, side: bool, away: bool| Placed { + depth, + side, + away, + bounds: Rect::new(x, 0.0, x + 10.0, 10.0), + member, + paints, + }; + // 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke + // wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a + // fill wall at the stroke wall's depth. + let faces = [ + at(5.0, 0.0, 0, Paints::Fill, true, false), + at(3.0, 5.0, 0, Paints::Stroke, true, false), + at(2.0, 5.0, 1, Paints::Stroke, true, false), + at(1.0, 50.0, 0, Paints::Stroke, true, false), + at(0.0, 0.0, 0, Paints::Stroke, true, true), + at(4.0, 0.0, 0, Paints::Fill, false, false), + at(6.0, 0.0, 0, Paints::Stroke, false, false), + at(3.0, 0.0, 0, Paints::Fill, true, false), + ]; + assert_eq!(draw_order(&faces, true), [4, 3, 2, 0, 7, 1, 5, 6]); + assert_eq!(draw_order(&faces, false), [4, 3, 2, 1, 7, 5, 0, 6]); + // A shape with no stroke, and a stroke without a width, move nothing. + // Only a stroke whose solid hides the fill's whole edge counts: an opaque, plain stroke with a + // width on a closed shape. + let stroke = |f: &dyn Fn(&mut StrokeLayer)| { + let mut s = StrokeLayer::new(Paint::solid(BLUE), 6.0); + f(&mut s); + AppearanceItem::Stroke(s) + }; + assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke); + assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape"); + assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width"); + let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false }; + assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes"); + assert!(Paints::of(&stroke(&|s| s.brush = Some("brush".into())), true) == Paints::Other); + assert!(Paints::of(&stroke(&|s| s.paint = Paint::None), true) == Paints::Other); + assert!(Paints::of(&stroke(&|s| s.paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY }), true) == Paints::Other, "a pattern"); + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |opacity: f32| { + let mut end = GradientStop::new(1.0, RED); + end.opacity = opacity; + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end])))) + }; + assert!(Paints::of(&stroke(&|s| s.paint = gradient(1.0)), true) == Paints::Stroke, "an opaque gradient"); + assert!(Paints::of(&stroke(&|s| s.paint = gradient(0.5)), true) == Paints::Other, "a gradient with a transparent stop"); + let other = |p: Paints| if p == Paints::Stroke { Paints::Other } else { p }; + let unstroked: Vec = faces.iter().map(|f| at(f.depth, f.bounds.x0, f.member, other(f.paints), f.side, f.away)).collect(); + assert_eq!(draw_order(&unstroked, true), draw_order(&faces, false)); +} + /// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose /// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner. #[test] diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs new file mode 100644 index 0000000000..4400555d2b --- /dev/null +++ b/crates/effects/src/solids/tests_shadow.rs @@ -0,0 +1,225 @@ +use serde_json::json; +use vectorcraft_doc::color::Color; +use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_geom::{Affine, BezPath}; + +use super::*; + +const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 }; + +/// The 3D and Materials effects that cast shadows. +const CASTING: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, crate::REVOLVE]; + +fn square_node(id: u64, dx: f64) -> Node { + let mut b = BezPath::new(); + b.move_to((100.0 + dx, 100.0)); + b.line_to((200.0 + dx, 100.0)); + b.line_to((200.0 + dx, 200.0)); + b.line_to((100.0 + dx, 200.0)); + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0)) +} + +fn art(n: &Node, id: &str) -> Option { + if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) } +} + +/// The opacity of shadow `n`: a path filled black and nothing else; `None` for any other node. +fn shadow_opacity(n: &Node) -> Option { + let [AppearanceItem::Fill(fill)] = n.appearance.items.as_slice() else { return None }; + (matches!(n.kind, NodeKind::Path { .. }) && fill.paint.color() == Some(Color::BLACK)).then_some(fill.opacity) +} + +/// Whether `n` is the shadow of opaque art: a path filled black at 50 %, nothing else. +fn is_shadow(n: &Node) -> bool { + shadow_opacity(n) == Some(0.5) +} + +fn shadows(n: &Node) -> usize { + let mut count = 0; + n.walk(&mut |c| count += usize::from(is_shadow(c))); + count +} + +#[test] +fn each_3d_effect_draws_its_shadow_behind_its_solid_when_shadows_are_on() { + for id in CASTING { + let mut n = square_node(1, 0.0); + n.appearance.effects.push(crate::new_effect(id, &json!({})).unwrap()); + let plain = art(&n, id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&plain), 0, "{id}: off by default"); + n.appearance.effects[0].params = json!({"shadow": true, "shadowDistance": 50}); + let lit = art(&n, id).unwrap_or_else(|| panic!("{id}")); + let children = lit.children().unwrap(); + assert!(is_shadow(&children[0]), "{id}: the shadow is drawn first"); + assert_eq!(shadows(&lit), 1, "{id}"); + // The default light is up and to the left: the shadow on a plane away from the object + // reaches past the solid down and right. + let shadow = children[0].visual_bounds().unwrap(); + let solid = children[1..].iter().filter_map(|c| c.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!(shadow.x1 > solid.x1 && shadow.y1 > solid.y1, "{id}: {shadow:?} {solid:?}"); + // The art's bounds take the shadow in. + assert!(lit.visual_bounds().unwrap().contains_rect(shadow), "{id}"); + } +} + +#[test] +fn the_shadow_moves_with_its_settings() { + for id in CASTING { + let bounds = |params: Value| { + let mut n = square_node(1, 0.0); + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + let lit = art(&n, id).unwrap(); + let shadow = &lit.children().unwrap()[0]; + assert!(is_shadow(shadow), "{id} {params}"); + shadow.visual_bounds().unwrap() + }; + let near = bounds(json!({"shadow": true})); + let far = bounds(json!({"shadow": true, "shadowDistance": 100})); + assert!(far.x1 > near.x1 + 1.0, "{id}: {near:?} {far:?}"); + // With the light straight in front, the outline falls about the plane's center; at 10 % + // the plane cuts it to a square 0.4 times the object's size across. + let tight = bounds(json!({"shadow": true, "shadowBounds": 10, "lightHeight": 90})); + let wide = bounds(json!({"shadow": true, "shadowBounds": 400, "lightHeight": 90})); + assert!(wide.width() > tight.width() * 2.0, "{id}: {tight:?} {wide:?}"); + let below = bounds(json!({"shadow": true, "shadowPosition": "below", "rotationX": -30})); + assert_ne!(below, near, "{id}"); + } +} + +#[test] +fn shadow_settings_out_of_range_or_of_the_wrong_kind_are_refused() { + for id in CASTING { + for (params, error) in [ + (json!({"shadow": "yes"}), "shadow must be true or false"), + (json!({"shadowPosition": "under"}), "shadowPosition must be behind or below"), + (json!({"shadowPosition": 1}), "shadowPosition must be behind or below"), + (json!({"shadowDistance": 101}), "shadowDistance must be a number from 0 to 100"), + (json!({"shadowDistance": -1}), "shadowDistance must be a number from 0 to 100"), + (json!({"shadowBounds": 5}), "shadowBounds must be a number from 10 to 400"), + (json!({"shadowBounds": "wide"}), "shadowBounds must be a number from 10 to 400"), + ] { + let err = validate_three_d_params(id, ¶ms).unwrap_err(); + assert!(err.contains(error), "{id} {params}: {err}"); + } + let ok = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 100, "shadowBounds": 10}); + assert!(validate_three_d_params(id, &ok).is_ok(), "{id}"); + } +} + +#[test] +fn a_group_casts_one_shadow_for_all_its_members() { + for id in CASTING { + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 200.0))]); + g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap()); + let lit = art(&g, id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&lit), 1, "{id}"); + let shadow = lit.children().unwrap()[0].clone(); + assert!(is_shadow(&shadow), "{id}"); + // With the light straight in front, the shadow behind spans both members. + let b = shadow.visual_bounds().unwrap(); + assert!(b.x0 < 200.0 && b.x1 > 300.0, "{id}: {b:?}"); + } +} + +/// A member's opacity mask applies to the part of the shadow it casts, as to its solid: that part +/// is drawn in the mask, the other member's outside it, both on one plane. +#[test] +fn a_masked_member_casts_its_part_of_the_shadow_in_its_mask() { + for id in CASTING { + let mut masked = square_node(2, 200.0); + let mut cover = square_node(3, 200.0); + cover.appearance.items = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0).items; + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(cover, true))); + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(masked)]); + g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let lit = art(&g, id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&lit), 2, "{id}: one piece for each member"); + let mut in_mask = 0; + lit.walk(&mut |c| { + if c.extras().mask.is_some() { + c.walk(&mut |s| in_mask += usize::from(is_shadow(s))); + } + }); + assert_eq!(in_mask, 1, "{id}"); + } +} + +#[test] +fn transparent_art_and_a_light_that_lights_nothing_cast_no_shadow() { + for id in CASTING { + let mut n = square_node(1, 0.0); + n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "shade": false})).unwrap()); + assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: Shading off"); + n.appearance.effects[0].params = json!({"shadow": true, "lightIntensity": 0}); + assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: no intensity"); + // A member at 0 % opacity casts nothing; the other member still casts its shadow. + let mut clear = square_node(2, 200.0); + clear.opacity = 0.0; + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(clear)]); + g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap()); + let lit = art(&g, id).unwrap_or_else(|| panic!("{id}")); + let shadow = lit.children().unwrap()[0].clone(); + assert!(is_shadow(&shadow), "{id}"); + let b = shadow.visual_bounds().unwrap(); + assert!(b.x1 < 300.0, "{id}: only the visible member casts: {b:?}"); + // Art at 50 % casts a shadow half as dark. + let mut half = square_node(1, 0.0); + for item in &mut half.appearance.items { + if let AppearanceItem::Fill(fill) = item { + fill.opacity = 0.5; + } + } + half.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let lit = art(&half, id).unwrap(); + assert_eq!(shadow_opacity(&lit.children().unwrap()[0]), Some(0.25), "{id}"); + // Art in a blending mode casts its shadow in that mode. + for item in &mut half.appearance.items { + if let AppearanceItem::Fill(fill) = item { + fill.blend = BlendMode::Screen; + } + } + let lit = art(&half, id).unwrap(); + let AppearanceItem::Fill(fill) = &lit.children().unwrap()[0].appearance.items[0] else { panic!("{id}: a fill") }; + assert_eq!(fill.blend, BlendMode::Screen, "{id}"); + } +} + +/// With a 3D effect on each of two fills, both fills' shadows are drawn behind both fills' solids. +#[test] +fn the_shadows_of_every_fill_go_behind_every_fill_s_solid() { + for id in CASTING { + let mut n = square_node(1, 0.0); + let fill = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap().clone(); + n.appearance.items = vec![fill.clone(), fill]; + for item in &mut n.appearance.items { + item.effects_mut().push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + } + let lit = art(&n, id).unwrap_or_else(|| panic!("{id}")); + let children = lit.children().unwrap(); + assert!(children.len() >= 4, "{id}"); + assert!(is_shadow(&children[0]) && is_shadow(&children[1]), "{id}: both shadows first"); + assert!(children[2..].iter().all(|c| !is_shadow(c)), "{id}"); + } +} + +#[test] +fn expand_appearance_keeps_the_shadow_as_a_path() { + for id in CASTING { + let mut d = Document::new(400.0, 400.0); + let mut n = square_node(1, 0.0); + n.id = d.alloc_id(); + n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; + let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap_or_else(|| panic!("{id}")); + assert_eq!(shadows(&expanded), 1, "{id}"); + let mut effects = 0; + expanded.walk(&mut |c| effects += c.appearance.effects.len()); + assert_eq!(effects, 0, "{id}"); + // It moves with the object, as the solid does. + let before = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap(); + n.transform(Affine::translate((40.0, 0.0)), false); + let after = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap(); + assert!((after.x0 - before.x0 - 40.0).abs() < 1e-6, "{id}: {before:?} {after:?}"); + } +} diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index c96dcb477c..acb980204c 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -173,6 +173,18 @@ fn bar(hw: f64) -> f64 { /// Where the notch of the (open) arrow sits, as a fraction of the length. const NOTCH: f64 = 0.7; +/// How far [`super::stroke_pieces`] trims the line under each end's head: the head's inset from +/// the tip, along the path. Both are 0 without a head at that end, and in [`ArrowAlign::Extend`], +/// where the stroke keeps its full length. +pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) { + if st.arrow_align != ArrowAlign::Tip { + return (0.0, 0.0); + } + let cap = cap_extent(st.cap, st.width); + let inset_of = |kind: Option, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); + (inset_of(st.start_arrow, false), inset_of(st.end_arrow, true)) +} + /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 1cb79e9c72..4bddf80b0a 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -18,11 +18,11 @@ use std::collections::VecDeque; use kurbo::{BezPath, Point, Shape, Vec2}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP}; -use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; +use vectorcraft_doc::{GradientMesh, LineCap, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::FillRule; use super::width::{flatten, left, length, split_at}; -use super::{aligned_width, cap_extent, is_closed, segments, subpaths, unit}; +use super::{aligned_width, arrow, cap_extent, is_closed, segments, subpaths, unit}; /// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the /// corner gets a fan of ribs round its outside. @@ -97,7 +97,10 @@ impl Lay<'_> { /// How far a rib at fraction `t` reaches either side of the path to cover the stroke there. fn cover(&self, t: f64) -> f64 { - self.half(t) + self.margin + // A square cap's corners stand √2 of the half-width out from where the dash ends, and a + // dash can end just past a corner: reach that far, or they paint outside every slice. + let diag = if self.st.dash.is_some() && self.st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + self.half(t) * diag + self.margin } /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: @@ -162,6 +165,9 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, return vec![]; } let heads = (st.head_reach(false), st.head_reach(true)); + // With a tip-aligned arrowhead the stroke's line, and with it the width profile, stops at the + // head's inset from the tip (`stroke_pieces`). + let trims = arrow::trims(st); let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) }; // Caps reach as far past the ends as the stroke is wide there. let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t)); @@ -170,27 +176,34 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, let count = subs.len(); let mut out = vec![]; for (k, (range, closed)) in subs.into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4)); - let side = - (st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0))); + let segs = segments(&bp.elements()[range]); + let flat = flatten(&segs, closed, tol.max(1e-4)); + let side = (st.align != StrokeAlign::Center && closed_path && closed) + .then(|| band_on_left(bp, rule, &flat.pts, st.align, (4.0 * tol).min(hw / 2.0))); // Arrowheads sit on the start of the first subpath and the end of the last one. let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 }); - let ribs = match pts.len() { + let trims = (if k == 0 { trims.0 } else { 0.0 }, if k + 1 == count { trims.1 } else { 0.0 }); + let ribs = match flat.pts.len() { 0 => continue, // A subpath of no length: a dot (round and projecting caps paint it). 1 => { let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1)); let r = lay.cover(0.0); - vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)] + vec![Rib::square(flat.pts[0] - d * e, d, r, 0.0, b), Rib::square(flat.pts[0] + d * e, d, r, 0.0, b)] } _ => { - let total = length(&pts, closed); + let total = length(&flat.pts, closed); if total <= 1e-12 { continue; } let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 }; let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0)); let heads = if closed { (0.0, 0.0) } else { heads }; + // A tip-aligned arrowhead shortens the line it sits on, and the stroke's outline + // reads the width profile along the shortened line (`stroke_pieces`); the ribs + // must read it there too, or on a profile that changes fast they cover a narrower + // band than the stroke paints. + let span = if closed { (0.0, total) } else { (trims.0.min(total), (total - trims.1).max(trims.0)) }; // Where an arrowhead's reach ends gets a rib of its own, so the ribs that reach // across a head run all the way there instead of narrowing towards the next corner // from the head's tip, which leaves the corners of its base outside (#5964). @@ -200,11 +213,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, .copied() .chain(profile) .chain((1..steps).map(|i| i as f64 / steps as f64)) - .map(|t| t * total) + .map(|t| span.0 + (span.1 - span.0) * t) .chain(head_ends) .collect(); - let (pts, _) = split_at(&pts, &corner, closed, &at); - strip(&lay, &pts, closed, total, side, heads) + let (pts, _, _) = split_at(&flat, closed, &at, &segs); + strip(&lay, &pts, closed, total, side, heads, span) } }; out.push(ribs); @@ -226,8 +239,9 @@ fn band_on_left(bp: &BezPath, rule: FillRule, pts: &[Point], align: StrokeAlign, } /// The ribs along one flattened subpath `pts` of length `total` (`heads`: how far arrowheads -/// reach from its start and end). -fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, heads: (f64, f64)) -> Vec { +/// reach from its start and end). `span` is the stretch of the subpath the width profile is laid +/// along: the whole of it, or what is left between tip-aligned arrowheads. +fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, heads: (f64, f64), span: (f64, f64)) -> Vec { let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -249,7 +263,9 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, // stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part // reaching that far would paint over a wide part beside it (#5960). The start of a closed // subpath is also its end. Ribs near an arrowhead reach across it. - let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect(); + // The fraction of the profile at how far `s` is along the subpath: the profile spans `span`. + let t_at = |s: f64| if span.1 > span.0 { ((s - span.0) / (span.1 - span.0)).clamp(0.0, 1.0) } else { 0.0 }; + let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t_at(along[i])) }).collect(); let window = match lay.st.dash { Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()), _ => 0.0, @@ -279,7 +295,7 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, let (first, last) = if closed { (0, n) } else { (1, n - 1) }; for i in first..last { let v = i % n; - let t = along[v] / total; + let t = t_at(along[v]); let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side)); // A closed subpath starts after the fan of its first corner and ends with it. let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 }; diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 46969ff639..fc3f2eee48 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -232,16 +232,14 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3); assert!(near(plain.area().abs(), 1000.0, 1e-6)); let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3); - // The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714). - assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05)); - // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens - // rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers - // its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192. + // The lens is a parabola between its ends: 2/3 of the full-width band. + assert!(near(lens.area().abs(), 2000.0 / 3.0, 0.1)); + // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). let st = stroke(10.0, |s| { s.profile = Some(WidthProfile::lens()); s.dash = Some(dashed(&[25.0, 25.0], 0.0)); }); - assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05)); + assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 1000.0 / 3.0, 0.1)); assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); } diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b427fdbce7..b389cab853 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -155,9 +155,10 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip. + // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip, + // where the smooth profile is thinner than a straight ramp (a parabola's first tenth). assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide"); - assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin"); + assert!(o.contains(Point::new(5.0, 0.8)) && !o.contains(Point::new(5.0, 1.2)), "first dash is thin"); assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty"); // Dots too: a round dot near the middle is bigger than one near an end. let st = stroke(10.0, |s| { @@ -166,7 +167,7 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5))); + assert!(o.contains(Point::new(50.0, 4.5)) && o.contains(Point::new(10.0, 1.4)) && !o.contains(Point::new(10.0, 2.0))); } /// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0). diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index a0056d6b32..ea966ea076 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -138,9 +138,8 @@ fn across_runs_from_the_left_edge_to_the_right() { let mut lens = st.clone(); lens.profile = Some(WidthProfile::lens()); let sl = slices(&line, &lens); - // A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is - // 3.125 up. - assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05); + // A quarter of the way along, the lens is three quarters as wide: its top edge is 3.75 up. + assert!(param(&sl, Point::new(25.0, -3.6)).unwrap() < 0.05); assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02); } diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 4a9dc26784..0b307cc6b5 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -3,17 +3,18 @@ //! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and //! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the //! subpath's length) are placed along the normal. Corners of the path take the stroke's miter, -//! round or bevel join on their outer side (smooth points bend round), open subpaths get butt, -//! round or projecting caps (the round cap blends the two side widths), and closed subpaths become -//! two loops of opposite orientation filled with the non-zero rule. Dashes are pieces of a longer -//! subpath and read the profile where they sit along it ([`outline_spans`]). +//! round or bevel join on their outer side (smooth points offset along the curve's normal), open +//! subpaths get butt, round or projecting caps (the round cap blends the two side widths), and +//! closed subpaths become two loops of opposite orientation filled with the non-zero rule. Dashes +//! are pieces of a longer subpath and read the profile where they sit along it ([`outline_spans`]). use std::f64::consts::PI; -use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Rect, Vec2}; +use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; +use vectorcraft_pathops::fit_cubics_through; -use super::{kink, segments, subpaths, unit}; +use super::{kink, segments, subpaths, tangent, unit}; /// Filled outline of `bp` stroked with `width` scaled by `profile`, with the caps, joins and miter /// limit of `st`, flattened to `tol`. @@ -24,15 +25,16 @@ pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &Stro /// Outline only flattened segments near `near`, retaining each run's original fractions of its /// whole subpath for the width profile. `None` means no segments could be omitted. pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64, near: Rect) -> Option { - let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; + let ctx = Ctx::new(width, profile, st, tol); let mut out = BezPath::new(); let mut cut = false; for (r, closed) in subpaths(bp) { if closed { return None; } - let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); - let n = pts.len(); + let segs = segments(&bp.elements()[r]); + let flat = flatten(&segs, closed, ctx.tol); + let n = flat.pts.len(); if n < 2 { continue; } @@ -41,7 +43,7 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil let mut kept = Vec::with_capacity(count); let mut total = 0.0; for i in 0..count { - let (a, b) = (pts[i], pts[i + 1]); + let (a, b) = (flat.pts[i], flat.pts[i + 1]); let len = a.distance(b); if !len.is_finite() { return None; @@ -51,7 +53,7 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil kept.push(!(a.x.max(b.x) < near.x0 || a.x.min(b.x) > near.x1 || a.y.max(b.y) < near.y0 || a.y.min(b.y) > near.y1)); } if kept.iter().all(|v| *v) { - ctx.subpath(&mut out, &pts, &corner, closed, (0.0, 1.0)); + ctx.subpath(&mut out, &flat, &segs, closed, (0.0, 1.0), count as f64); continue; } cut = true; @@ -59,27 +61,31 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil continue; } let mut walked = 0.0; - let mut run = Vec::new(); - let mut run_corner = Vec::new(); + let mut run: Option = None; let mut from = 0.0; for i in 0..count { if kept[i] { - if run.is_empty() { + if run.is_none() { from = walked / total; - run.push(pts[i]); - run_corner.push(corner[i]); + run = Some(Flat { pts: vec![flat.pts[i]], corner: vec![flat.corner[i]], seg_of: vec![], param: vec![] }); } - run.push(pts[i + 1]); - run_corner.push(corner[i + 1]); - } else if run.len() > 1 { - ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total)); - run.clear(); - run_corner.clear(); + if let Some(part) = run.as_mut() { + part.pts.push(flat.pts[i + 1]); + part.corner.push(flat.corner[i + 1]); + part.seg_of.push(flat.seg_of[i]); + part.param.push(flat.param[i]); + } + } else if let Some(part) = run.take() + && part.pts.len() > 1 + { + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } walked += lengths[i]; } - if run.len() > 1 { - ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total)); + if let Some(part) = run + && part.pts.len() > 1 + { + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } } cut.then_some(out) @@ -89,12 +95,15 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil /// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans)); /// subpaths without a span are whole. pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath { - let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; + let ctx = Ctx::new(width, profile, st, tol); let mut out = BezPath::new(); for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); - if pts.len() > 1 { - ctx.subpath(&mut out, &pts, &corner, closed, spans.get(i).copied().unwrap_or((0.0, 1.0))); + let segs = segments(&bp.elements()[r]); + let flat = flatten(&segs, closed, ctx.tol); + if flat.pts.len() > 1 { + let span = spans.get(i).copied().unwrap_or((0.0, 1.0)); + let pieces = if closed { flat.pts.len() } else { flat.pts.len() - 1 }.max(1); + ctx.subpath(&mut out, &flat, &segs, closed, span, pieces as f64 / (span.1 - span.0)); } } out @@ -105,19 +114,34 @@ pub(super) fn left(t: Vec2) -> Vec2 { Vec2::new(t.y, -t.x) } +/// A flattened subpath: its points, which of them are corners, and the source segment and +/// parameter range that drew each flattened piece (so a sample added between two points can read +/// that piece's tangent instead of the chord's direction). +pub(super) struct Flat { + pub(super) pts: Vec, + pub(super) corner: Vec, + pub(super) seg_of: Vec, + /// The source segment's parameter range for each flattened piece. Restricting the tangent + /// search to this range keeps a sample on the branch that drew it when a cubic loops back + /// near itself. + pub(super) param: Vec<(f64, f64)>, +} + /// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't /// repeat its start), each marked when it is a corner of the path. -pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, Vec) { - let Some(s0) = segs.first() else { return (vec![], vec![]) }; - let (mut pts, mut corner) = (vec![s0.start()], vec![false]); - // The first and the last segment that drew something. - let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None); - for seg in segs { - let at = pts.len() - 1; +pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { + let Some(s0) = segs.first() else { return Flat { pts: vec![], corner: vec![], seg_of: vec![], param: vec![] } }; + let mut flat = Flat { pts: vec![s0.start()], corner: vec![false], seg_of: vec![], param: vec![] }; + // The last segment that drew something, and the last parameter it reached. + let mut prev: Option<&PathSeg> = None; + let mut last_added: Option<(usize, f64)> = None; + for (si, seg) in segs.iter().enumerate() { + let at = flat.pts.len() - 1; + let before = flat.pts.len(); let mut push = |p: Point| { - if pts.last().is_none_or(|l| l.distance(p) > 1e-9) { - pts.push(p); - corner.push(false); + if flat.pts.last().is_none_or(|l| l.distance(p) > 1e-9) { + flat.pts.push(p); + flat.corner.push(false); } }; match seg { @@ -130,77 +154,151 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, } }), } - if pts.len() > at + 1 { - match prev { - Some(p) => corner[at] = kink(p, seg), - None => first = Some(seg), + if flat.pts.len() > before { + let end = flat.pts.len() - 1; + flat.seg_of.resize(end, si); + for slot in flat.seg_of.iter_mut().take(end).skip(at) { + *slot = si; + } + // Find each new point's source parameter, never going backwards. A point is on the + // curve, so its nearest parameter is exact except at a self-intersection, where the + // monotone search picks the branch that continues this flattened piece. + let mut ts = Vec::with_capacity(end - at + 1); + ts.push(0.0); + let mut t = 0.0; + for p in &flat.pts[at + 1..=end] { + if t >= 1.0 - 1e-12 { + t = 1.0; + } else { + let sub = seg.subsegment(t..1.0); + let nr = sub.nearest(*p, 1e-9); + t = (t + (1.0 - t) * nr.t).clamp(0.0, 1.0); + } + ts.push(t); + } + flat.param.extend(ts.windows(2).map(|w| (w[0], w[1]))); + last_added = ts.last().map(|t| (si, *t)); + if let Some(p) = prev { + flat.corner[at] = kink(p, seg); } prev = Some(seg); } } if closed { - if pts.len() > 1 && pts[0].distance(pts[pts.len() - 1]) <= 1e-9 { - pts.pop(); - corner.pop(); + if flat.pts.len() > 1 && flat.pts[0].distance(flat.pts[flat.pts.len() - 1]) <= 1e-9 { + flat.pts.pop(); + flat.corner.pop(); } - if let (Some(a), Some(b)) = (prev, first) { - corner[0] = kink(a, b); + if let (Some(a), Some(b)) = (segs.last(), segs.first()) { + flat.corner[0] = kink(a, b); + } + // The closing piece comes from the last source segment even when its endpoint is the + // already-present start point and did not add a flattened point. Keep a tangent (and its + // whole parameter range) for it. + flat.seg_of.truncate(flat.pts.len()); + flat.param.truncate(flat.pts.len()); + if flat.seg_of.len() < flat.pts.len() { + let last = segs.len().saturating_sub(1); + // A closing curve with interior flattened points has already reached a parameter + // before its end (the duplicated start point was not added); the missing piece starts + // where it left off, not at 0. + let start = match last_added { + Some((si, t)) if si == last => t, + _ => 0.0, + }; + flat.seg_of.resize(flat.pts.len(), last); + flat.param.resize(flat.pts.len(), (start, 1.0)); } } - (pts, corner) + flat } -/// The most samples added between two width points. -const MAX_SPAN_SAMPLES: usize = 64; -/// The most samples [`densify`] adds between width points along one piece of a subpath. -const MAX_CURVE_SAMPLES: usize = 4096; - -/// Insert samples where the profile has width points (the two points of a discontinuous point -/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so -/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps -/// the subpath onto the profile (see [`outline_spans`]). -fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec, Vec) { - let total = length(pts, closed); +/// Insert samples where the profile has width points and between them, so its smooth curve is +/// drawn as a smooth outline along straight runs too (the two points of a discontinuous point +/// share one sample). `span` maps the subpath onto the profile (see [`outline_spans`]). +fn densify( + flat: &Flat, + segs: &[PathSeg], + closed: bool, + profile: &WidthProfile, + span: (f64, f64), + density: f64, +) -> (Vec, Vec, Vec) { + // Two samples per profile segment: the segment end plus its midpoint. The outline is fitted + // with cubics through these points, so a smooth width curve needs far fewer samples than it + // did when every pair of samples became a separate cubic. A profile with a strong bend gets + // one more subdivision; a flat profile draws the same straight pieces as before. + const SAMPLES: usize = 2; + const MAX_SAMPLES: usize = 8; + // The gap (in width factor) the straight line between two samples may leave: small enough + // that a sharp peak like the lens' needs one more piece, large enough that a long taper's + // gentle bend does not. + const BEND: f64 = 0.05; + let total = length(&flat.pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { - return (pts.to_vec(), corner.to_vec()); + return split_at(flat, closed, &[], segs); } - let along = |t: f64| (t - span.0) / range * total; // Where the profile's points fall along this piece (a span past 1 wraps round a closed path). - let mut marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect(); - let mut added = 0; - for shift in [0.0, 1.0] { - for w in profile.points.windows(2) { - let (a, b) = (&w[0], &w[1]); - let (t0, t1) = (a.0 + shift, b.0 + shift); - if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 { - continue; - } - // A span whose curve is the straight line between its points (a two-point taper, - // points on one line) needs nothing between them. A cubic off the line is off it at a - // quarter or three quarters of the way. - let off_line = |u: f64| { - let (l, r) = profile.at(a.0 + (b.0 - a.0) * u); - half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs()) - }; - if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 { - continue; - } - // A monotone cubic span bends by at most 12 times its change in width over the span - // squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it. - let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs()); - let n = (1.5 * change / tol).sqrt().ceil(); - let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES }; - for k in 1..n { - if added >= MAX_CURVE_SAMPLES { - break; - } - marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64)); - added += 1; - } + let mut ts: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).collect(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); + // A flat profile draws the same straight pieces as before: sample only its points. + let constant = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12)); + let cap = (4096 / ts.len().max(1)).max(1); + // A curved path's flattened points already sample the width along the subpath. On a dense + // piece the profile's own points are enough; a straight run has only its ends, so it needs + // the extra samples. This keeps curved, profiled art close to a uniform stroke's size. + let mut marks = Vec::with_capacity(ts.len() * SAMPLES); + for w in ts.windows(2) { + let (a, b) = (w[0], w[1]); + let samples = if constant { + 1 + } else { + // `ts` has the profile both at t and t + 1 for a dash crossing a closed seam; the + // shifted copy has the same shape, so measure its bend on the unshifted segment. + let start = a.rem_euclid(1.0); + let end = start + (b - a); + let want = adaptive_samples(profile, start, end, SAMPLES, MAX_SAMPLES, BEND).min(cap); + // How many of the flattened path's points already fall in this profile segment. + // `density` is measured on the whole subpath, not on this piece, so outlining only + // the piece near the view picks the same samples as outlining the whole subpath. + let existing = (b - a) * density; + if existing >= want as f64 { 1 } else { want } + }; + for k in 0..samples { + marks.push(a + (b - a) * k as f64 / samples as f64); } } - split_at(pts, corner, closed, &marks) + if let Some(&last) = ts.last() { + marks.push(last); + } + let marks: Vec = marks.into_iter().map(|t| (t - span.0) / range * total).collect(); + split_at(flat, closed, &marks, segs) +} + +/// How many pieces one profile segment needs so a cubic fit through their ends keeps the width +/// curve. A straight taper is already straight; a visible bend gets progressively more samples. +fn adaptive_samples(profile: &WidthProfile, t0: f64, t1: f64, base: usize, max: usize, bend: f64) -> usize { + let mut samples = base.max(1); + while samples < max { + // One probe in the middle of each piece: how far the curve leaves the straight line + // between the piece's ends. The more pieces, the closer the line follows the curve, so + // the loop only stops when the pieces it has are fine enough (or at `max`). + let mut dev = 0.0_f64; + for k in 0..samples { + let (a, b) = (k as f64 / samples as f64, (k + 1) as f64 / samples as f64); + let (la, ra) = profile.at(t0 + (t1 - t0) * a); + let (lb, rb) = profile.at(t0 + (t1 - t0) * b); + let (lm, rm) = profile.at(t0 + (t1 - t0) * (a + b) / 2.0); + dev = dev.max((lm - (la + lb) / 2.0).abs()).max((rm - (ra + rb) / 2.0).abs()); + } + if dev <= bend { + break; + } + samples *= 2; + } + samples } /// Length of a flattened subpath (see [`flatten`]). @@ -212,31 +310,70 @@ pub(super) fn length(pts: &[Point], closed: bool) -> f64 { /// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its /// start; those on a point or off the subpath are ignored). -pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f64]) -> (Vec, Vec) { +pub(super) fn split_at(flat: &Flat, closed: bool, marks: &[f64], segs: &[PathSeg]) -> (Vec, Vec, Vec) { + let Flat { pts, corner, seg_of, param } = flat; let n = pts.len(); let seg_count = if closed { n } else { n.saturating_sub(1) }; - let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); + // The source's direction at a source parameter. The chord direction is the secant, not the + // tangent: using it for points added inside a chord jogs the normal at every flatten vertex. + let source_at = |si: usize, t: f64| -> Vec2 { segs.get(si).map_or(Vec2::new(1.0, 0.0), |seg| tangent(seg, t)) }; + // A point added inside a flattened piece searches only that piece's source parameter range: + // a looping cubic can pass near itself, and a full-segment nearest would take the other + // branch's tangent. + let source_near = |p: Point, i: usize| -> Vec2 { + let Some(si) = seg_of.get(i).copied() else { return Vec2::new(1.0, 0.0) }; + let Some(seg) = segs.get(si) else { return Vec2::new(1.0, 0.0) }; + let (t0, t1) = param.get(i).copied().unwrap_or((0.0, 1.0)); + if t1 - t0 > 1e-12 { + let sub = seg.subsegment(t0..t1); + tangent(seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t) + } else { + tangent(seg, t0) + } + }; + // At a shared point, average the arriving and leaving directions unless it is a corner. + let dir_at = |i: usize| -> Vec2 { + let incoming = if i == 0 { + if closed { seg_of.last().copied().zip(param.last().copied()) } else { None } + } else { + seg_of.get(i - 1).copied().zip(param.get(i - 1).copied()) + }; + let outgoing = seg_of.get(i).copied().zip(param.get(i).copied()); + let a = incoming.map(|(si, (_, t))| source_at(si, t)); + let b = outgoing.map(|(si, (t, _))| source_at(si, t)); + match (a, b) { + (Some(a), Some(b)) if !corner[i] => { + let sum = a + b; + if sum.hypot() > 1e-9 { unit(sum) } else { b } + } + (_, Some(b)) => b, + (Some(a), None) => a, + (None, None) => Vec2::new(1.0, 0.0), + } + }; + let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); let mut acc = 0.0; - // Sorted once, so each segment finds its marks by search, not by a pass over all of them. - let mut marks = marks.to_vec(); - marks.sort_by(f64::total_cmp); for i in 0..seg_count { let (a, b) = (pts[i], pts[(i + 1) % n]); let len = a.distance(b); out.0.push(a); out.1.push(corner[i]); - let from = marks.partition_point(|d| *d <= acc + 1e-9); - let mut inner: Vec = marks.iter().skip(from).copied().take_while(|d| *d < acc + len - 1e-9).collect(); + out.2.push(dir_at(i)); + let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); + inner.sort_by(f64::total_cmp); inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { - out.0.push(a.lerp(b, (d - acc) / len)); + let p = a.lerp(b, (d - acc) / len); + out.0.push(p); out.1.push(false); + out.2.push(source_near(p, i)); } acc += len; } if !closed && n > 0 { out.0.push(pts[n - 1]); out.1.push(corner[n - 1]); + out.2.push(dir_at(n - 1)); } out } @@ -249,6 +386,8 @@ struct Ctx<'a> { join: LineJoin, miter_limit: f64, tol: f64, + /// The profile has one (left, right) width everywhere. + uniform: bool, } /// A vertex of a flattened subpath: where it is, the directions in and out, the lengths of the @@ -257,14 +396,31 @@ struct Vertex { p: Point, a: Vec2, b: Vec2, + /// The smooth tangent: the source curve's direction at this sample, or the average of its + /// arriving and leaving directions at a smooth join. A smooth point offsets along this + /// normal, not the chord bisector; otherwise the chord normal inside each flattened piece and + /// the bisector at each flatten vertex alternate and the edge is scalloped. + n: Vec2, la: f64, lb: f64, corner: bool, } -impl Ctx<'_> { - fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) { - let (pts, corner) = densify(pts, corner, closed, self.profile, span, self.half, self.tol); +impl<'a> Ctx<'a> { + fn new(width: f64, profile: &'a WidthProfile, st: &StrokeLayer, tol: f64) -> Self { + Self { + half: width / 2.0, + profile, + cap: st.cap, + join: st.join, + miter_limit: st.miter_limit, + tol: tol.max(1e-4), + uniform: profile.points.first().is_some_and(|f| profile.points.iter().all(|p| p.1 == f.1 && p.2 == f.2)), + } + } + + fn subpath(&self, out: &mut BezPath, flat: &Flat, segs: &[PathSeg], closed: bool, span: (f64, f64), density: f64) { + let (pts, corner, dirs) = densify(flat, segs, closed, self.profile, span, density); let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let seg_dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -280,117 +436,398 @@ impl Ctx<'_> { // at a discontinuous point). let width_at = |d: f64| { let t = span.0 + (span.1 - span.0) * d / total; - let (b, a) = self.profile.around(if t > 1.0 { t - 1.0 } else { t }); + // A closed path joins its own start and end: a profile with different widths there + // is a step, not a stretch across the seam. + let (b, a) = if closed && span == (0.0, 1.0) && d <= 1e-12 { + (self.profile.around(1.0).0, self.profile.around(0.0).1) + } else { + self.profile.around(if t > 1.0 { t - 1.0 } else { t }) + }; let side = |(l, r): (f64, f64)| (self.half * l.max(0.0), self.half * r.max(0.0)); (side(b), side(a)) }; - let mut lpts = Vec::with_capacity(n); - let mut rpts = Vec::with_capacity(n); + let mut lpts: Vec<(Point, bool)> = Vec::with_capacity(n); + let mut rpts: Vec<(Point, bool)> = Vec::with_capacity(n); + // Only a butt cap makes the side end at a corner; square and round caps stay tangent to it. + let end_sharp = matches!(self.cap, LineCap::Butt); for (i, &p) in pts.iter().enumerate() { - let (before, after) = width_at(cum[i]); + let ((wl_b, wr_b), (wl_a, wr_a)) = width_at(cum[i]); if !closed && (i == 0 || i == n - 1) { - let nm = left(seg_dir[i.min(seg_count - 1)]); - let (wl, wr) = if i == 0 { after } else { before }; - lpts.push(p + nm * wl); - rpts.push(p - nm * wr); + let nm = left(dirs[i]); + let (wl, wr) = if i == 0 { (wl_a, wr_a) } else { (wl_b, wr_b) }; + lpts.push((p + nm * wl, end_sharp)); + rpts.push((p - nm * wr, end_sharp)); continue; } let ip = if i == 0 { seg_count - 1 } else { i - 1 }; - let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; + let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], n: dirs[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; // A step: the side goes straight out (or in) from the width before to the one after. - for (k, (wl, wr)) in [before, after].into_iter().enumerate() { - if k == 0 || (wl, wr) != before { - self.join(&mut lpts, &v, wl, 1.0); - self.join(&mut rpts, &v, wr, -1.0); - } + // Both ends of that straight piece are corners: the run before it must not round + // into the step. + let (step_l, step_r) = (wl_a != wl_b, wr_a != wr_b); + self.join(&mut lpts, &v, wl_b, 1.0, step_l); + self.join(&mut rpts, &v, wr_b, -1.0, step_r); + if step_l { + self.join(&mut lpts, &v, wl_a, 1.0, true); + } + if step_r { + self.join(&mut rpts, &v, wr_a, -1.0, true); } } if closed { - polygon(out, lpts.into_iter()); - polygon(out, rpts.into_iter().rev()); + polygon(out, lpts.into_iter(), self.tol); + polygon(out, rpts.into_iter().rev(), self.tol); return; } - let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); + let (t0, t1) = (dirs[0], dirs[n - 1]); let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0); let mut ring = lpts; // End cap: from the left side around the end to the right side. - cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap); + cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap, self.tol); ring.extend(rpts.into_iter().rev()); // Start cap: from the right side around the start back to the left side. - cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap); - polygon(out, ring.into_iter()); + cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap, self.tol); + polygon(out, ring.into_iter(), self.tol); } - /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path: the - /// join on the outer side of a turn, the inner miter point (or a detour through the vertex - /// when that would overshoot a segment) on the inner side. - fn join(&self, pts: &mut Vec, v: &Vertex, w: f64, side: f64) { + /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path. A + /// smooth point follows the vertex normal, the offset of the curve itself; a corner takes the + /// join on its outer side and the inner miter point (or a detour through the vertex when that + /// would overshoot a segment) on its inner side. + /// + /// Each point says whether the outline is sharp there: a path corner, a miter tip, a width + /// step or a butt cap end. Only those break the smooth fitting; a rapid width change makes + /// the offset turn sharply, but it is not a corner and must not be fitted as one. + fn join(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, w: f64, side: f64, step: bool) { let (na, nb) = (left(v.a) * side, left(v.b) * side); let mid = na + nb; // Cosine of half the turn; the miter point is `w / c` out along `mid`. let c = mid.hypot() / 2.0; - let miter = |pts: &mut Vec| pts.push(v.p + mid * (w / (2.0 * c * c))); if w <= 0.0 { - pts.push(v.p); - } else if v.b.dot(na) <= 0.0 { + pts.push((v.p, true)); + return; + } + let miter = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c * c))) } else { None }; + // The offset of the smooth path itself is at `w` along the path normal. The polyline + // miter (`w / c`) is the offset of the flattened segments, not of the curve: at a tight + // curve it spiked every sample outward and left a scalloped edge. The source curve's + // tangent is the smooth normal; a corner still uses its two chords for the join. + let smooth = v.p + left(v.n) * (w * side); + if v.b.dot(na) <= 0.0 { // Outer side: the path turns away from it. - let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; - match self.join { - _ if excess <= self.tol => miter(pts), - LineJoin::Miter if v.corner && 1.0 / c <= self.miter_limit => miter(pts), - LineJoin::Miter | LineJoin::Bevel if v.corner => pts.extend([v.p + na * w, v.p + nb * w]), - _ => self.arc(pts, v, na, nb, w), + if !v.corner { + // A smooth point has no join: its offset is along the path normal. + pts.push((smooth, step)); + } else { + let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; + match self.join { + _ if excess <= self.tol => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter if c > 1e-9 && 1.0 / c <= self.miter_limit => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter | LineJoin::Bevel => pts.extend([(v.p + na * w, true), (v.p + nb * w, true)]), + _ => self.arc(pts, v, na, nb, w, step), + } } - } else if c > 1e-9 && w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { - miter(pts); + } else if c <= 1e-9 { + // A reversal has no inner miter; go through the vertex. That is a notch in the + // outline, so it stays sharp: fitting a curve through it would loop. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); + } else if !v.corner { + // A smooth point's inner offset follows its normal too. With a constant profile, once + // the half-width reaches the local radius of curvature the offset crosses the centre of + // curvature and comes back as a reversed contour, which the non-zero fill rules into a + // hole. The stroke covers that centre, so cut through it instead: the cut is inside the + // painted region and does not show. (With varying widths the crossing usually marks a + // real caustic; a cut there would show as a slit, so leave it to the offset.) + let turn = 2.0 * c.clamp(-1.0, 1.0).acos(); + let radius = if turn > 1e-9 { 0.5 * (v.la + v.lb) / turn } else { f64::INFINITY }; + if self.uniform && w >= radius { + pts.push((smooth, true)); + pts.push((v.p, true)); + pts.push((smooth, true)); + } else { + pts.push((smooth, step)); + } + } else if w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { + // A corner's inner offset is its miter while that stays inside both segments. + pts.push((miter.unwrap_or(v.p), true)); } else { - pts.extend([v.p + na * w, v.p, v.p + nb * w]); + // The inner offset of a corner cuts through the vertex; keep that notch sharp. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); } } /// A round join from `na` to `nb` (unit normals) at radius `w` around `v`; a full reversal - /// rounds through the direction of travel. - fn arc(&self, pts: &mut Vec, v: &Vertex, na: Vec2, nb: Vec2, w: f64) { + /// rounds through the direction of travel. `sharp` marks a step at the join's ends. + fn arc(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, na: Vec2, nb: Vec2, w: f64, sharp: bool) { let phi = if (na + nb).hypot() < 1e-9 { if Vec2::new(-na.y, na.x).dot(v.a) > 0.0 { PI } else { -PI } } else { na.cross(nb).atan2(na.dot(nb)) }; let step = 2.0 * (1.0 - (self.tol / w).min(1.0)).acos(); let steps = ((phi.abs() / step.max(1e-3)).ceil() as usize).clamp(1, 256); for k in 0..=steps { let (s, c) = (phi * k as f64 / steps as f64).sin_cos(); - pts.push(v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w); + pts.push((v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w, sharp)); } } } /// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`. -fn cap_points(ring: &mut Vec, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap) { +#[allow(clippy::too_many_arguments)] +fn cap_points(ring: &mut Vec<(Point, bool)>, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap, tol: f64) { match cap { LineCap::Butt => {} LineCap::Square => { let h = (r0 + r1) / 2.0; - ring.push(c + u * r0 + v * h); - ring.push(c - u * r1 + v * h); + ring.push((c + u * r0 + v * h, true)); + ring.push((c - u * r1 + v * h, true)); } LineCap::Round => { - let steps = 16; + // Enough steps that the cap's facets stay within `tol`, as a round join does. + let r = r0.max(r1).max(1e-9); + let step = 2.0 * (1.0 - (tol / r).min(1.0)).acos(); + let steps = ((PI / step.max(1e-6)).ceil() as usize).clamp(8, 256); for k in 1..steps { - let a = std::f64::consts::PI * k as f64 / steps as f64; + let a = PI * k as f64 / steps as f64; let r = r0 + (r1 - r0) * (k as f64 / steps as f64); - ring.push(c + u * (a.cos() * r) + v * (a.sin() * r)); + ring.push((c + u * (a.cos() * r) + v * (a.sin() * r), false)); } } } } -fn polygon(out: &mut BezPath, mut pts: impl Iterator) { - let Some(first) = pts.next() else { return }; - out.move_to(first); - for p in pts { - out.line_to(p); +/// Append `pts` as a closed contour (a width outline's ring or loop), through a smooth run's +/// points as cubic curves. Sampled as line segments, a curved, wide stroke shows the facets of +/// its offset whenever it is magnified; the cubics keep it smooth. A run whose points are +/// collinear stays a line, and the points marked sharp — caps, miter joins, width steps and path +/// corners — stay sharp. A rapid width change turns the offset sharply without being a corner: +/// marking corners here rather than measuring turns keeps that turn smooth. +fn polygon(out: &mut BezPath, pts: impl Iterator, tol: f64) { + let mut ring: Vec<(Point, bool)> = Vec::new(); + for (p, sharp) in pts { + if !p.x.is_finite() || !p.y.is_finite() { + return; + } + if let Some(last) = ring.last_mut() + && last.0.distance(p) <= 1e-9 + { + last.1 |= sharp; + continue; + } + ring.push((p, sharp)); + } + if ring.len() > 1 + && ring[0].0.distance(ring[ring.len() - 1].0) <= 1e-9 + && let Some(last) = ring.pop() + { + ring[0].1 |= last.1; + } + let n = ring.len(); + if n == 0 { + return; + } + if n < 3 { + out.move_to(ring[0].0); + if n == 2 { + out.line_to(ring[1].0); + } + out.close_path(); + return; + } + // Start at a sharp point when there is one, so no run has to wrap: `smooth_run` starts from + // whichever point is current, and starting anywhere else would bridge over the skipped arc. + match ring.iter().position(|&(_, sharp)| sharp) { + None => { + // A smooth closed loop: fit across the seam, so it doesn't start with a kink. + let pts: Vec = ring.iter().map(|&(p, _)| p).collect(); + out.move_to(pts[0]); + smooth_run(out, &pts, true, tol); + } + Some(first) => { + out.move_to(ring[first].0); + // From each sharp point to the next; the sharp points themselves stay sharp. + let mut i = first; + loop { + let mut j = (i + 1) % n; + while !ring[j].1 { + j = (j + 1) % n; + } + // A contour with a single sharp point comes back to it: the run is the whole + // loop, fitted non-cyclically so that point stays sharp. + let steps = if j == i { n } else { (j + n - i) % n }; + if steps == 1 { + out.line_to(ring[j].0); + } else { + let run: Vec = (0..=steps).map(|k| ring[(i + k) % n].0).collect(); + smooth_run(out, &run, false, tol); + } + if j == first { + break; + } + i = j; + } + } } out.close_path(); } +/// The derivative of the run at each point, in points per point of chord length. Each coordinate +/// is a shape-preserving cubic Hermite (Fritsch–Carlson): between two points it stays within their +/// coordinate range, so a cubic through a fast width pinch cannot bulge past the pinch and cross +/// the other side, and it has a flat slope at a local extremum. A Catmull–Rom tangent (the secant +/// across the neighbours) does neither: it overshoots a V and, with the uneven sample spacing of a +/// flattened path, leaves a scalloped edge. +fn tangents(run: &[Point], cyclic: bool) -> Vec { + let n = run.len(); + let segs = if cyclic { n } else { n.saturating_sub(1) }; + let mut h = vec![0.0; segs]; + for (i, hi) in h.iter_mut().enumerate() { + *hi = run[i].distance(run[(i + 1) % n]); + } + let slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 { + return 0.0; + } + let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0); + let m = (w0 + w1) / (w0 / d0 + w1 / d1); + if m.is_finite() { m } else { 0.0 } + }; + // The one-sided slope of a run's end uses its two end secants, instead of only the last one. + // A coarse sample then still leaves the end of a profile curve with the right slope. + let end_slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 || h0 + h1 <= 1e-12 { + return 0.0; + } + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if m.is_finite() && m * d0 > 0.0 { + let cap = 3.0 * d0.abs(); + m.clamp(-cap, cap) + } else { + 0.0 + } + }; + let delta = |i: usize| -> Vec2 { + let hi = h[i % segs]; + if hi > 1e-12 { (run[(i + 1) % n] - run[i % n]) / hi } else { Vec2::ZERO } + }; + let mut out = vec![Vec2::ZERO; n]; + for (i, m) in out.iter_mut().enumerate() { + let (d0, d1) = (delta((i + n - 1) % n), delta(i % segs)); + let (h0, h1) = (h[(i + n - 1) % segs], h[i % segs]); + if h0 <= 1e-12 || h1 <= 1e-12 { + *m = if h1 > 1e-12 { d1 } else { d0 }; + } else if cyclic || (i > 0 && i + 1 < n) { + *m = Vec2::new(slope(d0.x, d1.x, h0, h1), slope(d0.y, d1.y, h0, h1)); + } else if i == 0 { + *m = if n >= 3 { + let (a, b) = (delta(0), delta(1)); + Vec2::new(end_slope(a.x, b.x, h[0], h[1]), end_slope(a.y, b.y, h[0], h[1])) + } else { + d1 + }; + } else { + *m = if n >= 3 { + let (a, b) = (delta(n - 2), delta(n - 3)); + Vec2::new(end_slope(a.x, b.x, h[n - 2], h[n - 3]), end_slope(a.y, b.y, h[n - 2], h[n - 3])) + } else { + d0 + }; + } + // A shape-preserving slope is flat in a coordinate at that coordinate's local extremum. + // Where both are extrema at one sample (a loop or a pinch in the sampled edge) the flat + // tangent would make a line and a kink; the secant across the sample keeps the curve C1 + // there, and the extremum itself is what trims the loop later. + if m.hypot() <= 1e-12 { + let prev = if cyclic || i > 0 { run[(i + n - 1) % n] } else { run[i] }; + let next = if cyclic || i + 1 < n { run[(i + 1) % n] } else { run[i] }; + let sec = next - prev; + if sec.hypot() > 1e-12 { + *m = sec.normalize(); + } + } + } + out +} + +/// Append a run of points as a fitted chain of cubics (or one line when they are collinear). +/// +/// A cubic through every flattened point, as this used to emit, keeps the outline smooth but puts +/// three coordinates per point into the file; a small, thin stroke then exports several times the +/// path data of the same stroke without a profile. Fit a chain within the outline tolerance +/// instead: the result is still G1 continuous and stays within `tol` of the sampled edge, but a +/// smooth side is a few cubics rather than one per flatten step. Profile bends that need finer +/// detail produce more cubics; straight runs stay lines. +fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { + let n = run.len(); + if n < 2 { + return; + } + if !cyclic && is_straight(run, tol) { + out.line_to(run[n - 1]); + return; + } + let tans = tangents(run, cyclic); + let tol = tol.max(1e-6); + // A short run is one or two profile features, not flattened path detail. Fitting it loosely + // enough to save path data can let a handle cross the narrow side of a V; keep the + // shape-preserving per-point cubics there. + let segs = if cyclic { n } else { n - 1 }; + if n <= 3 { + for i in 0..segs { + let j = (i + 1) % n; + let h = run[i].distance(run[j]); + let (m0, m1) = (tans[i] * (h / 3.0), tans[j] * (h / 3.0)); + if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { + out.line_to(run[j]); + } else { + out.curve_to(run[i] + m0, run[j] - m1, run[j]); + } + } + return; + } + let unit_or = |v: Vec2, fallback: Vec2| { + let len = v.hypot(); + if len > 1e-12 { + v / len + } else { + let len = fallback.hypot(); + if len > 1e-12 { fallback / len } else { Vec2::new(1.0, 0.0) } + } + }; + let t0 = unit_or(tans[0], run[1] - run[0]); + let (pts, t0, t1) = if cyclic { + // Fit the seam too: the fit starts and arrives with the same tangent, so closing the + // contour adds no kink. + let mut pts = Vec::with_capacity(n + 1); + pts.extend_from_slice(run); + pts.push(run[0]); + (pts, t0, t0) + } else { + (run.to_vec(), t0, unit_or(tans[n - 1], run[n - 1] - run[n - 2])) + }; + // Fit tighter than the flattening tolerance: the fit's error is the whole outline's error, + // and a least-squares fit may sit on one side of the sampled edge, which would otherwise + // shift a profile's area visibly. The points lie on the outline's own curve, so the fit follows + // their curve between them rather than the chords through them. + let cubics = fit_cubics_through(&pts, t0, t1, tol * 0.1); + if cubics.is_empty() { + out.line_to(run[n - 1]); + return; + } + for c in cubics { + out.curve_to(c.p1, c.p2, c.p3); + } +} + +/// Are the run's points within the flattening tolerance of its chord? +fn is_straight(run: &[Point], tol: f64) -> bool { + let (a, b) = (run[0], run[run.len() - 1]); + let d = b - a; + let l = d.hypot(); + if l <= 1e-12 { + return false; + } + run.iter().all(|p| (d.cross(*p - a) / l).abs() <= tol.max(1e-6) * 0.25) +} + #[cfg(test)] mod tests { use super::*; @@ -423,13 +860,12 @@ mod tests { assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } - /// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side. + /// The lens eases up to full width and back down (a parabolic bump averaging two thirds of + /// the width); a one-sided profile keeps its side at zero and stays on the other. #[test] - fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() { - // Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it - // covers 7/12 of the full-width band (a diamond would cover half). + fn lens_profile_is_a_smooth_bump_and_one_sided_profiles_stay_on_their_side() { let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); - assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{a}"); + assert!((a - 2000.0 / 3.0).abs() < 0.1, "{a}"); let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01); assert!((area(&o) - 500.0).abs() < 1e-6); @@ -453,41 +889,103 @@ mod tests { assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); } - /// #5714: between width points a straight run follows the profile's smooth curve, not a - /// straight line from one width to the next. #[test] - fn a_straight_run_follows_the_curve_between_width_points() { - // 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve - // is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt). - let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] }; - let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01); - for y in [-11.0, 11.0] { - assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}"); - } - for y in [-11.5, 11.5] { - assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}"); - } - // Round the widest point the side is flat: no corner sticks out above 15 pt. - let bb = o.bounding_box(); - assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}"); + fn a_closed_path_steps_where_its_profile_ends_differ() { + let mut sq = BezPath::new(); + sq.move_to((0.0, 0.0)); + sq.line_to((100.0, 0.0)); + sq.line_to((100.0, 100.0)); + sq.line_to((0.0, 100.0)); + sq.close_path(); + // 5 pt at the seam's start, 10 pt at its end: the ring must step between them, not + // stretch the last width point round the corner. + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 2.0, 2.0)] }; + let o = width_outline(&sq, 10.0, &p, &capped(LineCap::Butt), 0.01); + let at = |p: Point, x: f64, y: f64| (p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9; + let step = o.elements().windows(2).any(|w| match (w[0], w[1]) { + (PathEl::MoveTo(a), PathEl::LineTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => { + at(a, -10.0, -10.0) && at(b, -5.0, -5.0) || at(a, -5.0, -5.0) && at(b, -10.0, -10.0) + } + _ => false, + }); + assert!(step, "{o:?}"); } - /// A taper is a straight ramp: its outline gets no samples between the width points. #[test] - fn a_straight_taper_gets_no_samples_between_its_points() { - let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01); - assert_eq!(o.elements().len(), 5, "{:?}", o.elements()); + fn added_samples_follow_the_curve_not_the_flattened_chord() { + // A cubic with a tight S: its chords are far from its tangents. A sample inserted inside a + // chord must take the curve's direction there; the chord's direction is what made a wide + // profile scallop at every flatten vertex. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 120.0), (120.0, -120.0), (120.0, 0.0)); + let segs = segments(b.elements()); + let flat = flatten(&segs, false, 2.0); + assert!(flat.pts.len() >= 3); + let mut marks = vec![]; + let mut acc = 0.0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + marks.push(acc + len / 2.0); + acc += len; + } + let (all, _, dirs) = split_at(&flat, false, &marks, &segs); + assert!(all.len() > flat.pts.len()); + let seg = segs[0]; + for (p, d) in all.iter().zip(&dirs) { + let want = tangent(&seg, seg.nearest(*p, 1e-9).t); + assert!(d.dot(want) > 0.999, "{d:?} vs {want:?} at {p:?}"); + } } - /// A profile read from a file may have thousands of points and absurd widths: the samples added - /// between them stay bounded and the outline finite. #[test] - fn samples_between_width_points_are_capped() { - let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect(); - let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01); - let n = o.elements().len(); - assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}"); - assert!(o.bounding_box().area().is_finite()); + fn a_wiggly_profile_on_a_curve_fits_without_kinks() { + // A rapid width change turns the offset sharply; that is not a path corner, so the + // outline must stay tangent-continuous through it (the old turn-angle test did not). + let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-3); + let p = WidthProfile { + points: vec![(0.0, 1.0, 1.0), (0.15, 3.0, 0.4), (0.3, 0.2, 2.8), (0.5, 2.5, 0.3), (0.7, 0.4, 2.2), (0.85, 2.0, 0.5), (1.0, 1.0, 1.0)], + }; + let o = width_outline(&c, 10.0, &p, &capped(LineCap::Butt), 0.01); + let mut cursor = None; + let mut prev: Option = None; + let mut kinks = 0; + let mut check = |a: Vec2, b: Vec2| { + let (la, lb) = (a.hypot(), b.hypot()); + if la > 1e-9 && lb > 1e-9 && (a.dot(b) <= 0.0 || a.cross(b).abs() / (la * lb) > 1e-6) { + kinks += 1; + } + }; + for el in o.elements() { + match el { + PathEl::MoveTo(p) => { + cursor = Some(*p); + prev = None; + } + PathEl::LineTo(p) => { + if let Some(q) = cursor { + let d = *p - q; + if let Some(a) = prev { + check(a, d); + } + prev = Some(d); + } + cursor = Some(*p); + } + PathEl::CurveTo(a, b, p) => { + if let Some(q) = cursor { + let d = if a.distance(q) > 1e-9 { *a - q } else { *b - q }; + if let Some(prev) = prev { + check(prev, d); + } + prev = Some(if p.distance(*b) > 1e-9 { *p - *b } else { *p - *a }); + } + cursor = Some(*p); + } + PathEl::QuadTo(_, _) | PathEl::ClosePath => {} + } + } + assert_eq!(kinks, 0, "{o:?}"); } #[test] @@ -512,4 +1010,108 @@ mod tests { let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); } + + #[test] + fn a_uniform_circle_narrower_than_its_stroke_has_no_hole() { + // Half-width 15 on a radius-5 circle: the inner offset crosses the centre. The constant + // stroke still covers the centre, so the outline must not leave a hole there. + let c = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(1e-3); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let o = width_outline(&c, 30.0, &p, &capped(LineCap::Butt), 0.01); + assert!(o.contains(Point::new(0.0, 0.0)), "{o:?}"); + } + + #[test] + fn a_quick_width_change_does_not_overshoot_its_samples() { + // Width falls to a point and comes back, unevenly on either side: the fitted edge must not + // dip past the point (that crossed the other edge and left a scalloped neck). + let mut run = vec![Point::new(0.0, 0.0), Point::new(10.0, -1.0), Point::new(20.0, -0.3)]; + let (mut a, mut b) = (BezPath::new(), BezPath::new()); + a.move_to(run[0]); + b.move_to(run[0]); + smooth_run(&mut a, &run, false, 0.0); + // Reverse the same points: the fit must stay inside the V from either direction too. + run.reverse(); + smooth_run(&mut b, &run, false, 0.0); + for path in [a, b] { + let mut start = Point::ZERO; + for el in path.elements() { + match el { + PathEl::MoveTo(p) => start = *p, + PathEl::CurveTo(c1, c2, p) => { + for c in [c1, c2] { + assert!(c.y >= -1.0 - 1e-9, "{c:?} in {path:?}"); + assert!(c.y <= start.y.max(p.y) + 1e-9, "{c:?} in {path:?}"); + } + start = *p; + } + _ => {} + } + } + } + } + + #[test] + fn added_samples_stay_on_their_flattened_branch() { + // This cubic loops back near itself. At a coarse tolerance a chord midpoint can be closer + // to the other branch; a full-segment nearest would then take that branch's tangent and + // spike the offset. Each inserted sample must use the parameter range of its own piece. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((150.0, 100.0), (-50.0, 100.0), (100.0, 0.0)); + let segs = segments(b.elements()); + let seg = segs[0]; + let flat = flatten(&segs, false, 2.0); + let mut acc = 0.0; + let mut divergent = 0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + if len <= 1e-12 { + continue; + } + let (t0, t1) = flat.param[i]; + let sub = seg.subsegment(t0..t1); + for k in 1..16 { + let (out, _, dirs) = split_at(&flat, false, &[acc + len * k as f64 / 16.0], &segs); + assert!(out.len() >= i + 2, "no sample inserted in piece {i}"); + let p = out[i + 1]; + let want = tangent(&seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t); + assert!(dirs[i + 1].dot(want) > 0.999, "piece {i}, k {k}: {:?} vs {want:?}", dirs[i + 1]); + let full = tangent(&seg, seg.nearest(p, 1e-9).t); + if full.dot(want) < 0.9 { + divergent += 1; + } + } + acc += len; + } + assert!(divergent > 0, "the fixture did not cross the other branch"); + } + + #[test] + fn a_coarse_wide_circle_keeps_its_offset() { + // A wide stroke flattened coarsely: offsetting each flattened segment by its miter + // (`w / cos(θ/2)`) scalloped the circle outward at every sample. Smooth points follow the + // curve's normal instead, so the ring is the annulus between radii 10 and 30. + let c = kurbo::Circle::new((0.0, 0.0), 20.0).to_path(0.5); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let a = area(&width_outline(&c, 20.0, &p, &capped(LineCap::Butt), 0.5)); + let want = std::f64::consts::PI * (30.0 * 30.0 - 10.0 * 10.0); + assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); + } + + #[test] + fn profiled_outlines_stay_compact() { + // A smooth profile is fitted with cubics, so it must not turn every profile sample into + // exported path data. Compare it with the same curved path's uniform outline. + let circle = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(0.01); + let count = |points| { + let p = WidthProfile { points }; + width_outline(&circle, 5.0, &p, &capped(LineCap::Butt), 0.01).elements().len() + }; + let uniform = count(vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)]); + let lens = count(WidthProfile::lens().points); + let wave = count(vec![(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)]); + assert!(lens <= uniform * 3 / 2, "lens {lens} vs uniform {uniform}"); + assert!(wave <= uniform * 3 / 2, "wave {wave} vs uniform {uniform}"); + } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 836d4b7448..0fccbcdecb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -67,6 +67,11 @@ log = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["std", "process"] } +# Whether a recovery area's lock file is still the one that was opened (`FolderStore::still_named`). +# Already in Cargo.lock (walkdir), so this adds no new crate. +[target.'cfg(windows)'.dependencies] +same-file = "1.0.6" + # 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.4", default-features = false, features = ["std", "parallel"], optional = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b8993..b8c1f82e50 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -749,6 +749,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; +pub(crate) use addons::DecodedImages; pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 324d7bde58..4c4671f6b0 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -6,12 +6,15 @@ mod cleanup; mod color; mod contour; mod document; +mod image; mod pathtools; mod pen; mod select; mod shapes; mod typetools; +pub(crate) use image::DecodedImages; + use super::CommandSpec; /// The commands of every built-in add-on. @@ -21,6 +24,7 @@ pub fn specs() -> Vec { v.extend(color::specs()); v.extend(contour::specs()); v.extend(document::specs()); + v.extend(image::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); v.extend(select::specs()); diff --git a/crates/engine/src/cmd/addons/document/batch_trace.rs b/crates/engine/src/cmd/addons/document/batch_trace.rs index 61e59fc54f..084bd5ca27 100644 --- a/crates/engine/src/cmd/addons/document/batch_trace.rs +++ b/crates/engine/src/cmd/addons/document/batch_trace.rs @@ -113,7 +113,8 @@ pub(super) fn batch_trace_folder(s: &mut Session, p: &Value) -> Result { for path in &files { let opened_before = s.documents().len(); let one = |s: &mut Session| -> Result { - s.execute("document.open", &json!({ "path": path }))?; + // A PSD or PSB is traced as its merged image, not as the first of its layers. + s.execute("document.open", &json!({ "path": path, "layers": false }))?; s.execute("select.all", &json!({}))?; s.execute(trace, &json!({ "preset": preset }))?; let stem = fileio::file_stem(path); diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs index b0fbb41422..43e86caf52 100644 --- a/crates/engine/src/cmd/addons/document/tests.rs +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -333,6 +333,34 @@ fn batch_trace_folder_traces_and_saves_each_image() { let _ = std::fs::remove_dir_all(&dir); } +/// A layered PSD is traced as its merged image: a white layer under a black square gives the +/// square's shape, not a trace of the white layer alone. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_trace_folder_traces_a_layered_psd_as_its_merged_image() { + const C: &str = "addon.document.batchTraceFolder"; + let dir = folder("batch-trace-psd"); + let (src, out) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 24, "height": 24})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 24, "height": 24})).unwrap(); + s.execute("layer.new", &json!({"name": "Square"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 6, "y": 6, "width": 12, "height": 12})).unwrap(); + let psd = s.execute("document.export", &json!({"format": "psd"})).unwrap(); + std::fs::write(src.join("two.psd"), vectorcraft_format::base64_decode(psd["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + let r = + s.execute(C, &json!({"folder": src, "destinationFolder": out, "format": "svg", "preset": "Black and White Logo", "expand": true})).unwrap(); + assert_eq!(r["traced"].as_array().unwrap().len(), 1, "{r}"); + let svg = std::fs::read_to_string(out.join("two.svg")).unwrap(); + assert!(svg.contains("fill=\"#000000\""), "the black square is traced: {svg}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Batch Trace Folder rejects optional values with the wrong type before opening images. #[cfg(not(target_arch = "wasm32"))] #[test] diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs new file mode 100644 index 0000000000..524f6b205e --- /dev/null +++ b/crates/engine/src/cmd/addons/image.rs @@ -0,0 +1,598 @@ +//! Image Tools (`addon.image.*`), a built-in add-on. Remove Background makes the background of the +//! selected images transparent in their own pixels, as one undo step. The background is the most +//! common color along an image's edges, the average of its four corners, or colors the caller +//! picks. It is cleared where it reaches in from an edge, or wherever it is. The work is done here, +//! on the pixels; nothing is sent anywhere. It is a VectorCraft addition. Its submenu (Object › +//! Plug-ins › Image Tools) and dialog are the UI's. + +#[cfg(test)] +mod tests; + +use std::collections::{HashMap, HashSet}; +use std::io::Cursor; +use std::sync::{Arc, Weak}; + +use image::{DynamicImage, ImageBuffer, LumaA, Rgba}; +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::{ImageBlob, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; +use super::super::menucmds::has_image; +use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; + +/// Remove Background's command. +pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground"; +const C: &str = REMOVE_BACKGROUND; + +/// The most colors `colors` lists. +const MAX_COLORS: usize = 16; + +/// Bytes a pixel takes while it is worked on, beside its pixels: its level, whether the edge search +/// reached it, and its place in the search's stack (4 bytes, twice that while the stack grows). +const WORK_BYTES: u64 = 10; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.image.removeBackground", + "Remove Background…", + ["Object", "Plug-ins", "Image Tools"], + None, + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + has_image, + remove_background + )] +} + +/// Where the background's colors come from. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Background { + /// The most common color along the image's edges. + Edges, + /// The average of the image's four corner pixels that are not clear. + Corners, + /// These colors. + Colors(Vec<[u8; 3]>), +} + +/// How near a color is to a background color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Match { + /// The RGB distance, as a percentage of the largest one (black to white). + Distance, + /// The largest difference on one of red, green and blue, as a percentage of a channel's range. + Channels, +} + +/// What Remove Background clears: the background, whether only where it reaches in from an edge, +/// and how near a color must be to it (a percentage, measured as `measure` says) to be cleared, and +/// to fade out past that. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Settings { + pub background: Background, + pub contiguous: bool, + pub measure: Match, + pub tolerance: f64, + pub softness: f64, +} + +impl Settings { + /// The settings `p` gives, each checked. + fn read(p: &Value) -> Result { + let percent = |key: &str| -> Result { + let v = number_or(p, key, 10.0, C)?; + if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(C, format!("`{key}` must be from 0 to 100"))) } + }; + let (tolerance, softness) = (percent("tolerance")?, percent("softness")?); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => Some(colors(v)?), + }; + let background = match text_param(p, "background", C)? { + None | Some("edges") => Background::Edges, + Some("corners") => Background::Corners, + Some("colors") => Background::Colors(colors.ok_or_else(colors_error)?), + Some(_) => return Err(bad(C, "`background` must be edges, corners or colors")), + }; + let measure = match text_param(p, "match", C)? { + None | Some("distance") => Match::Distance, + Some("channels") => Match::Channels, + Some(_) => return Err(bad(C, "`match` must be distance or channels")), + }; + let contiguous = switch_or(p, "contiguous", true, C)?; + Ok(Self { background, contiguous, measure, tolerance, softness }) + } +} + +fn colors_error() -> crate::EngineError { + bad(C, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue") +} + +/// The colors `v` lists: an array of strings, or one string of them split by commas or spaces. +/// Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused. +fn colors(v: &Value) -> Result> { + // One past the most is enough to tell a list that is too long. + let texts: Vec<&str> = match v { + Value::String(s) => s.split([',', ' ', '\t', '\n']).filter(|t| !t.is_empty()).take(MAX_COLORS + 1).collect(), + Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::>().ok_or_else(colors_error)?, + _ => return Err(colors_error()), + }; + if texts.is_empty() || texts.len() > MAX_COLORS { + return Err(colors_error()); + } + texts.into_iter().map(|t| rgb8(t).ok_or_else(colors_error)).collect() +} + +/// The 8-bit RGB of the color `t` writes. +fn rgb8(t: &str) -> Option<[u8; 3]> { + if hex_with_alpha(t) { + return None; + } + let [r, g, b] = Color::from_hex(t)?.to_rgb_uncalibrated(); + Some([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) +} + +fn remove_background(s: &mut Session, p: &Value) -> Result { + let settings = Settings::read(p)?; + // A preview step keeps the pixels it decodes for the next step; other runs keep none. + let mut kept = std::mem::take(&mut s.decoded_images); + let previewing = s.previewing; + let found = changes(s, p, &settings, previewing.then_some(&mut kept)); + s.decoded_images = kept; + let changes = found?; + let ids: Vec = changes.iter().map(|(id, ..)| id.0).collect(); + let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum(); + if !changes.is_empty() { + s.edit("Remove Background", |d, _| { + for (id, out, xf) in changes { + let key = out.blob.content_key(); + d.images.entry(key.clone()).or_insert(out.blob); + if let Some(NodeKind::Image(im)) = d.node_mut(id).map(|n| &mut n.kind) { + im.key = key; + (im.width, im.height, im.xf) = (out.width, out.height, xf); + im.link = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear })) +} + +/// The images `p` names (`ids`, or the selected images) whose pixels change, each with its pixels +/// cleared as `settings` say and the transform that keeps its place and size. Decoded pixels are +/// taken from and kept in `kept` when it is given. +fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + let st = s.doc()?; + let ids: Vec = if p.get("ids").is_some_and(|v| !v.is_null()) { + // Each image once, however often `ids` names it. + let mut given = HashSet::new(); + let ids: Vec = checked_ids_param(s, p, "ids", C)?.into_iter().filter(|id| given.insert(*id)).collect(); + if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { + return Err(bad(C, format!("object {} is not an image", id.0))); + } + ids + } else { + st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect() + }; + if ids.is_empty() { + return Err(bad(C, "select an image")); + } + // Every image is checked before any is decoded. + let mut images = vec![]; + for id in ids { + let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The preview stands in for a file that hasn't been read, or that is kept at its preview. + if im.link.is_some() && blob.is_proxy() { + return Err(bad(C, "the linked image shows its preview: embed or relink it first")); + } + if blob.is_cmyk() { + return Err(bad(C, "a CMYK image has no transparency to set")); + } + images.push((id, im, blob)); + } + // Each image's pixels once, however many objects show them. + let mut cleared: HashMap<&str, Option> = HashMap::new(); + let mut changes = vec![]; + for (id, im, blob) in images { + let out = match cleared.get(im.key.as_str()) { + Some(out) => out.clone(), + None => { + let out = clear(blob, settings, kept.as_deref_mut())?; + cleared.insert(&im.key, out.clone()); + out + } + }; + if let Some(out) = out { + // A file read at another size than the object's keeps the object's place and size. + let xf = if (out.width, out.height) == (im.width, im.height) { + im.xf + } else { + im.xf * Affine::scale_non_uniform(f64::from(im.width) / f64::from(out.width), f64::from(im.height) / f64::from(out.height)) + }; + changes.push((id, out, xf)); + } + } + Ok(changes) +} + +/// An image with its background cleared: its PNG, its size in pixels, and how many of its pixels +/// were made fully clear. +#[derive(Clone, Debug)] +pub(crate) struct Cleared { + pub blob: ImageBlob, + pub width: u32, + pub height: u32, + pub cleared: u64, +} + +/// An image's pixels as straight RGBA, at 8 or 16 bits a sample. +#[derive(Clone)] +enum Pixels { + Eight(image::RgbaImage), + Sixteen(ImageBuffer, Vec>), +} + +/// The most bytes of decoded pixels and ICC profiles [`DecodedImages`] keeps (128 MiB, or 32 MiB in +/// the web app). +const KEEP_BYTES: u64 = MAX_RASTER_ALLOC / 16; + +/// The images Remove Background decoded during an interaction's preview steps, the most recently +/// used first, at most [`KEEP_BYTES`] in all. The next preview step runs the command again on the +/// same bytes and takes their pixels from here instead of decoding them again. The session holds +/// it and empties it when the interaction ends (OK or Cancel). A run that is not a preview step +/// does not read or fill it. +#[derive(Default)] +pub(crate) struct DecodedImages(Vec); + +/// An image decoded by [`clear`]: the bytes it was decoded from (held weakly, so they are not kept +/// alive for it, and an entry whose bytes are gone is dropped), its pixels, whether it was gray, +/// and its ICC profile. +struct Decoded { + source: Weak>, + pixels: Arc, + gray: bool, + icc: Option>, +} + +#[cfg(test)] +thread_local! { + /// How many times this thread decoded an image in [`clear`]. + pub(crate) static DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +impl DecodedImages { + /// Drops every image kept. + pub(crate) fn empty(&mut self) { + self.0.clear(); + } + + /// How many images are kept. + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + /// The pixels, gray flag and ICC profile kept for `bytes`, if they are kept. Images whose bytes + /// are gone are dropped first. + fn get(&mut self, bytes: &Arc>) -> Option<(Arc, bool, Option>)> { + self.0.retain(|d| d.source.strong_count() > 0); + let at = self.0.iter().position(|d| std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes)))?; + let d = self.0.remove(at); + let found = (Arc::clone(&d.pixels), d.gray, d.icc.clone()); + self.0.insert(0, d); + Some(found) + } + + /// Keeps `pixels` decoded from `bytes` first, in place of anything kept for the same bytes, and + /// drops images whose bytes are gone and the least recently used past [`KEEP_BYTES`]. + fn keep(&mut self, bytes: &Arc>, pixels: Arc, gray: bool, icc: Option>) { + self.0.retain(|d| d.source.strong_count() > 0 && !std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes))); + self.0.insert(0, Decoded { source: Arc::downgrade(bytes), pixels, gray, icc }); + let mut total = 0u64; + self.0.retain(|d| { + total = total.saturating_add(d.bytes()); + total <= KEEP_BYTES + }); + } +} + +impl Decoded { + /// How many bytes the entry keeps: its pixels and its ICC profile. + fn bytes(&self) -> u64 { + self.pixels.bytes().saturating_add(self.icc.as_ref().map_or(0, |icc| icc.len() as u64)) + } +} + +impl Pixels { + /// How many bytes the pixels take. + fn bytes(&self) -> u64 { + match self { + Self::Eight(img) => img.as_raw().len() as u64, + Self::Sixteen(img) => img.as_raw().len() as u64 * 2, + } + } + + /// The width and height in pixels. + fn dimensions(&self) -> (u32, u32) { + match self { + Self::Eight(img) => img.dimensions(), + Self::Sixteen(img) => img.dimensions(), + } + } + + /// Pixel `i` (row by row from the top left) at 16 bits a sample (an 8-bit sample `v` is + /// `v * 257`). + fn rgba16(&self, i: usize) -> [u16; 4] { + let mut out = [0u16; 4]; + match self { + Self::Eight(img) => { + for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) { + *o = u16::from(*v) * 257; + } + } + Self::Sixteen(img) => { + for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) { + *o = *v; + } + } + } + out + } +} + +/// `blob` with its background cleared as `settings` say, or `None` when no pixel changes. A CMYK +/// image, one that can't be decoded or is too large to change, and a result with no pixel left are +/// errors. The PNG keeps the source's ICC profile and resolution. The decoded pixels are taken +/// from and kept in `kept` when it is given. +pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + if blob.is_cmyk() { + return Err(bad(C, "a CMYK image has no transparency to set")); + } + let found = kept.as_deref_mut().and_then(|k| k.get(&blob.bytes)); + let (source, gray, icc) = match found { + Some(found) => found, + None => decode(blob, kept)?, + }; + // The pixels kept for the next run stay as they are; ones not kept are worked on in place. + let mut px = Arc::try_unwrap(source).unwrap_or_else(|kept| Pixels::clone(&kept)); + let (w, h) = px.dimensions(); + let levels = levels(w as usize, h as usize, &|i| px.rgba16(i), settings); + let Some(cleared) = apply(&mut px, &levels) else { return Ok(None) }; + let left = match &px { + Pixels::Eight(img) => img.pixels().any(|p| p.0[3] > 0), + Pixels::Sixteen(img) => img.pixels().any(|p| p.0[3] > 0), + }; + if !left { + return Err(bad(C, "every pixel matches the background: no image would be left")); + } + let out = match (px, gray) { + (Pixels::Eight(img), false) => DynamicImage::ImageRgba8(img), + (Pixels::Sixteen(img), false) => DynamicImage::ImageRgba16(img), + (Pixels::Eight(img), true) => { + DynamicImage::ImageLumaA8(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]]))) + } + (Pixels::Sixteen(img), true) => { + DynamicImage::ImageLumaA16(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]]))) + } + }; + let mut png = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + use image::ImageEncoder as _; + encoder.set_icc_profile(icc).map_err(|e| bad(C, e.to_string()))?; + } + out.write_with_encoder(encoder).map_err(|e| bad(C, e.to_string()))?; + // The resolution the file declares, which sizes the image at 100% when it is replaced or relinked. + if let Some(r) = ppi::resolution(&blob.bytes) { + png = ppi::with_png_resolution(&png, r); + } + Ok(Some(Cleared { blob: ImageBlob::png(png), width: w, height: h, cleared })) +} + +/// `blob`'s pixels as straight RGBA, whether they were gray, and its ICC profile. An image that +/// can't be decoded or is too large to change is an error. The pixels are kept in `kept`, when it +/// is given, if a copy of them fits the decode allowance beside the work. +fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc, bool, Option>)> { + let reader = + || image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(C, "the image's pixels can't be read")); + let mut decoder = reader()?.into_decoder().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + // The ICC profile the colors are in goes with them into the PNG. + let (w, h, kind, icc) = { + use image::ImageDecoder as _; + let (w, h) = decoder.dimensions(); + (w, h, decoder.color_type(), decoder.icc_profile().ok().flatten()) + }; + drop(decoder); + // The file's pixels, those worked on (as many again for the PNG made of them) and the work, + // checked from the header before anything is decoded. + let deep = kind.bytes_per_pixel() > kind.channel_count(); + let per_pixel = u64::from(kind.bytes_per_pixel()) + 2 * if deep { 8 } else { 4 } + WORK_BYTES; + if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(per_pixel)).is_none_or(|n| n > MAX_RASTER_ALLOC) { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to change"))); + } + let mut reader = reader()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_RASTER_ALLOC); + reader.limits(limits); + let img = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + #[cfg(test)] + DECODES.with(|c| c.set(c.get() + 1)); + let gray = matches!(img.color(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16); + let px = Arc::new(if deep { Pixels::Sixteen(img.into_rgba16()) } else { Pixels::Eight(img.into_rgba8()) }); + // A kept copy is worked on as a copy: one more set of the pixels worked on. + let with_copy = per_pixel + if deep { 8 } else { 4 }; + if let Some(kept) = kept + && u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(with_copy)).is_some_and(|n| n <= MAX_RASTER_ALLOC) + && px.bytes().saturating_add(icc.as_ref().map_or(0, |icc| icc.len() as u64)) <= KEEP_BYTES + { + kept.keep(&blob.bytes, Arc::clone(&px), gray, icc.clone()); + } + Ok((px, gray, icc)) +} + +/// Each pixel's alpha scaled by its level (255 keeps it). Returns how many pixels were made fully +/// clear, or `None` when no pixel changed. +fn apply(px: &mut Pixels, levels: &[u8]) -> Option { + let (mut changed, mut cleared) = (false, 0u64); + match px { + Pixels::Eight(img) => { + for (p, &l) in img.pixels_mut().zip(levels) { + let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u8; + changed |= a != p.0[3]; + cleared += u64::from(a == 0 && p.0[3] != 0); + p.0[3] = a; + } + } + Pixels::Sixteen(img) => { + for (p, &l) in img.pixels_mut().zip(levels) { + let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u16; + changed |= a != p.0[3]; + cleared += u64::from(a == 0 && p.0[3] != 0); + p.0[3] = a; + } + } + } + changed.then_some(cleared) +} + +/// How much of each of the `w` × `h` pixels `px` gives (row by row, straight RGBA at 16 bits a +/// sample) stays, from 0 (cleared) to 255 (kept), as `settings` say. Each pixel's distance to the +/// background is worked out once. A pixel past the first `u32::MAX` is left kept by the edge search. +pub(crate) fn levels(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4], settings: &Settings) -> Vec { + let n = w.saturating_mul(h); + let refs: Vec<[u16; 3]> = match &settings.background { + Background::Colors(refs) => refs.iter().map(|c| c.map(|v| u16::from(v) * 257)).collect(), + Background::Edges => match edge_color(w, h, px) { + Some(edge) => vec![edge], + None => return vec![255; n], + }, + Background::Corners => match corner_color(w, h, px) { + Some(corner) => vec![corner], + None => return vec![255; n], + }, + }; + let mut out: Vec = (0..n) + .map(|i| { + let [r, g, b, _] = px(i); + level([r, g, b], &refs, settings) + }) + .collect(); + if settings.contiguous { + // Out from the edges, through clear pixels and pixels that lose some alpha; the pixels it + // doesn't reach are kept. + let reaches = |out: &[u8], i: usize| out.get(i).is_some_and(|l| *l < 255) || px(i)[3] == 0; + let mut seen = vec![false; n]; + let mut stack: Vec = vec![]; + for i in border(w, h).filter(|&i| reaches(&out, i)) { + if let (Some(s), Ok(at)) = (seen.get_mut(i), u32::try_from(i)) { + *s = true; + stack.push(at); + } + } + while let Some(at) = stack.pop() { + let i = at as usize; + let (x, y) = (i % w, i / w); + let next = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)]; + for j in next.into_iter().flatten() { + if let Some(s) = seen.get_mut(j) + && !*s + && reaches(&out, j) + && let Ok(at) = u32::try_from(j) + { + *s = true; + stack.push(at); + } + } + } + for (l, s) in out.iter_mut().zip(&seen) { + if !*s { + *l = 255; + } + } + } + out +} + +/// The level of a pixel of color `c`: 0 within the tolerance of the nearest of `refs`, 255 from +/// the tolerance plus the softness on, and in proportion between them. +fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 { + let Settings { tolerance, softness, measure, .. } = *settings; + let d = refs.iter().map(|r| apart(c, *r, measure)).fold(f64::INFINITY, f64::min); + if d <= tolerance { + 0 + } else if d >= tolerance + softness { + 255 + } else { + ((d - tolerance) / softness * 255.0).round().clamp(0.0, 255.0) as u8 + } +} + +/// How far apart `a` and `b` are, as a percentage, measured as `measure` says: the RGB distance of +/// the largest one (black to white), or the largest difference on one channel of the channel's range. +/// A difference of `k` 8-bit steps on one channel is exactly `k / 255 × 100` with channels. +fn apart(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 { + match measure { + Match::Distance => { + let sq: f64 = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum(); + sq.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0 + } + Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) / 65_535.0 * 100.0, + } +} + +/// The pixels on the edges of a `w` × `h` image, each once. +fn border(w: usize, h: usize) -> impl Iterator { + let top = (0..w).filter(move |_| h > 0); + let bottom = (0..w).filter(move |_| h > 1).map(move |x| (h - 1) * w + x); + let sides = (1..h.saturating_sub(1)).flat_map(move |y| [Some(y * w), (w > 1).then(|| y * w + w - 1)].into_iter().flatten()); + top.chain(bottom).chain(sides) +} + +/// The most common color along an image's edges: its edge pixels grouped by 16 levels a channel, +/// with the clear ones as a group of their own, as the average of the largest group. `None` when +/// the clear edge pixels are at least as many as that group: the background is already cleared. +fn edge_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> { + let mut groups: HashMap<[u16; 3], (u64, [u64; 3])> = HashMap::new(); + let mut clear = 0u64; + for i in border(w, h) { + let [r, g, b, a] = px(i); + if a == 0 { + clear += 1; + continue; + } + let e = groups.entry([r >> 12, g >> 12, b >> 12]).or_default(); + e.0 += 1; + for (s, v) in e.1.iter_mut().zip([r, g, b]) { + *s += u64::from(v); + } + } + // Equal groups go by color, so the result doesn't depend on the map's order. + let (_, (n, sums)) = groups.into_iter().max_by(|a, b| a.1.0.cmp(&b.1.0).then(b.0.cmp(&a.0)))?; + if clear >= n { + return None; + } + Some(sums.map(|s| ((s + n / 2) / n.max(1)) as u16)) +} + +/// The average of the four corner pixels of a `w` × `h` image that are not clear (a corner of a +/// one-pixel-wide image counts twice). `None` when all four are clear: the background is already +/// cleared. +fn corner_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> { + if w == 0 || h == 0 { + return None; + } + let corners = [0, w - 1, (h - 1) * w, (h - 1) * w + w - 1]; + let (mut n, mut sums) = (0u64, [0u64; 3]); + for [r, g, b, a] in corners.map(px) { + if a > 0 { + n += 1; + for (s, v) in sums.iter_mut().zip([r, g, b]) { + *s += u64::from(v); + } + } + } + (n > 0).then(|| sums.map(|s| ((s + n / 2) / n) as u16)) +} diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs new file mode 100644 index 0000000000..df27180e82 --- /dev/null +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -0,0 +1,480 @@ +use std::io::Cursor; +use std::sync::Arc; + +use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use serde_json::{Value, json}; +use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::{Background, Match, REMOVE_BACKGROUND as C, Settings, level, levels}; +use crate::Session; +use crate::cmd::fileio::ppi; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [200, 0, 0, 255]; + +/// The command's defaults with `background`. +fn settings(background: Background) -> Settings { + Settings { background, contiguous: true, measure: Match::Distance, tolerance: 10.0, softness: 10.0 } +} + +fn png(img: DynamicImage) -> Vec { + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// 12 × 10 pixels: white, with a red block from (3, 3) to (9, 7) that holds one white pixel at +/// (6, 5), which no white edge pixel reaches. +fn framed() -> image::RgbaImage { + image::RgbaImage::from_fn(12, 10, |x, y| { + let block = (3..9).contains(&x) && (3..7).contains(&y); + image::Rgba(if block && (x, y) != (6, 5) { RED } else { WHITE }) + }) +} + +/// A session with `bytes` opened as a document, its image selected. +fn open(bytes: &[u8]) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + (s, id) +} + +fn image_of(s: &Session, id: NodeId) -> ImageObject { + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("an image") }; + (**im).clone() +} + +fn bytes_of(s: &Session, id: NodeId) -> Arc> { + s.doc().unwrap().doc.images[&image_of(s, id).key].bytes.clone() +} + +fn pixels(s: &Session, id: NodeId) -> DynamicImage { + image::load_from_memory(&bytes_of(s, id)).unwrap() +} + +fn alpha(img: &DynamicImage, x: u32, y: u32) -> u8 { + img.to_rgba8().get_pixel(x, y).0[3] +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn the_edge_colors_are_cleared_where_they_reach_in_from_an_edge() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [id.0], "cleared": 12 * 10 - 6 * 4})); + let out = pixels(&s, id); + assert_eq!(out.color(), image::ColorType::Rgba8); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 11, 9), alpha(&out, 2, 4)), (0, 0, 0), "the white around the block"); + assert_eq!(out.to_rgba8().get_pixel(4, 4).0, RED, "the block"); + assert_eq!(alpha(&out, 6, 5), 255, "white inside the block, which no edge reaches"); + let after = image_of(&s, id); + assert_eq!((after.width, after.height, after.xf), (12, 10, before.0.xf), "the same place and size"); + assert_eq!(undo_len(&s), before.1 + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(image_of(&s, id), before.0); +} + +/// Picked colors, like the other backgrounds, are cleared where they reach in from an edge, and with +/// `contiguous: false` wherever they are. +#[test] +fn picked_colors_are_cleared_from_the_edges_or_wherever_they_are() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 255, 255), "not inside the block"); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "inside the block too; the red stays"); +} + +/// With `contiguous: false` the edge color is cleared inside the subject too. +#[test] +fn the_edge_color_can_be_cleared_wherever_it_is() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let r = s.execute(C, &json!({"contiguous": false})).unwrap(); + assert_eq!(r["cleared"], 12 * 10 - 6 * 4 + 1); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255)); +} + +/// `colors` as one string split by commas and spaces, written as other color params take them. +#[test] +fn picked_colors_can_be_one_string_of_colors() { + for colors in ["#00ff00, ffffff", "green white", "#0f0,#FFF"] { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": colors, "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "{colors}"); + } +} + +/// A subject along a whole edge stays: only the most common edge color is the background. +#[test] +fn a_subject_along_one_edge_is_not_the_background() { + let img = image::RgbaImage::from_fn(10, 10, |_, y| image::Rgba(if y >= 7 { RED } else { WHITE })); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + s.execute(C, &json!({})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 0, 8)), (0, 255, 255)); +} + +/// The preview steps of one interaction, as the dialog's are, decode an image once and give what a +/// run on freshly decoded pixels gives. The pixels are kept only until the interaction ends (OK or +/// Cancel), and a run that is not a preview step keeps none. +#[test] +fn preview_steps_on_the_same_pixels_decode_them_once() { + let decodes = || super::DECODES.with(std::cell::Cell::get); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let start = decodes(); + let p = json!({"background": "colors", "colors": "#c80000", "contiguous": false, "tolerance": 0, "softness": 0}); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + s.preview(C, &json!({"tolerance": 20, "softness": 0})).unwrap(); + s.preview(C, &p).unwrap(); + assert_eq!((decodes() - start, s.decoded_images.len()), (1, 1), "decoded once for three preview steps"); + s.commit_interaction().unwrap(); + assert_eq!(s.decoded_images.len(), 0, "OK lets the pixels go"); + let (mut fresh, fresh_id) = open(&png(DynamicImage::ImageRgba8(framed()))); + fresh.execute(C, &p).unwrap(); + assert_eq!((decodes() - start, fresh.decoded_images.len()), (2, 0), "a run that is not a preview step keeps nothing"); + assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result"); + assert_eq!((alpha(&pixels(&s, id), 4, 4), alpha(&pixels(&s, id), 0, 0)), (0, 255), "the red cleared, the white kept"); + s.execute("edit.undo", &json!({})).unwrap(); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({})).unwrap(); + assert_eq!(s.decoded_images.len(), 1); + s.cancel_interaction().unwrap(); + assert_eq!(s.decoded_images.len(), 0, "Cancel lets the pixels go"); + s.execute(C, &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute(C, &json!({})).unwrap(); + assert_eq!(decodes() - start, 5, "each run that is not a preview step decodes"); +} + +/// Pixels a preview step takes from the step before keep what they were decoded with: a 16-bit gray +/// image with an ICC profile comes out as a run on freshly decoded pixels gives it. +#[test] +fn reused_pixels_keep_their_depth_gray_and_profile() { + use image::{ImageDecoder as _, ImageEncoder as _}; + let icc = b"a profile's bytes".to_vec(); + let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }])); + let mut bytes = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder.set_icc_profile(icc.clone()).unwrap(); + DynamicImage::ImageLuma16(gray16).write_with_encoder(encoder).unwrap(); + let decodes = || super::DECODES.with(std::cell::Cell::get); + let (mut s, id) = open(&bytes); + let start = decodes(); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + s.preview(C, &json!({})).unwrap(); + assert_eq!(decodes() - start, 1, "the second step took the pixels of the first"); + let (mut fresh, fresh_id) = open(&bytes); + fresh.execute(C, &json!({})).unwrap(); + assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result"); + let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); + let result = bytes_of(&s, id); + let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(result.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); +} + +/// Once the background is cleared, most edge pixels are clear and a second run clears nothing: the +/// subject's color along an edge is not taken for the background. +#[test] +fn a_second_run_leaves_the_subject() { + let img = image::RgbaImage::from_fn(10, 10, |x, y| { + image::Rgba(if y >= 7 { + RED + } else if (4..6).contains(&x) && (3..5).contains(&y) { + [0, 0, 200, 255] + } else { + WHITE + }) + }); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + s.execute(C, &json!({})).unwrap(); + let (first, steps) = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0})); + assert_eq!((image_of(&s, id), undo_len(&s)), (first, steps), "no change and no undo step"); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 4, 3)), (0, 255, 255), "the red and the blue stay"); +} + +#[test] +fn tolerance_and_softness_set_how_near_a_color_is_cleared() { + // White; 1.96 % from white; 21.6 % from white. + let px = [WHITE, [250, 250, 250, 255], [200, 200, 200, 255]]; + let at = |i: usize| px[i].map(|v| u16::from(v) * 257); + let run = |tolerance, softness| levels(3, 1, &at, &Settings { tolerance, softness, ..settings(Background::Colors(vec![[255; 3]])) }); + assert_eq!(run(0.0, 0.0), [0, 255, 255], "only white itself at 0"); + assert_eq!(run(2.0, 0.0), [0, 0, 255]); + assert_eq!(run(0.0, 10.0), [0, 50, 255], "1.96 % into a 10 % fade"); + assert_eq!(run(100.0, 0.0), [0, 0, 0], "everything at 100"); +} + +#[test] +fn a_result_with_no_pixel_left_is_refused_and_changes_nothing() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let e = s.execute(C, &json!({"background": "colors", "colors": "#ffffff", "tolerance": 100})).unwrap_err(); + assert!(e.to_string().contains("every pixel matches the background"), "{e}"); + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +#[test] +fn an_image_whose_pixels_do_not_change_is_left_with_no_undo_step() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({"background": "colors", "colors": "#00ff00", "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0})); + assert_eq!((image_of(&s, id), undo_len(&s)), before); + // An image whose edges and corners are already clear has no edge or corner color to clear, + // whatever color the clear pixels hold. + let ring = + image::RgbaImage::from_fn(6, 6, |x, y| image::Rgba(if (1..5).contains(&x) && (1..5).contains(&y) { WHITE } else { [255, 255, 255, 0] })); + for p in [json!({}), json!({"background": "corners", "contiguous": false})] { + let (mut s, _) = open(&png(DynamicImage::ImageRgba8(ring.clone()))); + assert_eq!(s.execute(C, &p).unwrap()["changed"], 0, "{p}"); + } +} + +/// The corners' color: 40 × 30 pixels of light gray around a red block, cleared within 5 steps on +/// each channel (`match: channels`, a tolerance of `5 / 255 × 100`, no softness), from the edges +/// or wherever it is. +#[test] +fn the_corners_color_is_cleared() { + let img = image::RgbaImage::from_fn(40, 30, |x, y| { + image::Rgba(if (10..30).contains(&x) && (8..22).contains(&y) { [200, 40, 40, 255] } else { [240, 240, 240, 255] }) + }); + for contiguous in [false, true] { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img.clone()))); + let p = json!({"background": "corners", "contiguous": contiguous, "match": "channels", "tolerance": 5.0 / 255.0 * 100.0, "softness": 0}); + let r = s.execute(C, &p).unwrap(); + assert_eq!(r["cleared"], 40 * 30 - 14 * 20, "{p}"); + let out = pixels(&s, id).to_rgba8(); + assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(15, 15).0), (0, [200, 40, 40, 255]), "{p}"); + } + // A clear corner is left out of the average: the other three are white, and white is cleared. + let mut img = image::RgbaImage::from_pixel(8, 8, image::Rgba(WHITE)); + img.put_pixel(0, 0, image::Rgba([0; 4])); + img.put_pixel(4, 4, image::Rgba(RED)); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + let r = s.execute(C, &json!({"background": "corners", "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 7, 7), alpha(&out, 3, 3), alpha(&out, 4, 4)), (0, 0, 255)); + assert_eq!(r["cleared"], 8 * 8 - 2, "the pixels made clear, not the corner that was clear already"); +} + +/// One picked color matched by channel at a tolerance of 0: cleared wherever it is, and a pixel a +/// long way from it kept. +#[test] +fn one_picked_color_is_cleared_by_channel() { + let mut img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255])); + img.put_pixel(2, 2, image::Rgba([250, 250, 250, 255])); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + let p = json!({"background": "colors", "colors": "#0a141e", "contiguous": false, "match": "channels", "tolerance": 0, "softness": 0}); + assert_eq!(s.execute(C, &p).unwrap()["cleared"], 15); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 2, 2)), (0, 255)); +} + +/// With `match: channels`, a tolerance of `t / 255 × 100` clears a color `k` 8-bit steps from the +/// background on one channel exactly when `k ≤ t`, for every `t` and `k` from 0 to 255; with +/// `distance` the same color is nearer when the others match. +#[test] +fn channels_match_the_largest_difference_on_one_channel() { + for t in 0..=255u16 { + let s = Settings { measure: Match::Channels, tolerance: f64::from(t) / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) }; + for k in 0..=255u16 { + for c in [[k * 257, 0, 0], [0, k * 257, 0], [0, 0, k * 257]] { + assert_eq!(level(c, &[[0; 3]], &s) == 0, k <= t, "t {t}, k {k}"); + } + } + } + let at = |measure| { + level([0, 40 * 257, 0], &[[0; 3]], &Settings { measure, tolerance: 32.0 / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) }) + }; + assert_eq!((at(Match::Channels), at(Match::Distance)), (255, 0), "40 steps on green: past 32 on one channel; 9 % of the RGB distance"); +} + +#[test] +fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { + let deep = ImageBuffer::from_fn(8, 8, |x, y| Rgb(if (2..6).contains(&x) && (2..6).contains(&y) { [51_400, 0, 1] } else { [65_535; 3] })); + let (mut s, id) = open(&png(DynamicImage::ImageRgb16(deep))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") }; + assert_eq!(out.get_pixel(0, 0).0[3], 0); + assert_eq!(out.get_pixel(3, 3).0, [51_400, 0, 1, 65_535], "16-bit samples kept as they were"); + // Samples one 8-bit step apart are told apart: only the background is cleared at 0. + let near = ImageBuffer::from_fn(4, 4, |x, y| Rgb(if (1..3).contains(&x) && (1..3).contains(&y) { [65_280; 3] } else { [65_535; 3] })); + let (mut s, id) = open(&png(DynamicImage::ImageRgb16(near))); + s.execute(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") }; + assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(1, 1).0[3]), (0, 65_535)); + let gray = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])); + let (mut s, id) = open(&png(DynamicImage::ImageLuma8(gray))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([240, 0], [30, 255])); + let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }])); + let (mut s, id) = open(&png(DynamicImage::ImageLuma16(gray16))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); +} + +/// The ICC profile the colors are in stays with them. +#[test] +fn the_color_profile_is_kept() { + use image::{ImageDecoder as _, ImageEncoder as _}; + let icc = b"a profile's bytes".to_vec(); + let mut bytes = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder.set_icc_profile(icc.clone()).unwrap(); + DynamicImage::ImageRgba8(framed()).write_with_encoder(encoder).unwrap(); + let blob = ImageBlob::png(bytes); + let out = super::clear(&blob, &settings(Background::Edges), None).unwrap().unwrap(); + let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(out.blob.bytes.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); +} + +/// The resolution the file declares stays with the pixels, so a later Replace or relink sizes the +/// image as before. +#[test] +fn the_resolution_is_kept() { + let bytes = ppi::with_png_resolution(&png(DynamicImage::ImageRgba8(framed())), (300.0, 150.0)); + let (mut s, id) = open(&bytes); + s.execute(C, &json!({})).unwrap(); + let (x, y) = ppi::resolution(&bytes_of(&s, id)).expect("a resolution"); + assert_eq!((x.round(), y.round()), (300.0, 150.0)); +} + +#[test] +fn a_cmyk_image_is_refused() { + let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [0, 0, 0, 0].repeat(8)).unwrap(); + let tiff = ImageBlob::cmyk_tiff(&inks).unwrap(); + let (mut s, id) = open(&tiff.bytes); + let before = (image_of(&s, id), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a CMYK image has no transparency to set"), "{e}"); + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +/// A linked image of `blob`, `width` × `height` pixels as the object records it, beside the open +/// document's image. +fn place_linked(s: &mut Session, key: &str, blob: ImageBlob, (width, height): (u32, u32)) -> NodeId { + s.edit("Place", |d, sel| { + d.images.insert(key.into(), blob); + let id = d.alloc_id(); + let im = ImageObject { + key: key.into(), + width, + height, + xf: Default::default(), + link: Some(LinkInfo::new("/nowhere/art.png")), + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn a_linked_image_is_embedded_and_one_that_shows_its_preview_is_refused() { + let bytes = png(DynamicImage::ImageRgba8(framed())); + let (mut s, _) = open(&bytes); + let linked = place_linked(&mut s, "linked", ImageBlob::png(bytes.clone()), (12, 10)); + let clip = vectorcraft_doc::links::PlacementOptions { clip: true, ..Default::default() }; + s.edit("Placement", |d, _| { + if let Some(NodeKind::Image(im)) = d.node_mut(linked).map(|n| &mut n.kind) { + im.placement = clip; + } + Ok(()) + }) + .unwrap(); + let r = s.execute(C, &json!({"ids": [linked.0, linked.0]})).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [linked.0], "cleared": 12 * 10 - 6 * 4}), "an image named twice changes once"); + let im = image_of(&s, linked); + assert_eq!((im.link, alpha(&pixels(&s, linked), 0, 0)), (None, 0), "embedded, with its background cleared"); + assert_eq!(im.placement, clip, "its Placement Options kept, as Embed keeps them"); + // Pixels the file wasn't read for: the preview, as an image whose file is missing shows it. + let preview = Arc::new(bytes); + let missing = place_linked(&mut s, "missing", ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }, (12, 10)); + let before = (image_of(&s, missing), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("the linked image shows its preview: embed or relink it first"), "{e}"); + assert_eq!((image_of(&s, missing), undo_len(&s)), before); +} + +/// An object whose pixels were read at another size than it records keeps its place and size. +#[test] +fn an_image_read_at_another_size_keeps_its_place_and_size() { + let bytes = png(DynamicImage::ImageRgba8(framed())); + let (mut s, _) = open(&bytes); + let id = place_linked(&mut s, "linked", ImageBlob::png(bytes), (24, 20)); + s.execute(C, &json!({"ids": [id.0]})).unwrap(); + let im = image_of(&s, id); + assert_eq!((im.width, im.height, im.xf), (12, 10, Affine::scale_non_uniform(2.0, 2.0))); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = (image_of(&s, id), undo_len(&s)); + // With `ids`, the command is available when one of them is an image. + let not_image = format!("object {rect} is not an image"); + let cases: [(Value, &str); 18] = [ + (json!({"background": "sky"}), "`background` must be edges, corners or colors"), + (json!({"background": 5}), "`background` must be a string"), + (json!({"match": "rgb"}), "`match` must be distance or channels"), + (json!({"match": true}), "`match` must be a string"), + (json!({"contiguous": "yes"}), "`contiguous` must be true or false"), + (json!({"contiguous": 1}), "`contiguous` must be true or false"), + (json!({"tolerance": 101}), "`tolerance` must be from 0 to 100"), + (json!({"softness": -1}), "`softness` must be from 0 to 100"), + (json!({"tolerance": "5"}), "`tolerance` must be a number"), + (json!({"background": "colors"}), "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue"), + (json!({"background": "colors", "colors": []}), "`colors` must list"), + (json!({"background": "colors", "colors": "#12"}), "`colors` must list"), + (json!({"background": "colors", "colors": "#ffffff80"}), "`colors` must list"), + (json!({"colors": ["#ffffff", 7]}), "`colors` must list"), + (json!({"background": "colors", "colors": vec!["#000"; 17]}), "`colors` must list"), + (json!({"background": "colors", "colors": "#000 ".repeat(100_000)}), "`colors` must list"), + (json!({"ids": [id.0, rect]}), ¬_image), + (json!({"ids": [rect]}), "select an image"), + ]; + for (p, msg) in cases { + let e = s.execute(C, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +#[test] +fn an_image_too_large_to_change_is_refused_before_it_is_decoded() { + let run = |w: u32, h: u32| { + let (mut s, _) = open(&crate::tests_linkspanel::png_header(w, h)); + s.execute(C, &json!({})).unwrap_err().to_string() + }; + // As gray samples, RGBA worked on and kept for the PNG, and the work: 19 bytes a pixel. + let over = run(10_632, 10_631); + assert!(over.contains("an image of 10632 × 10631 pixels is too large to change"), "{over}"); + // Within the allowance it is decoded, and its missing rows can't be read. + let within = run(10_631, 10_631); + assert!(within.contains("the image's pixels can't be read"), "{within}"); +} diff --git a/crates/engine/src/cmd/addons/pen/tests.rs b/crates/engine/src/cmd/addons/pen/tests.rs index 1bb9f3345e..b314b8db32 100644 --- a/crates/engine/src/cmd/addons/pen/tests.rs +++ b/crates/engine/src/cmd/addons/pen/tests.rs @@ -285,3 +285,54 @@ fn the_tool_draws_a_path_one_step_per_anchor_and_closes_it() { assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3); assert_eq!(steps(&s), n + 8); } + +/// #6634: the tool's closing drag on the first anchor sets that anchor's incoming handle to the +/// mirror of the pointer and does not give it symmetric handles: a corner keeps its outgoing +/// handle, a smooth anchor turns it to stay in line at its own length, and Alt pressed mid-drag +/// keeps it where the drag had turned it. +#[test] +fn the_tools_closing_drag_does_not_make_the_first_anchor_symmetric() { + // An open path whose first anchor, at (50, 50), has the handles `first`, drawn on from its last + // anchor; the closing gesture is then up to the caller. + let draw = |first: Value| { + let mut s = session(); + let mut a = json!({"x": 50, "y": 50}); + a.as_object_mut().unwrap().extend(first.as_object().unwrap().clone()); + let anchors = json!([a, {"x": 150, "y": 50}, {"x": 150, "y": 150}]); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()); + s.select_tool("smartPen", view()).unwrap(); + gesture(&mut s, &[(150.0, 150.0)], Mods::default()); + (s, id) + }; + let first = |s: &Session, id: NodeId| { + let sp = &path(s, id).subpaths[0]; + assert!(sp.closed, "{sp:?}"); + sp.anchors[0] + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + + // A corner: the first segment keeps its shape. + let (mut s, id) = draw(json!({"out": [80, 30]})); + gesture(&mut s, &[(50.0, 50.0), (50.0, 100.0)], Mods::default()); + let a = first(&s, id); + assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(50.0, 0.0), Point::new(80.0, 30.0), AnchorKind::Corner), "{a:?}"); + + // A smooth anchor: the outgoing handle turns opposite the incoming one, keeping its length 30. + let (mut s, id) = draw(json!({"in": [20, 50], "out": [80, 50]})); + assert_eq!(path(&s, id).subpaths[0].anchors[0].kind, AnchorKind::Smooth); + gesture(&mut s, &[(50.0, 50.0), (50.0, 100.0)], Mods::default()); + let a = first(&s, id); + assert_eq!((a.h_in, a.kind), (Point::new(50.0, 0.0), AnchorKind::Smooth), "{a:?}"); + assert!(near(a.h_out, Point::new(50.0, 80.0)), "{a:?}"); + + // Alt pressed mid-drag: a corner, its outgoing handle where the drag had turned it. + let (mut s, id) = draw(json!({"in": [20, 50], "out": [80, 50]})); + let alt = Mods { alt: true, ..Mods::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), view()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 50.0, 100.0), view()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 80.0, 100.0).with_mods(alt), view()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 100.0).with_mods(alt), view()).unwrap(); + let a = first(&s, id); + assert_eq!((a.h_in, a.kind), (Point::new(20.0, 0.0), AnchorKind::Corner), "{a:?}"); + assert!(near(a.h_out, Point::new(50.0, 80.0)), "{a:?}"); +} diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index 24d0e4c3ac..87549e3694 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -131,6 +131,8 @@ fn insert_custom(s: &mut Session, label: &str, live: Option, cs: Cust && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + // A shape drawn pixel-aligned (Align New Objects to Pixel Grid) snaps its stored outline. + super::super::object::snap_if_aligned(n); } Ok(val) } @@ -270,6 +272,7 @@ fn box3d(s: &mut Session, p: &Value) -> Result { && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + super::super::object::snap_if_aligned(n); } Ok(val) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 2dfeb497d0..1f64e582ad 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -19,12 +19,12 @@ //! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the //! image as its Contents row says; new ones go right below that row. -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::appearance::SharedStack; -use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; use vectorcraft_geom::Rect; use super::edit::{outermost, roots_of, selected_roots}; @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -1198,7 +1198,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } -/// Sample enabled attributes, refusing object transparency that a text range cannot store. +/// Sample enabled attributes with transform-aware character dimensions and atomic validation. fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; @@ -1270,9 +1270,6 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, }; let picked = Picked::of(&node); - if !reverse && !defaults_only { - s.eyedropper_sample = Some(node); - } // The highlighted characters' type objects and ranges: after Select All in threaded text, all // the text of every frame of the story, except a frame the attributes come from, in place of // the object selection. @@ -1285,6 +1282,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))], _ => vec![], }; + let target_ids = || targets.iter().copied().filter(|_| ranges.is_empty()).chain(ranges.iter().map(|(id, _)| *id)); + // Prepare every dimensional copy before changing defaults or the remembered sample. A bad + // source or target transform must refuse the entire pick, including a multi-frame range. + let characters = if attrs.character && !append { + let d = &s.doc()?.doc; + copied_character_styles(&node, target_ids().filter_map(|id| d.node(id)))? + } else { + HashMap::new() + }; // Character styles cannot represent object transparency. Refuse before even changing // paint defaults; applying it to the object would also alter the unselected characters. if attrs.appearance.transparency { @@ -1298,11 +1304,33 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { } } } - if !reverse { - picked.to_defaults(s, &attrs.appearance); - } + // The session's bounds check follows the selection. Check text targets reached through + // explicit IDs, a reverse pick, or a highlighted story as well. Paragraph changes can + // move point text even when character copying is disabled. + let text_ids: Vec<_> = if attrs.paragraph && !append && picked.text.is_some() { + let d = &s.doc()?.doc; + target_ids().filter(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))).collect() + } else { + characters.keys().copied().collect() + }; + let check_text_bounds = |d: &Document| { + if !text_ids.is_empty() && !crate::sanity::objects_in_range(d, &text_ids) { + return Err(bad(C, "sampled text would exceed the canvas (coordinates out of range)")); + } + Ok(()) + }; + // Remember the pick only after the document edit has passed its own validation. + let finish = |s: &mut Session, ids: &[NodeId]| { + if !reverse { + if !defaults_only { + s.eyedropper_sample = Some(node); + } + picked.to_defaults(s, &attrs.appearance); + } + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) + }; if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { - return Ok(json!({ "ids": [] })); + return Ok(finish(s, &[])); } if !ranges.is_empty() { let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); @@ -1312,24 +1340,26 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs))); let placed = picked.placed(Some(t.local_bounds())); - vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); + vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| Picked::run(st, &placed, &attrs, characters.get(&id))); if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); } super::typecmd::refresh_bounds(t); } + check_text_bounds(d)?; Ok(count_changed(d, &before, &ids)) })?; - return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); + return Ok(finish(s, &ids)); } // When every target comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Eyedropper", |d| { for id in &targets { if let Some(n) = d.node_mut(*id) { - picked.apply(n, &attrs, append); + picked.apply(n, &attrs, append, characters.get(id)); } } + check_text_bounds(d)?; // The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds // only the colors of the visible rows the source has. let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); @@ -1369,7 +1399,59 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let object = super::graph::capture_series_object_opacity(d, &objects); Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; - Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) + Ok(finish(s, &targets)) +} + +/// Read source typography in document space, then compensate for each target's transform. +/// Paint stays in its existing sampling path; character scale never changes object placement. +fn copied_character_styles<'a>(source: &Node, targets: impl Iterator) -> Result> { + const C: &str = "appearance.copyFrom"; + let mut styles = HashMap::new(); + let NodeKind::Text(source) = &source.kind else { return Ok(styles) }; + let Some(run) = source.runs.first() else { return Ok(styles) }; + let mut targets = targets + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((n.id, t)), + _ => None, + }) + .peekable(); + if targets.peek().is_none() { + return Ok(styles); + } + let effective = source.effective_char_style(&run.style).ok_or_else(|| bad(C, "source text transform is collapsed or unrepresentable"))?; + if !copied_dimensions_fit(&run.style, &effective) { + return Err(bad(C, "character dimensions cannot be represented with the source text transform")); + } + for (id, text) in targets { + let scale = text.style_scale().ok_or_else(|| bad(C, "target text transform is collapsed or unrepresentable"))?; + let mut local = effective.clone(); + local.size /= scale.points; + local.leading = local.leading.map(|v| v / scale.points); + local.baseline_shift /= scale.points; + local.tcy_up_down /= scale.points; + local.tcy_left_right /= scale.points; + local.h_scale /= scale.horizontal; + // As with dimensional type commands, reject overflow and underflow before layout. + if !copied_dimensions_fit(&effective, &local) { + return Err(bad(C, "character dimensions cannot be represented with the target text transform")); + } + styles.insert(id, local); + } + Ok(styles) +} + +/// Bound each converted type dimension and refuse loss to zero before layout reads it. +fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool { + [ + (from.size, to.size), + (from.leading.unwrap_or(0.0), to.leading.unwrap_or(0.0)), + (from.baseline_shift, to.baseline_shift), + (from.tcy_up_down, to.tcy_up_down), + (from.tcy_left_right, to.tcy_left_right), + (from.h_scale, to.h_scale), + ] + .iter() + .all(|(from, to)| to.is_finite() && to.abs() <= crate::MAX_COORD && (*from == 0.0 || *to != 0.0)) } /// What the Eyedropper picks up from an object. @@ -1422,8 +1504,8 @@ impl Picked { /// Copy character attributes and the enabled paint attributes through the same path for /// whole text objects and highlighted character ranges. - fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) { - if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) { + fn run(st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs, character: Option<&CharStyle>) { + if let Some(cs) = character { *st = CharStyle { fill: std::mem::take(&mut st.fill), stroke: std::mem::take(&mut st.stroke), @@ -1439,7 +1521,7 @@ impl Picked { /// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its /// stack instead. Type takes the paints on its characters, and character and paragraph /// attributes from type. - fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) { + fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool, character: Option<&CharStyle>) { let ap = &a.appearance; if ap.transparency { n.opacity = self.opacity; @@ -1456,7 +1538,7 @@ impl Picked { let placed = self.placed(Some(t.local_bounds())); let text = self.text.as_ref(); for r in &mut t.runs { - self.run(&mut r.style, &placed, a); + Self::run(&mut r.style, &placed, a, character); } if let Some((_, para)) = text.filter(|_| a.paragraph) { // The source's first paragraph's attributes, on every paragraph. diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 8de9dd5c84..300d9f6aa1 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -21,7 +21,7 @@ use super::clipboard::Clipboard; use super::clipboard::resources::SwatchChoices; use super::patterncmds::add_pattern; use super::swatch::str_list; -use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; +use super::swatchlib::{FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, shown_id, shown_library_id, stem}; use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; /// The extension of saved brush libraries: native documents. @@ -462,6 +462,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc if let Some(found) = s.brush_libraries.get(key) { return Some(found); } + if let Some(found) = concealed_library_id(s, key).and_then(|id| s.brush_libraries.get(&id)) { + return Some(found); + } if let Some(b) = BUILTIN.iter().find(|b| b.id == key) { return Some((builtin_info(b), builtin_library(key)?)); } @@ -476,20 +479,22 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc< fn list(s: &mut Session, _: &Value) -> Result { s.brush_libraries.rescan(); + let concealed = concealed_library_ids(s, []); let libs: Vec = libraries(s) .into_iter() .map(|l| { let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); - json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)}) + let (id, path) = (shown_id(&concealed, &l.id), s.brush_libraries.shown_path(&l.id)); + json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path}) }) .collect(); - Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() })) + Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.shown_user_dir() })) } fn get(s: &mut Session, p: &Value) -> Result { let (info, lib) = library_param(s, p, "brush.library")?; let brushes: Vec = lib.brushes.iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); - Ok(json!({"id": info.id, "name": info.name, "category": info.category, "brushes": brushes})) + Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "brushes": brushes})) } fn add(s: &mut Session, p: &Value) -> Result { @@ -545,7 +550,7 @@ fn add(s: &mut Session, p: &Value) -> Result { } let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n)); let applied: Vec = paths.iter().map(|id| id.0).collect(); - Ok(json!({"library": info.id, "names": doc_names, "added": added, "existing": existing, "applied": applied})) + Ok(json!({"library": shown_library_id(s, &info.id), "names": doc_names, "added": added, "existing": existing, "applied": applied})) } fn save(s: &mut Session, p: &Value) -> Result { @@ -571,5 +576,5 @@ fn load(s: &mut Session, p: &Value) -> Result { } BrushLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e)) })?; - Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) + Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()})) } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 1677d27f91..b4b231a22b 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -45,7 +45,7 @@ pub(crate) use symbols::{ }; use symbols::{ symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_instance_reset_transform, symbol_list, symbol_new, symbol_options, - symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, + symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_sort, symbol_spray, symbol_unused, symbol_update, }; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; @@ -312,6 +312,15 @@ pub fn specs() -> Vec { has_doc, symbol_unused ), + cmd!( + "symbol.sortByName", + "Sort by Name", + ["Window", "Symbols"], + None, + "{} sort the document's symbols by name, ignoring case (symbols whose names differ only in case keep their order), as one undo step; the current symbol stays current; no undo step is added when they are already in order", + has_doc, + symbol_sort + ), cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances), cmd!( "symbol.spray", diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 4c5cd89edf..b789f600b4 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -140,6 +140,27 @@ pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result { Ok(json!({ "names": names })) } +/// `symbol.sortByName`: the symbols in name order, ignoring case; a stable sort, so names that +/// differ only in case keep their order. The current symbol is stored by name first, so a document +/// whose current symbol is its first one keeps that symbol current. Nothing is changed, and no undo +/// step is added, when the symbols are already in order. +pub(super) fn symbol_sort(s: &mut Session, _: &Value) -> Result { + let key = |sym: &Symbol| sym.name.to_lowercase(); + let d = &s.doc()?.doc; + if d.symbols.is_sorted_by_key(key) { + return ok(); + } + let current = current_symbol(d).or_else(|| symbol_names(d).into_iter().next()); + s.edit("Sort by Name", |d, _| { + if let Some(name) = current { + d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); + } + d.symbols.sort_by_cached_key(key); + Ok(()) + })?; + ok() +} + fn symbol_names(d: &Document) -> Vec { d.symbols.iter().map(|s| s.name.clone()).collect() } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 65f1056895..bbc56dbc15 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -19,8 +19,8 @@ use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use super::edit::selected_roots; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, bool_or, check_numbers, cmd, color_value, f64_or, f64_req, has_selection, ids_param, - str_param, unique_name, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, f64_req, has_selection, ids_param, switch_or, + text_param, unique_name, }; pub fn specs() -> Vec { @@ -166,13 +166,15 @@ pub(crate) struct Scope { } impl Scope { - pub(crate) fn of(p: &Value) -> Self { - Self { - fill: bool_or(p, "fill", true), - stroke: bool_or(p, "stroke", true), - images: bool_or(p, "includeImages", true), - patterns: bool_or(p, "includePatterns", true), - } + /// The scope `p` gives; each switch left out (or null) is on. A switch that is not true or false, + /// such as `"false"`, is an error that names `cmd`. + pub(crate) fn of(p: &Value, cmd: &str) -> Result { + Ok(Self { + fill: switch_or(p, "fill", true, cmd)?, + stroke: switch_or(p, "stroke", true, cmd)?, + images: switch_or(p, "includeImages", true, cmd)?, + patterns: switch_or(p, "includePatterns", true, cmd)?, + }) } } @@ -414,18 +416,18 @@ impl ColorVisitor for Visit<'_, '_> { } pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { - run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f)) + run_pass(s, p, cmd, label, Recolor::new(Scope::of(p, cmd)?, f)) } /// [`recolor`] where `f` gives gray colors, so embedded images are stored with one gray channel. fn recolor_gray(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { - run_pass(s, p, cmd, label, Recolor { gray_images: true, ..Recolor::new(Scope::of(p), f) }) + run_pass(s, p, cmd, label, Recolor { gray_images: true, ..Recolor::new(Scope::of(p, cmd)?, f) }) } /// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch /// link and tint. pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { - run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links)) + run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p, cmd)?, f, links)) } /// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`; @@ -548,8 +550,9 @@ fn saturate(s: &mut Session, p: &Value) -> Result { fn adjust_balance(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.colors.adjustBalance"; check_numbers(p, &["r", "g", "b", "c", "m", "y", "k", "gray", "tint"], C)?; + let convert = switch_or(p, "convert", false, C)?; let given = |keys: &[&str]| keys.iter().any(|k| p.get(*k).is_some()); - let mode = match str_param(p, "mode") { + let mode = match text_param(p, "mode", C)? { Some(m) => m.to_ascii_lowercase(), None if given(&["c", "m", "y", "k"]) => "cmyk".into(), None if given(&["gray"]) => "gray".into(), @@ -592,11 +595,10 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result { (*tint, *c) = (t, base.tinted(t)); true }; - return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); + return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p, C)?, &shift)); } other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))), }; - let convert = bool_or(p, "convert", false); let f = move |c| { let out = adjust(c); if convert { out } else { keep_model(c, out) } @@ -703,7 +705,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { fn harmony(s: &mut Session, p: &Value) -> Result { const C: &str = "color.harmony"; let base = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?; - let rule = str_param(p, "rule").ok_or_else(|| bad(C, "missing `rule`"))?; + let rule = text_param(p, "rule", C)?.ok_or_else(|| bad(C, "missing `rule`"))?; let rule = Harmony::parse(rule).ok_or_else(|| { let ids: Vec = Harmony::ALL.iter().map(|h| h.id()).collect(); bad(C, format!("unknown rule `{rule}` ({})", ids.join("|"))) @@ -713,7 +715,7 @@ fn harmony(s: &mut Session, p: &Value) -> Result { if let Some(n) = p.get("steps").and_then(Value::as_u64) { opts.steps = n.min(u32::MAX as u64) as u32; } - if let Some(v) = str_param(p, "variation") { + if let Some(v) = text_param(p, "variation", C)? { opts.variation = Variation::parse(v).ok_or_else(|| bad(C, format!("unknown variation `{v}` (tintsShades|warmCool|vividMuted)")))?; } if let Some(a) = p.get("amount").and_then(Value::as_f64) { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 973c82af53..3c829e8a3e 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -162,7 +162,8 @@ fn constrained_turn(s: &Session, rotation: f64) -> f64 { } } -/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. +/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. A new path or +/// compound path is pixel-aligned while the document's Align New Objects to Pixel Grid is on. fn insert_look_turned( s: &mut Session, label: &str, @@ -180,6 +181,7 @@ fn insert_look_turned( let id = d.alloc_id(); let mut n = Node::new(id, kind); look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.name = name; n.bbox_angle = bbox_angle; let mut into = None; @@ -512,6 +514,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { n.name = Some(name.into()); n.blend = BlendMode::Screen; n.opacity = op; + super::object::align_new_object(d, &mut n); std::sync::Arc::new(n) }) .collect(); @@ -603,6 +606,7 @@ fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec, Node::path(d.alloc_id(), pd, Appearance::default()) }; look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.bbox_angle = bbox_angle; std::sync::Arc::new(n) }) @@ -752,7 +756,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; - t.area.fit = new_area_fit(s, a, "text.create")?; + new_area_fit(s, a, "text.create", &mut t)?; } t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { @@ -827,11 +831,16 @@ pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut Ok(()) } -/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes -/// them), else Auto Size when the "Auto Size New Area Type" preference is on. -pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result { +/// The fit new area type `t` gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes +/// them, `block` turning Dynamic Text on), else Auto Size when the "Auto Size New Area Type" +/// preference is on; and Block's `wordGap` and `lineGap`. +pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) -> Result<()> { let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None }; - Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default)) + let (word_gap, line_gap) = super::typecmd::block_gaps(p, c)?; + (t.area.fit, t.dynamic_text) = super::typecmd::fit_param(p, default, false, c)?.unwrap_or((default, false)); + t.area.word_gap = word_gap.unwrap_or(t.area.word_gap); + t.area.line_gap = line_gap.unwrap_or(t.area.line_gap); + Ok(()) } /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index cb1bb86fbd..7499e14943 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", has_doc, info ), @@ -42,7 +42,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ("brushes", "Brushes"), ("spotColors", "Spot Colors"), ("patterns", "Pattern Objects"), - ("gradients", "Gradient Swatches"), + ("gradients", "Gradient Objects"), ("symbols", "Symbols"), ("fonts", "Fonts"), ("fontDetails", "Font Details"), @@ -131,7 +131,8 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S /// The Document Info categories of `d` (only `category` when given) from `info` (what /// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a pattern. +/// rows of its objects filled or stroked with a pattern and with a gradient. +#[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, @@ -139,6 +140,7 @@ fn sections( images: &[ImageRow], repeats: &[(String, String)], pattern_objects: &[(String, String)], + gradient_objects: &[(String, String)], category: Option<&str>, ) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { @@ -183,7 +185,7 @@ fn sections( "brushes" => names("brushNames"), "spotColors" => names("spotColors"), "patterns" => pattern_objects.to_vec(), - "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), + "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), "fontDetails" => { @@ -254,19 +256,37 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result { Ok(v["text"].as_str().unwrap_or_default().to_string()) } -/// The type object `n` holds and the runs its layout draws, when it uses a pattern: the runs with a -/// glyph outline (list markers and ruby included) or an underline or strikethrough bar. Spaces and -/// line breaks draw no outline. A type object's characters carry their own fill and stroke, and the -/// type object's own fills and strokes paint on its characters, so only those runs paint. The type -/// is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +/// The pattern paint `p` uses: the pattern's name. +fn pattern_label(p: &Paint) -> Option { + match p { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + } +} + +/// The gradient paint `p` uses: the name of the gradient swatch it is linked to, else its kind +/// ("Linear gradient", "Radial gradient" or "Freeform gradient"). +fn gradient_label(p: &Paint) -> Option { + match p { + Paint::Gradient(g) => Some(g.swatch.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("{} gradient", g.gradient.kind.label()))), + _ => None, + } +} + +/// The type object `n` holds and the runs its layout draws, when it uses a pattern or a gradient: +/// the runs with a glyph outline (list markers and ruby included) or an underline or strikethrough +/// bar. Spaces and line breaks draw no outline. A type object's characters carry their own fill and +/// stroke, and the type object's own fills and strokes paint on its characters, so only those runs +/// paint. The type is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the +/// renderer lays it out. fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { let NodeKind::Text(t) = &n.kind else { return None }; - let is_pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)); let own = n.appearance.items.iter().any(|i| match i { - vectorcraft_doc::AppearanceItem::Fill(f) => is_pattern(&f.paint), - vectorcraft_doc::AppearanceItem::Stroke(s) => is_pattern(&s.paint), + vectorcraft_doc::AppearanceItem::Fill(f) => is_listed(&f.paint), + vectorcraft_doc::AppearanceItem::Stroke(s) => is_listed(&s.paint), }); - if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) { + if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) { return None; } let db = vectorcraft_text::FontDb::global(); @@ -277,42 +297,38 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T Some((t, painted)) } -/// The patterns `n`'s own fills and strokes paint, each once. A hidden fill or stroke and a stroke -/// with no width paint nothing, and neither does any fill or stroke of a guide (drawn in the guide -/// color), of a compound path's member (`members`: the compound path's own fills and strokes paint -/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint -/// only where an effect gives them a shape. -fn own_patterns<'a>( +/// The patterns or gradients (`label`: [`pattern_label`] or [`gradient_label`]) that `n`'s own +/// fills and strokes paint, each once. A hidden fill or stroke and a stroke with no width paint +/// nothing, and neither does any fill or stroke of a guide (drawn in the guide color), of a compound +/// path's member (`members`: the compound path's own fills and strokes paint it) or of type whose +/// layout draws nothing (`painted_runs`). An image's fills and strokes paint only where an effect +/// gives them a shape. +fn own_paints( d: &Document, - n: &'a Node, + n: &Node, members: &HashSet<*const Node>, painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, -) -> Vec<&'a str> { + label: fn(&Paint) -> Option, +) -> Vec { if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) || painted_runs.is_some_and(|(_, r)| r.is_empty()) + || !n.appearance.items.iter().any(|i| label(i.paint()).is_some()) { return vec![]; } - // The patterns an image's shaped fills and strokes paint (their effects can transform the - // pattern, so they are matched by name). - let shaped: Option> = matches!(n.kind, NodeKind::Image(_)).then(|| { - vectorcraft_render::effects::image_paint(Some(d), n) - .into_iter() - .filter_map(|(_, _, item)| match item.paint() { - Paint::Pattern { pattern, .. } => Some(pattern.clone()), - _ => None, - }) - .collect() - }); - let mut out: Vec<&str> = vec![]; + // The patterns or gradients an image's shaped fills and strokes paint (their effects can + // transform the paint, so they are matched by label). + let shaped: Option> = matches!(n.kind, NodeKind::Image(_)) + .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().filter_map(|(_, _, item)| label(item.paint())).collect()); + let mut out: Vec = vec![]; for item in &n.appearance.items { - let Paint::Pattern { pattern, .. } = item.paint() else { continue }; + let Some(name) = label(item.paint()) else { continue }; let paints = item.visible() && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) - && shaped.as_ref().is_none_or(|s| s.contains(pattern)); - if paints && !out.contains(&pattern.as_str()) { - out.push(pattern); + && shaped.as_ref().is_none_or(|s| s.contains(&name)); + if paints && !out.contains(&name) { + out.push(name); } } out @@ -354,6 +370,11 @@ fn info(s: &mut Session, p: &Value) -> Result { // `pattern_objects` and `pattern_rows` hold the `patternObjects` entries and the rows of the objects filled or stroked with a pattern, in walk order. let mut pattern_objects: Vec = vec![]; let mut pattern_rows: Vec<(String, String)> = vec![]; + // `gradient_objects`, `gradient_rows` and `gradients` hold the `gradientObjects` entries, the rows of the objects filled or stroked + // with a gradient, in walk order, and the gradients they use. + let mut gradient_objects: Vec = vec![]; + let mut gradient_rows: Vec<(String, String)> = vec![]; + let mut gradients = BTreeSet::new(); // The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once. // The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two. let mut seen: HashSet<*const Node> = HashSet::new(); @@ -412,27 +433,17 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - // The paint kinds this object's fills and strokes use, each once. - let mut kinds = BTreeSet::new(); - // The patterns this object's fills and strokes use, each once. - let mut used_patterns: Vec<&str> = vec![]; - let painted_runs = painted_runs(d, n); - for pattern in own_patterns(d, n, &members, painted_runs.as_ref()) { - patterns.insert(pattern.to_string()); - used_patterns.push(pattern); - } for item in &n.appearance.items { - let paint = item.paint(); - // Objects counts an object with a pattern when Pattern Objects lists it, below. - if let Paint::Gradient(_) = paint { - kinds.insert("gradients"); - } - if let Paint::Solid { swatch: Some(name), .. } = paint + if let Paint::Solid { swatch: Some(name), .. } = item.paint() && d.swatch(name).is_some_and(|s| s.spot) { spot.insert(name.clone()); } } + // The patterns and the gradients this object's fills and strokes paint, each once. + let painted_runs = painted_runs(d, n); + let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), pattern_label); + let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), gradient_label); // The characters' own paints: an inline graphic draws no outline and does not take its // run's paint (a list marker set in its run does), and a stroke with no width paints // nothing. @@ -440,23 +451,32 @@ fn info(s: &mut Session, p: &Value) -> Result { for run in painted.iter().filter_map(|i| t.runs.get(*i)) { let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); for paint in std::iter::once(&run.style.fill).chain(stroke) { - if let Paint::Pattern { pattern, .. } = paint { - patterns.insert(pattern.clone()); - if !used_patterns.contains(&pattern.as_str()) { - used_patterns.push(pattern); + for (used, label) in + [(&mut used_patterns, pattern_label as fn(&Paint) -> Option), (&mut used_gradients, gradient_label)] + { + if let Some(name) = label(paint) + && !used.contains(&name) + { + used.push(name); } } } } } + // Objects counts the objects that Pattern Objects and Gradient Objects list. if !used_patterns.is_empty() { let name = n.display_name(); + patterns.extend(used_patterns.iter().cloned()); + pattern_rows.push((name.clone(), used_patterns.join(", "))); pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns })); - pattern_rows.push((name, used_patterns.join(", "))); - kinds.insert("patterns"); + *counts.entry("patterns").or_default() += 1; } - for k in kinds { - *counts.entry(k).or_default() += 1; + if !used_gradients.is_empty() { + let name = n.display_name(); + gradients.extend(used_gradients.iter().cloned()); + gradient_rows.push((name.clone(), used_gradients.join(", "))); + gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients })); + *counts.entry("gradients").or_default() += 1; } if n.extras().mask.is_some() { *counts.entry("opacityMasks").or_default() += 1; @@ -506,10 +526,12 @@ fn info(s: &mut Session, p: &Value) -> Result { "patterns": d.patterns.len(), "patternNames": patterns, "patternObjects": pattern_objects, + "gradientNames": gradients, + "gradientObjects": gradient_objects, }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -583,12 +605,14 @@ mod tests { &json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}), ) .unwrap(); - s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); let i = s.execute("document.info", &json!({})).unwrap(); + // The type's characters take the current gradient fill, so it is a gradient object too. assert_eq!( (i["objects"]["paths"].as_u64(), i["objects"]["textObjects"].as_u64(), i["objects"]["gradients"].as_u64()), - (Some(1), Some(1), Some(1)) + (Some(1), Some(1), Some(2)) ); + assert_eq!((i["gradientObjects"][0]["id"].clone(), i["gradientObjects"][1]["id"].clone()), (r.clone(), t)); assert_eq!(i["fonts"][0], "Source Sans 3 Regular"); assert_eq!(i["document"]["artboards"][0]["width"], 200.0); s.execute("select.set", &json!({"ids": [r]})).unwrap(); @@ -842,6 +866,124 @@ mod tests { assert!(!text.contains("Unused"), "{text}"); } + /// Document Info › Gradient Objects lists each object filled or stroked with a gradient once, + /// with the gradients it uses (a linked swatch by name, else the gradient's kind), one in a + /// group and a type object whose characters use a gradient included; a gradient swatch no art + /// uses adds no row, paint that draws nothing is left out by the rules Pattern Objects follows, + /// and Objects counts the listed objects. With Selection Only it lists the objects in the + /// selection, each once, and the report writes the same rows. + #[test] + fn info_lists_the_gradient_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + s.execute("swatch.new", &json!({"name": "Ramp", "gradient": ramp("linear")})).unwrap(); + s.execute("swatch.new", &json!({"name": "Spare", "gradient": ramp("radial")})).unwrap(); + let (a, b, c, f) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0), square(&mut s, 0.0)); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Ramp"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "gradient": ramp("linear")})).unwrap(); + s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b], "gradient": ramp("radial")})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [f], "gradient": ramp("freeform")})).unwrap(); + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + // A type object whose characters are filled with the gradient. + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Ramp"})).unwrap(); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + // A hidden gradient fill and a gradient stroke with no width paint nothing, so neither object is listed. + let (d, e) = (square(&mut s, 100.0), square(&mut s, 200.0)); + s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Ramp"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [e], "gradient": ramp("linear")})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // Type with no characters, or of only spaces and a line break, paints nothing, and neither + // does a guide made from a path with a gradient. + let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Ramp"})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap(); + let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Ramp"})).unwrap(); + let guide = square(&mut s, 300.0); + s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Ramp"})).unwrap(); + s.execute("select.set", &json!({"ids": [guide]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + // Nor does a compound path's member's own gradient fill, or an image's gradient fill + // without an effect that gives it a shape. + let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap(); + let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone(); + let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance); + let pic = square(&mut s, 360.0); + s.execute("select.set", &json!({"ids": [pic]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let image = s.doc().unwrap().selection.objects[0]; + s.edit("own paint", |d, _| { + d.node_mut(member).unwrap().appearance = paint.clone(); + d.node_mut(image).unwrap().appearance = paint; + Ok(()) + }) + .unwrap(); + // Underlined spaces draw a bar in the gradient, so that type object is listed. + let bar = s.execute("text.create", &json!({"x": 0, "y": 340, "text": " "})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [bar], "swatch": "Ramp"})).unwrap(); + s.execute("select.set", &json!({"ids": [bar]})).unwrap(); + s.execute("text.setFormat", &json!({"underline": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": bar, "name": "Bar"})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + i["gradientObjects"], + json!([ + {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]}, + {"id": b, "name": "", "gradients": ["Radial gradient"]}, + {"id": f, "name": "", "gradients": ["Freeform gradient"]}, + {"id": t, "name": "Type", "gradients": ["Ramp"]}, + {"id": bar, "name": "Bar", "gradients": ["Ramp"]}, + ]) + ); + assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"])); + // Objects counts the objects that Gradient Objects lists. + assert_eq!(i["objects"]["gradients"].as_u64(), Some(5), "{}", i["objects"]); + s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["gradients"].is_null() && i["gradientObjects"] == json!([]), "{}", i["objects"]); + let i = s.execute("document.info", &json!({"category": "gradients"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("gradients"), Some("Gradient Objects"))); + assert_eq!( + i["sections"][0]["rows"], + json!([ + ["Both", "Ramp, Linear gradient"], + ["", "Radial gradient"], + ["", "Freeform gradient"], + ["Type", "Ramp"], + ["Bar", "Ramp"] + ]) + ); + // A selection that holds the group and the object inside it lists that object once. + for ids in [json!([group, b]), json!([b, group])] { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "gradients"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Radial gradient"]]), "{ids}"); + assert_eq!(i["objects"]["gradients"].as_u64(), Some(1), "{ids}"); + } + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "gradients"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["gradientNames"].clone()), (json!([["Type", "Ramp"]]), json!(["Ramp"]))); + let text = s.execute("document.info", &json!({"category": "gradients", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!( + text.contains("\nGRADIENT OBJECTS\nBoth: Ramp, Linear gradient\n: Radial gradient\n: Freeform gradient\nType: Ramp\nBar: Ramp\n"), + "{text}" + ); + assert!(!text.contains("Spare"), "{text}"); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index db792ab1c6..ae3fc521f0 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -13,8 +13,8 @@ use super::edit::selected_roots; use super::pathops::Beside; use super::select::{first_selectable, is_selectable, selectable_ids, walk_selectable}; use super::{ - CommandSpec, EngineError, Point, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ok, - str_param, switch_or, + CommandSpec, EngineError, Point, Result, Session, bad, checked_ids_param, cmd, f64_req, has_doc, has_selection, id_param, number_or, ok, + str_param, switch_or, text_param, }; pub fn specs() -> Vec { @@ -482,7 +482,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let ids = st.selection.objects.clone(); let saved = &st.doc.saved_selections; - let name = str_param(p, "name").and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved)); + let name = text_param(p, "name", C)?.and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved)); let existing = saved.iter().position(|x| x.name == name); if existing.is_none() && saved.len() >= SavedSelection::MAX { return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX))); @@ -577,10 +577,12 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result { for e in edits { let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?; let i = *index.get(name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?; + let delete = switch_or(e, "delete", false, C)?; + let new_name = text_param(e, "newName", C)?; let Some(slot) = after.get_mut(i) else { continue }; - if bool_or(e, "delete", false) { + if delete { *slot = None; - } else if let Some(n) = str_param(e, "newName").and_then(SavedSelection::clean_name) + } else if let Some(n) = new_name.and_then(SavedSelection::clean_name) && let Some(x) = slot { x.name = n; @@ -764,7 +766,7 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result { fn guide_add(s: &mut Session, p: &Value) -> Result { let pos = f64_req(p, "pos", "guide.add")?; - let vertical = bool_or(p, "vertical", false); + let vertical = switch_or(p, "vertical", false, "guide.add")?; let artboard = match p.get("artboard").filter(|v| !v.is_null()) { Some(v) => { let boards = &s.doc()?.doc.artboards; @@ -845,7 +847,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; let ids = guide_ids(&s.doc()?.doc); let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::>>()?; - if bool_or(p, "toggle", false) { + if switch_or(p, "toggle", false, "guide.select")? { // Added to (or taken from) what is selected, the art included. s.select(|_, sel| { for i in picked { @@ -924,7 +926,9 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { if moving.is_empty() { return Err(bad("guide.move", "no guide selected (or give an index)")); } - let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false)); + let dx = number_or(p, "dx", 0.0, "guide.move")?; + let dy = number_or(p, "dy", 0.0, "guide.move")?; + let copy = switch_or(p, "copy", false, "guide.move")?; if !s.doc()?.selection.is_empty() { let ids = super::edit::selected_roots(s)?; return super::object::apply_transform( diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 894d9b90ae..dadafaa1b8 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Document Setup", [], None, - "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", + "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), alignNewObjectsToPixelGrid?: bool (Align New Objects to Pixel Grid: paths and compound paths drawn from then on are pixel-aligned, as object.setPixelAligned; art already in the document, pasted, duplicated or placed keeps its own setting), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", has_doc, setup )] @@ -46,6 +46,7 @@ pub(crate) fn setup_json(d: &Document) -> Value { "smallCapsSize": s.small_caps_size, "exportText": s.export_text.id(), "backgroundContents": s.background.id(), + "alignNewObjectsToPixelGrid": s.align_new_objects_to_pixel_grid, }) } @@ -140,6 +141,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?, "exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?, "backgroundContents" => s.background = background_param(v, C)?, + "alignNewObjectsToPixelGrid" => s.align_new_objects_to_pixel_grid = flag(k, v)?, // Read-only values `document.setup` reports. "gridColorsName" | "warnings" => {} _ => return Err(bad(C, format!("unknown setting `{k}`"))), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a8a91f0af..108b8593c9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -9,9 +9,9 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ - CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param, - f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, - index, ok, point_param, str_param, switch_or, + CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param, + f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, + number_or, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), @@ -184,7 +184,7 @@ pub fn specs() -> Vec { "Paste in Front", ["Edit"], Some("Cmd+F"), - "{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one), artboards?: [index…] (a copy on each of these artboards, placed as on `artboard`, the copies in artboard order; `artboard` wins when both are given; default while the Artboard tool is chosen with several artboards selected: those; art copied from no artboard keeps its place on the first and its offset to it on the others; an index the document lacks, a repeated one or an empty list is an error)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::Front) ), @@ -193,7 +193,7 @@ pub fn specs() -> Vec { "Paste in Back", ["Edit"], Some("Cmd+B"), - "{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?, artboard?: index, artboards?: [index…] (both as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::Back) ), @@ -202,7 +202,7 @@ pub fn specs() -> Vec { "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), - "{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?, artboard?: index, artboards?: [index…] (both as edit.pasteInFront)} paste where the objects were copied (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace) ), @@ -237,7 +237,7 @@ pub fn specs() -> Vec { } fn file_close(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).map(index).or(s.active_index()).ok_or(EngineError::NoDocument)?; + let i = opt_u64(p, "file.close", "index")?.map(index).or(s.active_index()).ok_or(EngineError::NoDocument)?; if !s.close_document(i) { return Err(bad("file.close", "no such document")); } @@ -269,7 +269,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result { fn doc_node(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; let mut opts = slice_opts(p, "document.node")?; - let summary = bool_or(p, "summary", false); + let summary = switch_or(p, "summary", false, "document.node")?; if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { // The full object JSON is never truncated: a slice without `summary` would // otherwise silently return the whole subtree. @@ -663,7 +663,8 @@ fn undo_group_begin(s: &mut Session, p: &Value) -> Result { } fn undo_group_end(s: &mut Session, p: &Value) -> Result { - s.end_undo_group(bool_or(p, "cancel", false)); + let cancel = switch_or(p, "cancel", false, "edit.undoGroup.end")?; + s.end_undo_group(cancel); ok() } @@ -672,10 +673,14 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result { /// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document /// under an interaction that would then preview on, or cancel to, the document from before (#1719). /// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the -/// document. +/// document. A preview that Real-time Drawing off left unapplied is applied first, as the tool's own +/// commit applies it; when it fails, the interaction is canceled. pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> { let typing = typing_in_progress(s); - s.commit_interaction()?; + match s.unapplied_interaction_preview().filter(|_| !s.previewing) { + Some((cmd, p)) if s.preview(&cmd, &p).is_err() => s.cancel_interaction()?, + _ => s.commit_interaction()?, + } if typing { s.set_tool_option("endTyping", &Value::Bool(true)); } @@ -1231,6 +1236,9 @@ impl PasteMode { fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result { let choices = SwatchChoices::parse(mode.label(), p)?; + if mode == PasteMode::Offset { + check_numbers(p, &["dx", "dy"], "edit.paste")?; + } // Taken out of the session for the edit (and put back whatever happens). let clip = std::mem::take(&mut s.clipboard); let r = paste_clip(s, p, mode, &clip, &choices); @@ -1246,15 +1254,79 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_ paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices) } -/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the -/// objects were on the artboard they were copied from, on that one; without it (or a copy from no -/// artboard), where they were. -fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine { - let target = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| doc.artboards.get(i)); - match (target, clip.source_artboard) { - (Some(t), Some(src)) => Affine::translate(t.rect.origin() - src.origin()), - _ => Affine::IDENTITY, +/// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard` +/// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several +/// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same +/// ones); `None` when none is named. A named index that is not an artboard of the document, a +/// repeated one or an empty list is an error. +fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result>> { + let n = s.doc()?.doc.artboards.len(); + let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n); + let single = match p.get("artboard") { + Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?), + None => None, + }; + let listed = match p.get("artboards") { + Some(v) => { + let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?; + let mut all = list + .iter() + .map(one) + .collect::>>() + .ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?; + all.sort_unstable(); + if all.windows(2).any(|w| w[0] == w[1]) { + return Err(bad(cmd, "`artboards` names an artboard twice")); + } + Some(all) + } + None => None, + }; + if let Some(i) = single { + return Ok(Some(vec![i])); } + if listed.is_some() { + return Ok(listed); + } + let picked = artboard_tool_picks(s); + if picked.is_empty() { + return Ok(None); + } + s.note_journal("artboards", json!(picked)); + Ok(Some(picked)) +} + +/// The artboards of the active document the Artboard tool has selected, in artboard order, while +/// that tool is chosen with two or more of them selected; otherwise none. Indices the document +/// does not have are left out. +pub(crate) fn artboard_tool_picks(s: &Session) -> Vec { + if s.tool_id() != "artboard" { + return Vec::new(); + } + let n = s.active().map_or(0, |d| d.doc.artboards.len()); + let mut picked: Vec = s.tool_options_of("artboard")["selected"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok())) + .filter(|i| *i < n) + .collect(); + picked.sort_unstable(); + picked.dedup(); + if picked.len() < 2 { + picked.clear(); + } + picked +} + +/// Paste in Place, in Front or in Back onto artboards `targets`: one copy on each, where the objects +/// were on the artboard they were copied from. A copy from no artboard keeps its place on the first +/// target and its offset to that one on the others; without targets, the objects go where they were. +fn onto_artboards(doc: &Document, targets: Option<&[usize]>, clip: &Clipboard) -> Vec { + let rects: Vec<_> = targets.unwrap_or_default().iter().filter_map(|i| doc.artboards.get(*i)).map(|a| a.rect.origin()).collect(); + let Some(first) = rects.first().copied() else { return vec![Affine::IDENTITY] }; + let src = clip.source_artboard.map_or(first, |r| r.origin()); + rects.iter().map(|o| Affine::translate(*o - src)).collect() } /// Insert clipboard resources atomically in the active editable context using the requested placement mode. @@ -1268,6 +1340,12 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho ), _ => None, }; + let targets = match mode { + PasteMode::Front => paste_targets(s, p, "edit.pasteInFront")?, + PasteMode::Back => paste_targets(s, p, "edit.pasteInBack")?, + PasteMode::InPlace => paste_targets(s, p, "edit.pasteInPlace")?, + _ => None, + }; let st = s.doc()?; let same_doc = clip.source_doc == Some(st.uid); let parent = st.target_parent_for_path()?; @@ -1315,14 +1393,15 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho (_, Some((_, dv))) => vec![Affine::translate(dv)], (PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") { Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())), - None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))), + None => Affine::translate((number_or(p, "dx", off, mode.label())?, number_or(p, "dy", off, mode.label())?)), }], (PasteMode::AllArtboards, None) => { let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default(); st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect() } - // In place, in front, in back: onto the active artboard when the UI names it. - _ => vec![onto_artboard(&st.doc, p, clip)], + // In place, in front, in back: onto the active artboard when the UI names it, or onto each + // artboard the Artboard tool has selected. + _ => onto_artboards(&st.doc, targets.as_deref(), clip), }; // Guides alone don't follow a plain paste's offset or the view centre: they stay where they // were. With objects they move as the objects do. @@ -1489,8 +1568,8 @@ fn layer_named(d: &mut Document, name: &str) -> NodeId { fn duplicate(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; - let dx = f64_or(p, "dx", 0.0); - let dy = f64_or(p, "dy", 0.0); + let dx = number_or(p, "dx", 0.0, "edit.duplicate")?; + let dy = number_or(p, "dy", 0.0, "edit.duplicate")?; let ids = s.edit("Duplicate", |d, sel| duplicate_in(d, sel, &roots, Affine::translate((dx, dy))))?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index e4c8d79450..5584073563 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -881,3 +881,56 @@ fn move_takes_several_effects_that_land_together_in_order() { assert!(s.execute("effect.move", &json!({"from": [0, 9], "to": 0, "fromItem": null, "ids": [id.0]})).is_err()); assert_eq!(node(&s, id).appearance, before); } + +/// A 3D effect's shadow is set with `effect.setParams` in one undo step, saved in the native file, +/// drawn in exports and kept as a path by Expand Appearance; settings out of range are refused +/// without an undo step. +#[test] +fn a_3d_shadow_is_one_undo_step_saved_exported_and_expanded() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for (params, why) in [ + (json!({"shadowBounds": 401}), "shadowBounds must be a number from 10 to 400"), + (json!({"shadowPosition": "left"}), "shadowPosition must be behind or below"), + ] { + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap_err().to_string(); + assert!(e.contains(why), "{params}: {e}"); + assert_eq!(s.doc().unwrap().doc, before); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + let params = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 20, "shadowBounds": 150}); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let art = effects::solid_art(&node(&s, id)).unwrap(); + let shadow = art.children().unwrap()[0].clone(); + assert!(matches!(shadow.kind, NodeKind::Path { .. })); + // Saved and opened again, the effect keeps its shadow settings and draws the same shadow. + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + let fx = &restored.node(id).unwrap().appearance.effects[0]; + for (key, value) in params.as_object().unwrap() { + assert_eq!(fx.params.get(key), Some(value), "{key}"); + } + assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art); + // SVG export draws it: one more shape than without the shadow. + let shapes = |s: &mut Session| { + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + vectorcraft_testkit::svg_shapes(&svg) + }; + let with = shapes(&mut s); + s.execute("edit.undo", &json!({})).unwrap(); + let without = shapes(&mut s); + assert_eq!(with, without + 1); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + let mut shadows = 0; + s.doc().unwrap().doc.walk(|n| { + if n.path_data().is_some() && n.path_data() == shadow.path_data() && n.appearance.items == shadow.appearance.items { + shadows += 1; + } + }); + assert_eq!(shadows, 1); +} diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 8dce25b11a..1d39e2b4e4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -36,6 +36,9 @@ pub const MAX_STEPS: u32 = 1000; const FREEFORM_MESH: u32 = 8; /// Columns (around the centre) of the mesh a radial gradient expands to. const RADIAL_MESH_COLS: usize = 8; +/// Largest turn between a conic gradient's mesh columns, so their straight outer edges stay +/// close enough to a circle to reach past the box. +const CONIC_MESH_GAP: f64 = 1.0 / 32.0; pub fn specs() -> Vec { vec![ @@ -44,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -1279,7 +1282,13 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32 /// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that /// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial: /// `0..t1`, each level's ellipse centred on the way from the focal point to the start). +/// A conic gradient also has its placement, the box and the radius in its frame that reaches +/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center). A +/// diamond gradient has its frame ([`vectorcraft_color::GradientGeom::diamond_frame`]; `None` +/// when it has no extent), the box and the level of the square that reaches its corners. struct Frame { + conic: Option<(vectorcraft_color::GradientGeom, Rect, f64)>, + diamond: Option<(Option, Rect, f64)>, radial: bool, start: Point, focal: Point, @@ -1309,7 +1318,19 @@ impl Frame { } // Keep a sliver of span so the art maps back invertibly. t1 = t1.max(t0 + 1e-6); - Self { radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 } + let conic = (g.gradient.kind == GradientKind::Conic).then(|| { + let to_unit = geom.conic_frame().inverse(); + let reach = corners.iter().map(|c| (to_unit * *c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6); + // Past the corners, and far enough out that the chords between columns at most + // CONIC_MESH_GAP of a turn apart stay outside them. + let reach = if reach.is_finite() { reach * 1.01 / (std::f64::consts::PI * CONIC_MESH_GAP).cos() } else { 1.0 }; + (geom, bounds, reach) + }); + let diamond = (g.gradient.kind == GradientKind::Diamond).then(|| { + let reach = corners.iter().map(|c| geom.param_at(GradientKind::Diamond, *c)).fold(0.0f64, f64::max); + (geom.diamond_frame(), bounds, if reach.is_finite() { reach.max(1e-6) * 1.01 } else { 1.0 }) + }); + Self { conic, diamond, radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 } } /// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t` @@ -1325,6 +1346,46 @@ impl Frame { /// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour /// changes, so it paints the gradient exactly. fn mesh(&self, g: &Gradient) -> GradientMesh { + if let Some((geom, _, reach)) = &self.conic { + // Columns around the center where the color changes, at most CONIC_MESH_GAP of a turn + // apart so the outer edge stays past the box; the seam is two columns in one place. + let br = breaks(g, 0.0, 1.0, CONIC_MESH_GAP); + let us: Vec = br.iter().map(|(t, _)| *t).collect(); + let frame = geom.conic_frame(); + let at = |u: f64, v: f64| { + let a = u * std::f64::consts::TAU; + frame * Point::new(reach * v * a.cos(), reach * v * a.sin()) + }; + return GradientMesh::from_grid(&us, &[0.0, 1.0], &at, &|_, c, _| br[c].1); + } + if let Some((frame, b, reach)) = &self.diamond { + let Some(frame) = frame else { + // No extent: the whole box is the last stop. + let last = g.sample_keep_model(1.0); + let at = |u: f64, v: f64| Point::new(b.x0 + u * b.width(), b.y0 + v * b.height()); + return GradientMesh::from_grid(&[0.0, 1.0], &[0.0, 1.0], &at, &|_, _, _| last); + }; + // Rings of squares where the color changes, with columns at the corners: each patch + // lies between two rings along one side, where the level runs straight across it. + let br = breaks(g, 0.0, *reach, f64::INFINITY); + let ts: Vec = br.iter().map(|(t, _)| t / reach).collect(); + let at = |u: f64, v: f64| *frame * (diamond_perimeter(u).to_vec2() * (v * reach)).to_point(); + const CORNERS: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0]; + let mut mesh = GradientMesh::from_grid(&CORNERS, &ts, &at, &|r, _, _| br[r].1); + // Every column is at a corner, where `from_grid`'s tangent is the mean of the two + // sides: each handle along a ring points a third of the way along its own side, so + // the sides stay straight. + let side = |v: f64, from: usize, to: Option<&f64>| to.map_or(vectorcraft_geom::Vec2::ZERO, |&u| (at(u, v) - at(CORNERS[from], v)) / 3.0); + for (r, &v) in ts.iter().enumerate() { + for c in 0..CORNERS.len() { + if let Some(point) = mesh.points.get_mut(r * CORNERS.len() + c) { + point.handles[0] = side(v, c, CORNERS.get(c + 1)); + point.handles[1] = side(v, c, c.checked_sub(1).and_then(|i| CORNERS.get(i))); + } + } + } + return mesh; + } // Rings as close as the columns around them, so the mesh draws smoothly. let gap = if self.radial { (self.t1 - self.t0) / RADIAL_MESH_COLS as f64 } else { f64::INFINITY }; let br = breaks(g, self.t0, self.t1, gap); @@ -1340,13 +1401,44 @@ impl Frame { /// `n` solid shapes covering the box in paint order, each coloured as the gradient in the /// middle of its band: for a linear gradient, rectangles from each band's start to the far - /// end, for a radial one, nested ellipses (largest first). Each paints over the ones - /// below except its band, so no seam shows between bands. With translucent stops each shape - /// is just its band, so nothing doubles up. + /// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the + /// center from each band's start to the seam, for a diamond one, nested squares (largest + /// first). Each paints over the ones below except its band, so no seam shows between bands. + /// With translucent stops each shape is just its band, so nothing doubles up. fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { + let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); + if let Some((geom, b, _)) = &self.conic { + return geom + .conic_wedges(*b, n, opaque) + .into_iter() + .map(|(pts, t)| (PathData::single(SubPath::polyline(&pts, true)), g.sample_keep_model(t as f32))) + .collect(); + } + if let Some((frame, b, reach)) = &self.diamond { + let Some(frame) = frame else { + return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))]; + }; + // Nested squares about the center, largest first, as a radial gradient's ellipses. + let step = reach / n as f64; + let square = |t: f64| -> SubPath { + let pts: Vec = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect(); + SubPath::polyline(&pts, true) + }; + return (0..n) + .rev() + .map(|k| { + let mut shape = PathData::single(square((k + 1) as f64 * step)); + if !opaque && k > 0 { + let mut hole = square(k as f64 * step); + hole.reverse(); + shape.subpaths.push(hole); + } + (shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32)) + }) + .collect(); + } let step = (self.t1 - self.t0) / n as f64; let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); - let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); if self.radial { let ellipse = |t: f64| { let c = self.focal + (self.start - self.focal) * t; @@ -1376,3 +1468,14 @@ impl Frame { .collect() } } + +/// The point a fraction `u` (0..1) of the way around the square with corners (1, 0), (0, 1), +/// (-1, 0) and (0, -1), from the first: a diamond gradient's last stop in its frame. +fn diamond_perimeter(u: f64) -> Point { + const CORNERS: [(f64, f64); 5] = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0), (1.0, 0.0)]; + let s = if u.is_finite() { u.clamp(0.0, 1.0) * 4.0 } else { 0.0 }; + let i = (s.floor() as usize).min(3); + let f = s - i as f64; + let ((x0, y0), (x1, y1)) = (CORNERS.get(i).copied().unwrap_or((1.0, 0.0)), CORNERS.get(i + 1).copied().unwrap_or((1.0, 0.0))); + Point::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f) +} diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 04a443ed4c..6478cc81b8 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -2,7 +2,7 @@ //! save, export and serialize by path or bytes (base64), the format table, `command.batch` and //! `action.batch` (the Actions panel menu's Batch…). //! -//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images). +//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, HP-GL, raster images). //! - `action_batch`: play an action on every file in a folder or every data set. //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). @@ -50,6 +50,7 @@ mod pdfimport; pub mod pngtext; pub mod ppi; pub mod progress; +mod psdlayers; pub mod psdread; mod pxrread; mod save; @@ -204,6 +205,8 @@ mod tests_eps; #[cfg(test)] mod tests_dxfimport; #[cfg(test)] +mod tests_hpgl; +#[cfg(test)] mod tests_metafile; #[cfg(test)] @@ -218,6 +221,8 @@ mod tests_pdfx; #[cfg(test)] mod tests_psd; #[cfg(test)] +mod tests_psdlayers; +#[cfg(test)] mod tests_psdread; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 74d9185ea3..5e920e1b3c 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -11,7 +11,7 @@ use serde_json::json; use super::super::{Result, Session, bad}; #[cfg(not(target_arch = "wasm32"))] -use super::super::{bool_or, find_command, str_param}; +use super::super::{check_switches, find_command, switch_or, text_param}; #[cfg(target_arch = "wasm32")] pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result { @@ -68,12 +68,24 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { )); } } - let source = str_param(p, "source").unwrap_or("folder"); - let destination = str_param(p, "destination").unwrap_or("none"); + // Every option is checked before the destination folder is made. + let source = text_param(p, "source", C)?.unwrap_or("folder"); + if !matches!(source, "folder" | "datasets") { + return Err(bad(C, "`source` must be `folder` or `datasets`")); + } + let destination = text_param(p, "destination", C)?.unwrap_or("none"); if !matches!(destination, "none" | "saveAndClose" | "folder") { return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`")); } - let dest_folder = str_param(p, "destinationFolder").map(absolute_path); + let dest_folder = text_param(p, "destinationFolder", C)?.map(absolute_path); + text_param(p, "folder", C)?; + let label = text_param(p, "label", C)?.unwrap_or("Batch").to_string(); + let dataset_file_naming = text_param(p, "datasetFileNaming", C)?.unwrap_or("fileDataSetName"); + let override_open = switch_or(p, "overrideOpen", true, C)?; + let stop_on_error = switch_or(p, "stopOnError", true, C)?; + let override_save = switch_or(p, "overrideSave", true, C)?; + let override_export = switch_or(p, "overrideExport", true, C)?; + check_switches(p, &["includeSubdirs"], C)?; if destination == "folder" { let Some(ref folder) = dest_folder else { return Err(bad(C, "`destinationFolder` is required when destination is `folder`")); @@ -81,17 +93,15 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { may_write(folder)?; std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?; } - let label = str_param(p, "label").unwrap_or("Batch").to_string(); - let override_open = bool_or(p, "overrideOpen", true); let run = Run { label: &label, steps: cmds, - stop_on_error: bool_or(p, "stopOnError", true), - override_save: bool_or(p, "overrideSave", true), - override_export: bool_or(p, "overrideExport", true), + stop_on_error, + override_save, + override_export, destination, dest_folder: dest_folder.as_deref(), - dataset_file_naming: p.get("datasetFileNaming").map_or("fileDataSetName", |v| v.as_str().unwrap_or("")), + dataset_file_naming, }; match source { "folder" => batch_folder(s, C, p, override_open, &run), @@ -186,9 +196,9 @@ fn retargeting_save(c: &Value, redirected: bool) -> bool { fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::{absolute_path, may_read}; - let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; + let folder = text_param(p, "folder", cmd)?.map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; may_read(&folder)?; - let include_subdirs = bool_or(p, "includeSubdirs", false); + let include_subdirs = switch_or(p, "includeSubdirs", false, cmd)?; let files = collect_files(&folder, include_subdirs, 0)?; if files.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files"))); @@ -716,6 +726,11 @@ fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result std::path::PathBuf { let p = dir.join(name); std::fs::write( @@ -789,6 +820,46 @@ mod tests { assert!(dest.join("b.svg").is_file(), "b.svg written"); } + #[test] + fn batch_refuses_options_of_the_wrong_type_before_it_writes() { + let src = tmp("types-src"); + let dest = tmp("types-dest"); + write_svg(&src, "a.svg", "#f00"); + let mut s = Session::new(); + let cases = [ + ("stopOnError", json!("false"), "`stopOnError` must be true or false"), + ("overrideOpen", json!(0), "`overrideOpen` must be true or false"), + ("overrideSave", json!("true"), "`overrideSave` must be true or false"), + ("overrideExport", json!([]), "`overrideExport` must be true or false"), + ("includeSubdirs", json!(1), "`includeSubdirs` must be true or false"), + ("source", json!(1), "`source` must be a string"), + ("source", json!("files"), "`source` must be `folder` or `datasets`"), + ("destination", json!(true), "`destination` must be a string"), + ("folder", json!(5), "`folder` must be a string"), + ("label", json!(5), "`label` must be a string"), + ("datasetFileNaming", json!(3), "`datasetFileNaming` must be a string"), + ]; + for (key, value, message) in cases { + let out = dest.join(key); + let mut p = json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": out.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}], + }); + p[key] = value; + let e = s.execute("action.batch", &p).unwrap_err().to_string(); + assert!(e.contains(message), "{key}: {e}"); + assert!(!out.exists(), "{key}: the destination folder was created"); + } + let e = s + .execute("action.batch", &json!({"folder": src.to_string_lossy(), "destination": "folder", "destinationFolder": 7, "commands": [{"command": "object.unlockAll"}]})) + .unwrap_err() + .to_string(); + assert!(e.contains("`destinationFolder` must be a string"), "{e}"); + assert!(s.active().is_none(), "no file was opened"); + } + #[cfg(unix)] #[test] fn batch_folder_keeps_backslash_in_source_file_name() { diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs index ed2b44ad3a..c81877632c 100644 --- a/crates/engine/src/cmd/fileio/affinity/paint.rs +++ b/crates/engine/src/cmd/fileio/affinity/paint.rs @@ -188,17 +188,24 @@ impl Builder { let kind = match g.kind { AfKind::Linear => GradientKind::Linear, AfKind::Radial => GradientKind::Radial, - AfKind::Conical => { - self.warn("conical gradients import as radial gradients"); - GradientKind::Radial - } + AfKind::Conical => GradientKind::Conic, }; let (u, v): (Vec2, Vec2) = (x - o, y - o); - if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { + if kind.is_centered() && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { self.warn("skewed elliptical gradients import as upright ellipses"); } let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; - let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; + let geom = match kind { + // A conic sweeps from unit +x towards unit +y: its unit frame carried onto the + // page as a transform carries it (mirrored when the transform is a reflection). + GradientKind::Conic => { + let mut geom = GradientGeom { start: Point::ZERO, end: Point::new(1.0, 0.0), aspect: 1.0, focal: None }; + geom.transform(m, GradientKind::Conic); + geom + } + GradientKind::Radial => GradientGeom { start: o, end: x, aspect, focal: None }, + _ => GradientGeom { start: o, end: x, aspect: 1.0, focal: None }, + }; let mut gp = GradientPaint::new(Gradient::new(kind, stops)); gp.angle = (-(u.y)).atan2(u.x).to_degrees(); gp.geom = Some(geom); diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index dec69c9eee..18bc78b5d9 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -76,7 +76,9 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { /// starts (one for all the steps in that document), so a step may open, switch or close documents. /// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets /// `artboard`, as the Artboards panel shows it in the document the step runs in: past that -/// document's last artboard, the last one. A step that resizes the active artboard +/// document's last artboard, the last one; a Paste in Place, in Front or in Back step that names +/// `artboards` keeps them, and one that names neither, while the Artboard tool is chosen with +/// several artboards selected, pastes on those. A step that resizes the active artboard /// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`. /// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them. fn run_steps( @@ -104,9 +106,14 @@ fn run_steps( } else { crate::FIT_ACTIVE_ARTBOARD.contains(&id.as_str()).then_some("index") }; + // A paste step that names its `artboards` keeps them; one that names neither key, while the + // Artboard tool is chosen with several artboards selected, pastes on those. + let names_artboards = id.starts_with("edit.pasteIn") + && (params.get("artboards").is_some() || (params.get("artboard").is_none() && !crate::cmd::edit::artboard_tool_picks(s).is_empty())); if let Some(a) = artboard && let Some(key) = key && params.get(key).is_none() + && !names_artboards && let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1)) && let Some(o) = params.as_object_mut() { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 81e8168d06..dd2b257f2a 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. A switch (includeSubdirs, overrideOpen, overrideSave, overrideExport, stopOnError) that is not true or false, or a source, folder, destination, destinationFolder, datasetFileNaming or label that is not a string, is an error before any folder is made or file opened; null reads as left out. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index ac33326994..a64c74e035 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -1,8 +1,8 @@ //! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As //! → EPS Options): the options parsed from the params, transparency flattened with a flattener //! preset, one file of the art's bounds or one per artboard (with a central file of the whole -//! document when there are several and art shows), the preview and thumbnail rendered, -//! and the native document embedded for reopening. +//! document when there are several and art shows), the preview rendered and the thumbnail made +//! from it, and the native document embedded for reopening. use std::borrow::Cow; @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Document; use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster}; use vectorcraft_geom::{Point, Rect}; +use vectorcraft_render::Rendered; use super::super::flatten::{FlattenOptions, expand_transparent_tile_images, flatten_expanded}; use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param}; @@ -231,14 +232,38 @@ fn pages(doc: &Document, set: &Settings) -> Result, Rect, Poi } } -/// `rect` of `doc` drawn at a pixel a point (fewer when larger than `max` a side), straight RGBA, -/// transparent or on white. -fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result { +/// Pixels a point for `rect`: one, or fewer when it is larger than `max` a side. +fn fit(rect: Rect, max: f64) -> f64 { let long = rect.width().max(rect.height()); - let scale = if long > max { max / long } else { 1.0 }; + if long > max { max / long } else { 1.0 } +} + +/// `rect` of `doc` drawn at [`fit`]'s scale for `max`, transparent or on white. +fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result { + let scale = fit(rect, max); vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?; - let img = r.render_region(doc, rect, scale, !transparent); - Ok(Raster { width: img.width, height: img.height, rgba: img.to_straight() }) + Ok(r.render_region(doc, rect, scale, !transparent)) +} + +/// `img` as the writer takes it: straight RGBA. +fn straight(img: &Rendered) -> Raster { + Raster { width: img.width, height: img.height, rgba: img.to_straight() } +} + +/// The thumbnail of `rect` made from `preview`, the same rect rendered larger: at the size a +/// thumbnail render of `rect` has, each pixel the average of the preview's pixels under it, on white. +fn thumbnail_of(preview: &Rendered, rect: Rect) -> Result { + let (width, height) = vectorcraft_render::raster_size(rect, fit(rect, THUMBNAIL)).map_err(|e| bad(C, e))?; + let mut rgba = preview.area_resized(width, height).map_err(|e| bad(C, e))?.pixels; + // Premultiplied over white: each channel gains the white the pixel's alpha leaves showing. + for px in rgba.as_chunks_mut::<4>().0 { + let clear = 255 - px[3]; + for c in &mut px[..3] { + *c = c.saturating_add(clear); + } + px[3] = 255; + } + Ok(Raster { width, height, rgba }) } /// What the file carries to reopen: `doc` (with only artboard `board` when the file is one of @@ -318,11 +343,18 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let mut renderer = vectorcraft_render::Renderer::new(); for (board, region, origin) in pages { let rect = vectorcraft_eps::preview_rect(region, origin); - let preview = match set.eps.preview { + let shown = match set.eps.preview { Preview::None => None, kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?), }; - let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?; + // The thumbnail is made from the preview; only a file without one renders it. + let thumbnail = match (set.thumbnails, &shown) { + (false, _) => None, + (true, Some(img)) => Some(thumbnail_of(img, rect)?), + (true, None) => Some(straight(&render(&mut renderer, doc, rect, THUMBNAIL, false)?)), + }; + let preview = shown.as_ref().map(straight); + drop(shown); let o = EpsOptions { region, origin, diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 02dda6f0f0..62e5b78e6b 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -9,14 +9,14 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes}; use crate::DocState; -use super::super::{Result, Session, bad, bool_or, edit, f64_or, str_param}; +use super::super::{Result, Session, bad, check_switches, edit, number_or, switch_or, text_param}; use super::encode::single_artboard; use super::{ ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return, }; pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { - let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; + let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?; let expanded = expand_presets(s, "document.serialize", p)?; let p = with_black_output(s, &expanded); let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?; @@ -53,11 +53,11 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { } pub(super) fn export(s: &mut Session, p: &Value) -> Result { - let path = str_param(p, "path"); + let path = text_param(p, "path", "document.export")?; if let Some(path) = path { super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?; } - let f = writable("document.export", str_param(p, "format"), path)?; + let f = writable("document.export", text_param(p, "format", "document.export")?, path)?; let expanded = expand_presets(s, "document.export", p)?; let p = with_black_output(s, &expanded); let (doc, display_doc_title) = source(s, f, &p, "document.export")?; @@ -86,7 +86,7 @@ pub fn export_doc<'a>(st: &'a DocState, f: &Format, p: &Value) -> Result(st: &'a DocState, p: &Value) -> Result> { - if !bool_or(p, "selectedOnly", false) { + if !switch_or(p, "selectedOnly", false, "document.export")? { return Ok(Cow::Borrowed(&*st.doc)); } let keep: HashSet = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)).into_iter().collect(); @@ -237,7 +237,8 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> { - if f.id == "txt" && bool_or(p, "selectionOnly", false) { + check_switches(p, &["selectedOnly"], cmd)?; + if switch_or(p, "selectionOnly", false, cmd)? && f.id == "txt" { let doc = selection(s, cmd, true)?.0; let display_doc_title = super::has_own_title(&doc); return Ok((Cow::Owned(doc), display_doc_title)); @@ -309,11 +310,11 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do /// are guides, not artwork, and are left out. pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportSelection"; - if let Some(path) = str_param(p, "path") { + let path = text_param(p, "path", C)?; + if let Some(path) = path { super::check_not_lossy_overwrite(s.doc()?, path, p, C)?; } - let path = str_param(p, "path"); - let f = writable(C, str_param(p, "format"), path)?; + let f = writable(C, text_param(p, "format", C)?, path)?; let expanded = expand_presets(s, C, p)?; let p = with_black_output(s, &expanded); let (d, bounds) = selection(s, C, f.id == "txt")?; @@ -327,11 +328,12 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { /// `transparent`, with the encoder's warnings. pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportForOffice"; - if let Some(path) = str_param(p, "path") { + let path = text_param(p, "path", C)?; + if let Some(path) = path { super::check_not_lossy_overwrite(s.doc()?, path, p, C)?; } - let path = str_param(p, "path"); - let ppi = f64_or(p, "ppi", 150.0); + let ppi = number_or(p, "ppi", 150.0, C)?; + let transparent = switch_or(p, "transparent", false, C)?; let doc = &s.doc()?.doc; let artboard = match p.get("artboard") { None | Some(Value::Null) => 0, @@ -340,7 +342,7 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { if artboard >= doc.artboards.len() { return Err(bad(C, format!("no artboard {artboard} (0-based; the document has {})", doc.artboards.len()))); } - let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" }; + let background = if transparent { "transparent" } else { "white" }; let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard })); let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?; // The pixel size, from the PNG header. diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 23aeb83bb0..b04104f70f 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -479,6 +479,8 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, + // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them. + reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false), Format { id: "tga", label: "Targa", @@ -493,7 +495,8 @@ pub const FORMATS: &[Format] = &[ // Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a // vector app for a raster editor's documents. reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false), - // Photoshop's large document format: read like a PSD (its merged image), never written. + // The large document form of PSD: read like a PSD (its layers, or its merged image), never + // written. reader("psb", "PSB", &["psb"], "image/x-psb", true), // PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't. reader("ps", "PostScript", &["ps"], "application/postscript", false), @@ -551,6 +554,10 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", @@ -564,9 +571,10 @@ pub const OPEN_EXTS: &[&str] = &[ /// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. +/// The packaging declares no type for HP-GL plot files either. pub const UNASSOCIATED_EXTS: &[&str] = &[ "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", - "heic", "heif", "jxl", + "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", ]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares @@ -640,6 +648,10 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 58fff3cad1..fde1ed273f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -134,6 +134,17 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) { return format("svg"); } + // HP-GL has no signature: a file that starts with plotter instructions, named as one or with an + // extension no other format (text, the formats VectorCraft names as unsupported, and the files + // File › Open takes in other ways included) reads. + let ext = super::extension(name); + let other = by_name.is_some_and(|f| f.id != "hpgl") + || super::TEXT_EXTS.contains(&ext.as_str()) + || super::unsupported(&ext).is_some() + || super::opens_as_other(&ext); + if !other && vectorcraft_cad::hpgl::is_hpgl(bytes) { + return format("hpgl"); + } by_name } @@ -487,11 +498,25 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (doc, warnings) = super::metafile::import(bytes)?; (doc, warnings, false) } + "hpgl" => { + let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?; + (i.document, i.warnings, false) + } "affinity" => { let i = super::affinity::import(&title, bytes)?; preview_only = i.preview_only; (i.doc, i.warnings, false) } + // A PSD or PSB file's layers become layers (`layers`, default on); without layers, or with + // `layers: false`, its merged image opens. Layers that can't be read leave the merged image. + "psd" | "psb" if opts.layers => { + let cms = vectorcraft_color::cms::active(); + match super::psdlayers::import(bytes, opts.hidden_layers, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)) { + Ok(Some((doc, warnings))) => (doc, warnings, false), + Ok(None) => (raster_doc(&title, bytes)?, vec![], false), + Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false), + } + } _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 365edbedbf..1f166797f8 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -42,7 +42,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "compatibility", ty: "string", default: "\"1.7\"", - description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0", + description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0 (a pdfA2b file at 1.4 with a page longer than 14,400 pt is written as 1.6, with a UserUnit as at 1.6, and a warning; see document.exportPdf)", }, FormatOption { name: "preserveEditing", @@ -99,7 +99,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "longPagesInPoints", ty: "boolean", default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", }, FormatOption { name: "createLayers", @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5; apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 2a67cf4a45..2a0dede17f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -28,8 +28,10 @@ pub struct LoadOptions { /// What a PDF's text becomes. pub text_as: TextAs, /// A PDF's optional content groups become layers (else one layer per page, without the art - /// that is off). + /// that is off), and a PSD or PSB file's layers become layers (else its merged image opens). pub layers: bool, + /// A PSD or PSB file's hidden layers come in, hidden (`hiddenLayers`; else they are left out). + pub hidden_layers: bool, /// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]). pub dxf: vectorcraft_cad::ImportOptions, /// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its @@ -57,6 +59,7 @@ impl Default for LoadOptions { grays: Grays::default(), text_as: TextAs::default(), layers: true, + hidden_layers: false, dxf: Default::default(), editing_data: true, trust_editing_data: false, @@ -69,7 +72,8 @@ impl Default for LoadOptions { impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, - /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`), + /// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false), + /// `editingData` (true | false | `always`), /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF /// import options). pub fn from_params(cmd: &str, p: &Value) -> Result { @@ -95,6 +99,10 @@ impl LoadOptions { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, }; + let hidden_layers = match p.get("hiddenLayers").filter(|v| !v.is_null()) { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "hiddenLayers must be true or false"))?, + }; let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) { None => (true, false), Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true), @@ -108,7 +116,21 @@ impl LoadOptions { .ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?, }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, legacy_names, native: true }) + Ok(Self { + pages, + crop, + password, + color_mode, + grays, + text_as, + layers, + hidden_layers, + dxf, + editing_data, + trust_editing_data, + legacy_names, + native: true, + }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/psdlayers.rs b/crates/engine/src/cmd/fileio/psdlayers.rs new file mode 100644 index 0000000000..c3c45029a0 --- /dev/null +++ b/crates/engine/src/cmd/fileio/psdlayers.rs @@ -0,0 +1,1529 @@ +//! File › Open of a PSD or PSB file with its layers as objects. The import option that converts +//! layers to objects +//! () +//! keeps as much of the layer structure as it can, merges the layers of a clipping mask into one +//! layer, and discards hidden layers that need merging; the rest of what this module does is +//! VectorCraft's own. Read from the layer records of the public PSD file format specification +//! (). +//! +//! Each layer becomes a layer of the document, with the layer's name, visibility, opacity and blend +//! mode, holding its pixels as an image at their place on the canvas. A layer group becomes a layer +//! whose sublayers are the group's layers (a pass-through group lets them blend with what is below +//! it; any other group is isolated). A layer mask becomes the layer's opacity mask: a gray image +//! whose outside is hidden or shown as the mask's default color says, kept and switched off when +//! the mask is disabled. A layer's opacity is multiplied by its fill opacity, except in a layer +//! that others are clipped to, whose fill opacity fades only its own pixels. A solid color fill +//! layer that stores no pixels and has no vector mask comes in as an image of its color over the +//! canvas (an RGB, CMYK, Grayscale, Lab or HSB color); type, shape and other fill layers come in as +//! the pixels the file stores for them. +//! +//! Layers clipped to the layer below them are merged into it as one image (named after them all, +//! joined with " + "), shown where the layer below has pixels. Layers clipped to an adjustment +//! layer, or to a fill layer drawn over the whole canvas, show over the whole canvas. A layer in a +//! blend mode VectorCraft doesn't have (Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear +//! Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color, Lighter Color) is merged with the +//! layers below it in its group. A Dissolve layer's partly transparent pixels are merged as opaque +//! or transparent, opaque at the share of its pixels their transparency gives, in a fixed pattern +//! that differs from layer to layer. When a merge's canvases would need more memory than allowed, +//! the layer comes in alone: in the Normal mode, or unclipped. +//! Hidden layers are left out unless `hidden` asks for them. A hidden layer that would need a merge +//! (one in a mode VectorCraft lacks, one that layers are clipped to, one clipped to another, or +//! one below a merged layer) is always left out, with the layers clipped to it. Adjustment layers, +//! fill layers that store no pixels and whose fill isn't read or whose shape is a vector mask, +//! layer effects that are switched on, knockouts and vector masks aren't applied; the warnings +//! count them and the hidden layers left out. +//! +//! The file is untrusted: every count, length and size is checked before anything is allocated, +//! groups nest at most [`MAX_DEPTH`] deep (layers deeper than a document may nest merge into the +//! group around them, with a warning), each layer's pixels, like the canvases one step of a +//! merge holds together, must fit the memory allowed, and all the pixels decoded and the canvases +//! the merges make must fit [`TOTAL_DECODED`] times that. + +use std::cell::Cell; +use std::sync::Arc; + +use image::RgbaImage; +use vectorcraft_color::BlendMode; +use vectorcraft_color::blend::{blend_rgb, composite}; +use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; +use vectorcraft_doc::{Document, ImageObject, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask}; +use vectorcraft_geom::Affine; + +use super::load::{RasterImage, as_png}; +use super::psdread::{Header, Reader, byte, header, layer_channel, layer_info, long_key, row_bytes, sample, to_rgb}; +use crate::cmd::place::text::mac_roman; + +/// Most layer groups inside one another. +pub(super) const MAX_DEPTH: usize = 64; +/// The pixels all layers and masks decode to, merges' second readings included, and the canvases +/// the merges make may take this many times the memory allowed for one image. +const TOTAL_DECODED: u64 = 8; +/// Most channels one layer has (colors, transparency and masks), as many as a document may. +const MAX_CHANNELS: usize = 56; +/// The fewest bytes a layer record takes. +const MIN_RECORD: usize = 34; +const DAMAGED: &str = "the PSD file's layer records are damaged"; + +/// The keys of the tagged blocks that make a layer an adjustment layer. +const ADJUSTMENTS: [&[u8; 4]; 18] = [ + b"levl", b"curv", b"brit", b"blnc", b"hue ", b"hue2", b"selc", b"mixr", b"grdm", b"phfl", b"expA", b"vibA", b"thrs", b"nvrt", b"post", b"clrL", + b"blwh", b"CgEd", +]; + +/// A layer's blend mode. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Blend { + Mode(BlendMode), + /// A group's Pass Through. + Pass, + /// A mode VectorCraft doesn't have: the layer is merged with the layers below it. + Other(Other), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Other { + Dissolve, + LinearBurn, + LinearDodge, + VividLight, + LinearLight, + PinLight, + HardMix, + Subtract, + Divide, + DarkerColor, + LighterColor, +} + +impl Other { + fn label(self) -> &'static str { + match self { + Other::Dissolve => "Dissolve", + Other::LinearBurn => "Linear Burn", + Other::LinearDodge => "Linear Dodge", + Other::VividLight => "Vivid Light", + Other::LinearLight => "Linear Light", + Other::PinLight => "Pin Light", + Other::HardMix => "Hard Mix", + Other::Subtract => "Subtract", + Other::Divide => "Divide", + Other::DarkerColor => "Darker Color", + Other::LighterColor => "Lighter Color", + } + } +} + +/// The blend mode a record's key names (an unknown key: Normal). +fn blend(key: &[u8; 4]) -> Blend { + use BlendMode as M; + let mode = match key { + b"pass" => return Blend::Pass, + b"diss" => return Blend::Other(Other::Dissolve), + b"lbrn" => return Blend::Other(Other::LinearBurn), + b"lddg" => return Blend::Other(Other::LinearDodge), + b"vLit" => return Blend::Other(Other::VividLight), + b"lLit" => return Blend::Other(Other::LinearLight), + b"pLit" => return Blend::Other(Other::PinLight), + b"hMix" => return Blend::Other(Other::HardMix), + b"fsub" => return Blend::Other(Other::Subtract), + b"fdiv" => return Blend::Other(Other::Divide), + b"dkCl" => return Blend::Other(Other::DarkerColor), + b"lgCl" => return Blend::Other(Other::LighterColor), + b"dark" => M::Darken, + b"mul " => M::Multiply, + b"idiv" => M::ColorBurn, + b"lite" => M::Lighten, + b"scrn" => M::Screen, + b"div " => M::ColorDodge, + b"over" => M::Overlay, + b"sLit" => M::SoftLight, + b"hLit" => M::HardLight, + b"diff" => M::Difference, + b"smud" => M::Exclusion, + b"hue " => M::Hue, + b"sat " => M::Saturation, + b"colr" => M::Color, + b"lum " => M::Luminosity, + _ => M::Normal, + }; + Blend::Mode(mode) +} + +/// A rectangle of the canvas, in pixels: top, left, bottom, right. +#[derive(Clone, Copy, Debug, Default)] +struct Bounds { + top: i32, + left: i32, + bottom: i32, + right: i32, +} + +impl Bounds { + fn read(r: &mut Reader<'_>) -> Result { + let mut v = || r.u32().map(|v| v as i32); + Ok(Self { top: v()?, left: v()?, bottom: v()?, right: v()? }) + } + /// Width and height (0 when empty or inverted). + fn size(self) -> (usize, usize) { + let side = |a: i32, b: i32| usize::try_from(i64::from(b) - i64::from(a)).unwrap_or(0); + (side(self.left, self.right), side(self.top, self.bottom)) + } +} + +/// What a layer holds besides plain pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Content { + Pixels, + /// A type layer: its pixels come in. + Type, + /// An adjustment layer: not applied. + Adjustment, + /// A solid color fill layer: its stored pixels, or its color over the whole canvas when it + /// stores none (nothing when the color can't be read, or when its shape is a vector mask). + Solid(Option), + /// A gradient or pattern fill layer: its stored pixels, if any. + Fill, +} + +/// A solid color fill layer's color. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Paint { + /// sRGB, 0..1. + Rgb([f32; 3]), + /// Ink amounts, 0..1. + Cmyk([f32; 4]), +} + +/// A layer mask's place and its color outside it. +#[derive(Clone, Copy, Debug)] +struct MaskInfo { + bounds: Bounds, + default: u8, + disabled: bool, + /// The mask's values, and its default color, are read inverted. + inverted: bool, +} + +/// One layer record: a layer, or a group's opening or closing divider. +struct Record<'a> { + name: String, + bounds: Bounds, + /// Channel id (−1 transparency, −2 the layer mask, 0.. the colors) and its data, compression + /// method first. + channels: Vec<(i16, &'a [u8])>, + blend: Blend, + /// The layer's opacity, 0..1. + opacity: f32, + /// Its fill opacity, 0..1. + fill: f32, + clipped: bool, + hidden: bool, + mask: Option, + /// The section divider type: 0 a layer, 1 or 2 a group (open or closed in the panel), 3 the + /// end of a group (the record below its layers). + divider: u32, + content: Content, + effects: bool, + vector_mask: bool, + knockout: bool, +} + +/// The layer records of `info` (the layer count, the records, then their channels' data). +fn records(info: &[u8], psb: bool) -> Result>, String> { + let mut r = Reader::new(info); + let count = usize::from((r.u16()? as i16).unsigned_abs()); + if count.saturating_mul(MIN_RECORD) > info.len() { + return Err(DAMAGED.into()); + } + let mut heads = Vec::with_capacity(count); + for _ in 0..count { + heads.push(record(&mut r, psb)?); + } + let mut out = Vec::with_capacity(count); + for (mut rec, lengths) in heads { + rec.channels = lengths.into_iter().map(|(id, n)| r.take(n).map(|d| (id, d))).collect::>()?; + out.push(rec); + } + Ok(out) +} + +/// One record, with the lengths of its channels' data (which follows every record). +fn record<'a>(r: &mut Reader<'a>, psb: bool) -> Result<(Record<'a>, Vec<(i16, usize)>), String> { + let bounds = Bounds::read(r)?; + let n = usize::from(r.u16()?); + if n > MAX_CHANNELS { + return Err(DAMAGED.into()); + } + let mut lengths = Vec::with_capacity(n); + for _ in 0..n { + let id = r.u16()? as i16; + lengths.push((id, r.length(psb)?)); + } + if &r.array::<4>()? != b"8BIM" { + return Err(DAMAGED.into()); + } + let blend = blend(&r.array()?); + let opacity = f32::from(r.u8()?) / 255.0; + let clipped = r.u8()? != 0; + // Bit 1: hidden. + let hidden = r.u8()? & 2 != 0; + r.u8()?; + let extra = r.section(false)?; + let mut rec = Record { + name: String::new(), + bounds, + channels: vec![], + blend, + opacity, + fill: 1.0, + clipped, + hidden, + mask: None, + divider: 0, + content: Content::Pixels, + effects: false, + vector_mask: false, + knockout: false, + }; + rec.read_extra(extra, psb)?; + Ok((rec, lengths)) +} + +impl Record<'_> { + /// The layer mask, blending ranges, name and tagged blocks after the record's fixed part. A + /// damaged mask, blending ranges or name is an error; a damaged tagged block keeps what was + /// read before it. + fn read_extra(&mut self, extra: &[u8], psb: bool) -> Result<(), String> { + let damaged = |_| DAMAGED.to_string(); + let mut e = Reader::new(extra); + let mask = e.section(false).map_err(damaged)?; + self.mask = match mask_info(mask) { + Some(info) => Some(info), + None if mask.is_empty() => None, + None => return Err(DAMAGED.into()), + }; + // The blending ranges. + e.section(false).map_err(damaged)?; + // A Pascal string padded to a multiple of 4 bytes, length byte included, in the system's + // encoding (its Unicode name follows in `luni`), read as Mac Roman as psd-tools (MIT) reads + // it: , `psd/layer_and_mask.py`. + let n = usize::from(e.u8().map_err(damaged)?); + let name = e.take(n).map_err(damaged)?; + self.name = name.iter().map(|&b| mac_roman(b)).collect(); + e.take((4 - (n + 1) % 4) % 4).map_err(damaged)?; + // Effects in a descriptor (`lfx2`, `lmfx`) say whether they are switched on; the older + // `lrFX` block is counted whenever it is there. + let (mut effects, mut old_effects) = (None, false); + while e.rest().len() >= 12 { + let (Ok(signature), Ok(key)) = (e.array::<4>(), e.array::<4>()) else { break }; + if !matches!(&signature, b"8BIM" | b"8B64") { + break; + } + let Ok(data) = e.section(psb && long_key(&key)) else { break }; + match &key { + b"luni" => { + if let Some(name) = unicode(data) { + self.name = name; + } + } + b"lsct" | b"lsdk" => { + if let Some(t) = data.get(..4) { + self.divider = u32::from_be_bytes([t[0], t[1], t[2], t[3]]); + } + if data.get(4..8) == Some(b"8BIM") + && let Some(Ok(key)) = data.get(8..12).map(<[u8; 4]>::try_from) + { + self.blend = blend(&key); + } + } + b"iOpa" => self.fill = data.first().map_or(1.0, |&v| f32::from(v) / 255.0), + b"TySh" | b"tySh" => self.content = Content::Type, + b"SoCo" => self.content = Content::Solid(solid_color(data)), + b"GdFl" | b"PtFl" => self.content = Content::Fill, + b"lfx2" | b"lmfx" => effects = Some(effects == Some(true) || effects_on(data)), + b"lrFX" => old_effects = true, + b"vmsk" | b"vsms" => self.vector_mask = true, + // Shallow or deep knockout. + b"knko" => self.knockout = data.first().is_some_and(|&k| k != 0), + k if ADJUSTMENTS.contains(&k) => self.content = Content::Adjustment, + _ => {} + } + // Some writers pad blocks further: skip to the next signature. + for _ in 0..3 { + if e.rest().is_empty() || e.rest().starts_with(b"8BIM") || e.rest().starts_with(b"8B64") { + break; + } + let _ = e.take(1); + } + } + self.effects = effects.unwrap_or(old_effects); + Ok(()) + } + + /// Does the record store no pixels (bounds of no area, or no color channel)? + fn stores_nothing(&self) -> bool { + let (w, h) = self.bounds.size(); + w == 0 || h == 0 || !self.channels.iter().any(|(id, _)| *id >= 0) + } + + /// Is the layer a fill drawn from its color over the whole canvas (a solid color fill that + /// stores no pixels and has no vector mask)? + fn color_over_canvas(&self) -> bool { + matches!(self.content, Content::Solid(Some(_))) && self.stores_nothing() && !self.vector_mask + } + + /// Does the layer cover the whole canvas without drawing (an adjustment layer, or a fill that + /// stores no pixels, has no vector mask and isn't drawn from its color)? Layers clipped to it + /// show over the whole canvas. + fn covers_without_drawing(&self) -> bool { + match self.content { + Content::Adjustment => true, + Content::Solid(None) | Content::Fill => self.stores_nothing() && !self.vector_mask, + _ => false, + } + } + + /// Is the layer a fill that stores no pixels and whose shape is a vector mask, so that it + /// isn't drawn? + fn undrawn_shape(&self) -> bool { + matches!(self.content, Content::Solid(_) | Content::Fill) && self.stores_nothing() && self.vector_mask + } +} + +/// Does an effects descriptor (`lfx2`, `lmfx`) have its master switch on and an effect switched on? +/// Read as psd-tools (MIT) reads them, a missing switch being off: `masterFXSwitch` and each +/// effect's `enab` (, `api/effects.py`). +fn effects_on(data: &[u8]) -> bool { + // A key, then its type `bool`, then one byte. + let values = |key: &'static [u8]| data.windows(key.len() + 1).filter(move |w| w.starts_with(key)).filter_map(|w| w.last().map(|&v| v != 0)); + values(b"masterFXSwitchbool").any(|on| on) && values(b"enabbool").any(|on| on) +} + +/// The layer mask data's place, default color, disabled flag (bit 1) and invert flag (bit 2). +fn mask_info(data: &[u8]) -> Option { + let mut r = Reader::new(data); + let bounds = Bounds::read(&mut r).ok()?; + let default = r.u8().ok()?; + let flags = r.u8().ok()?; + Some(MaskInfo { bounds, default, disabled: flags & 2 != 0, inverted: flags & 4 != 0 }) +} + +/// A `luni` block's name: a count of UTF-16 units, then the units. +fn unicode(data: &[u8]) -> Option { + let mut r = Reader::new(data); + let n = usize::try_from(r.u32().ok()?).ok()?; + let units: Vec = r.take(n.checked_mul(2)?).ok()?.as_chunks::<2>().0.iter().map(|u| u16::from_be_bytes(*u)).collect(); + Some(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()) +} + +/// The color of a solid color fill layer's descriptor, from the values after its color keys: red, +/// green and blue (0..255, or 0..1 in the `redFloat` keys), cyan, magenta, yellow and black (0..100 +/// percent ink), gray (0..100 percent black), Lab (L 0..100, a and b) or hue (degrees), saturation +/// and brightness (0..100). `None` for a color in another model. +fn solid_color(data: &[u8]) -> Option { + // A key, then `doub` and a double, or `UntF`, a unit and a double. + let value = |key: &[u8]| -> Option { + let at = data.windows(key.len()).position(|w| w == key)? + key.len(); + let at = match data.get(at..at + 4)? { + b"doub" => at + 4, + b"UntF" => at + 8, + _ => return None, + }; + let v = f64::from_be_bytes(data.get(at..at + 8)?.try_into().ok()?); + v.is_finite().then_some(v) + }; + let unit = |v: f64| v.clamp(0.0, 1.0) as f32; + let rgb = |keys: [&[u8]; 3], scale: f64| -> Option<[f32; 3]> { + Some([unit(value(keys[0])? / scale), unit(value(keys[1])? / scale), unit(value(keys[2])? / scale)]) + }; + if let Some(c) = rgb([b"Rd ", b"Grn ", b"Bl "], 255.0).or_else(|| rgb([b"redFloat", b"greenFloat", b"blueFloat"], 1.0)) { + return Some(Paint::Rgb(c)); + } + if let (Some(c), Some(m), Some(y), Some(k)) = (value(b"Cyn "), value(b"Mgnt"), value(b"Ylw "), value(b"Blck")) { + return Some(Paint::Cmyk([c, m, y, k].map(|v| unit(v / 100.0)))); + } + if let Some(g) = value(b"Gry ") { + return Some(Paint::Rgb([unit(1.0 - g / 100.0); 3])); + } + if let (Some(l), Some(a), Some(b)) = (value(b"Lmnc"), value(b"A "), value(b"B ")) { + let [r, g, b] = lab_to_srgb(Lab { l: l as f32, a: a as f32, b: b as f32 }); + return Some(Paint::Rgb([r, g, b].map(|v| v.clamp(0.0, 1.0)))); + } + let (h, s, b) = (value(b"H ")?, value(b"Strt")?, value(b"Brgh")?); + Some(Paint::Rgb(hsb(h, unit(s / 100.0), unit(b / 100.0)))) +} + +/// Hue `h` (degrees), saturation `s` and brightness `b` (0..1) as RGB. +fn hsb(h: f64, s: f32, b: f32) -> [f32; 3] { + let h = (h.rem_euclid(360.0) / 60.0) as f32; + let f = |n: f32| { + let k = (n + h) % 6.0; + b - b * s * k.min(4.0 - k).clamp(0.0, 1.0) + }; + [f(5.0), f(3.0), f(1.0)] +} + +/// The layer tree: records in the order the file stores them, bottom first. +enum Item { + Layer(usize), + /// A group's record and its layers. + Group(usize, Vec), +} + +fn tree(records: &[Record<'_>]) -> Result, String> { + let mut stack: Vec> = vec![vec![]]; + for (i, rec) in records.iter().enumerate() { + match rec.divider { + 3 => { + if stack.len() > MAX_DEPTH { + return Err("the PSD file's layer groups are nested too deeply".into()); + } + stack.push(vec![]); + } + 1 | 2 => { + // A group without its end divider holds nothing. + let children = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { vec![] }; + if let Some(top) = stack.last_mut() { + top.push(Item::Group(i, children)); + } + } + _ => { + if let Some(top) = stack.last_mut() { + top.push(Item::Layer(i)); + } + } + } + } + // Groups the file never closes: their layers join the group around them. + while stack.len() > 1 { + let inner = stack.pop().unwrap_or_default(); + if let Some(top) = stack.last_mut() { + top.extend(inner); + } + } + Ok(stack.pop().unwrap_or_default()) +} + +/// Straight RGBA, 0..1, at `left`, `top` on the canvas. +#[derive(Clone, Default)] +struct Canvas { + left: i64, + top: i64, + width: usize, + height: usize, + px: Vec<[f32; 4]>, +} + +impl Canvas { + fn from_image(left: i64, top: i64, img: &RgbaImage) -> Self { + let px = img.pixels().map(|p| p.0.map(|v| f32::from(v) / 255.0)).collect(); + Self { left, top, width: img.width() as usize, height: img.height() as usize, px } + } + fn get(&self, x: i64, y: i64) -> Option<[f32; 4]> { + let (x, y) = (usize::try_from(x - self.left).ok()?, usize::try_from(y - self.top).ok()?); + if x >= self.width || y >= self.height { + return None; + } + self.px.get(y * self.width + x).copied() + } + /// The canvas as an image, cropped to its painted pixels; `None` when nothing is painted. + fn to_image(&self) -> Option<(i64, i64, RgbaImage)> { + let painted = |x: usize, y: usize| self.px.get(y * self.width + x).is_some_and(|p| p[3] > 0.0); + let rows: Vec = (0..self.height).filter(|&y| (0..self.width).any(|x| painted(x, y))).collect(); + let (&y0, &y1) = (rows.first()?, rows.last()?); + let cols: Vec = (0..self.width).filter(|&x| (y0..=y1).any(|y| painted(x, y))).collect(); + let (&x0, &x1) = (cols.first()?, cols.last()?); + let img = RgbaImage::from_fn((x1 - x0 + 1) as u32, (y1 - y0 + 1) as u32, |x, y| { + let p = self.px.get((y0 + y as usize) * self.width + x0 + x as usize).copied().unwrap_or_default(); + image::Rgba(p.map(byte)) + }); + Some((self.left + x0 as i64, self.top + y0 as i64, img)) + } +} + +/// The blend function of a mode VectorCraft doesn't have, on straight RGB. +fn blend_other(mode: Other, b: [f32; 3], s: [f32; 3]) -> [f32; 3] { + let each = |f: fn(f32, f32) -> f32| [0, 1, 2].map(|i| f(b[i].clamp(0.0, 1.0), s[i].clamp(0.0, 1.0)).clamp(0.0, 1.0)); + // Darker and Lighter Color compare the total of the channel values. + let total = |c: [f32; 3]| c[0] + c[1] + c[2]; + let burn = |b: f32, s: f32| blend_rgb(BlendMode::ColorBurn, [b; 3], [s; 3])[0]; + let dodge = |b: f32, s: f32| blend_rgb(BlendMode::ColorDodge, [b; 3], [s; 3])[0]; + match mode { + // Dissolve's pixels are opaque or transparent (`coverage`); the opaque ones replace. + Other::Dissolve => s, + Other::LinearBurn => each(|b, s| b + s - 1.0), + Other::LinearDodge => each(|b, s| b + s), + Other::VividLight => [0, 1, 2].map(|i| { + let (b, s) = (b[i].clamp(0.0, 1.0), s[i].clamp(0.0, 1.0)); + if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) } + }), + Other::LinearLight => each(|b, s| b + 2.0 * s - 1.0), + Other::PinLight => each(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), + Other::HardMix => each(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), + Other::Subtract => each(|b, s| b - s), + Other::Divide => each(|b, s| if s <= 0.0 { if b <= 0.0 { 0.0 } else { 1.0 } } else { b / s }), + Other::DarkerColor => { + if total(s) < total(b) { + s + } else { + b + } + } + Other::LighterColor => { + if total(s) > total(b) { + s + } else { + b + } + } + } +} + +/// Source-over compositing of straight `s` onto straight `b` in `mode`, as +/// [`vectorcraft_color::blend::composite`] does for the modes it has. +fn over(mode: Blend, b: [f32; 4], s: [f32; 4]) -> [f32; 4] { + let other = match mode { + Blend::Mode(m) => return composite(m, b, s), + Blend::Pass => return composite(BlendMode::Normal, b, s), + Blend::Other(o) => o, + }; + let (ab, a) = (b[3].clamp(0.0, 1.0), s[3].clamp(0.0, 1.0)); + let ao = a + ab * (1.0 - a); + if ao <= 0.0 { + return [0.0; 4]; + } + let mixed = blend_other(other, [b[0], b[1], b[2]], [s[0], s[1], s[2]]); + let c = |i: usize| ((1.0 - ab) * s[i] + ab * mixed[i]) * a + (1.0 - a) * ab * b[i]; + [c(0) / ao, c(1) / ao, c(2) / ao, ao] +} + +/// What one entry of a layer list becomes: a layer of the document. +struct Unit<'i> { + name: String, + visible: bool, + opacity: f32, + blend: Blend, + /// The record whose layer mask the layer takes. + mask_of: Option, + /// The record the layer comes from (each record has its own Dissolve pattern). + record: usize, + body: Body<'i>, + /// While layers are clipped to it: how its merged canvas is shaped. + clip: Option, +} + +impl Unit<'_> { + /// Is it what a merge of layers in modes VectorCraft lacks made: one image, visible, at full + /// opacity in the Normal mode, without a mask? + fn is_merge(&self) -> bool { + matches!(self.body, Body::Merged(_)) + && self.clip.is_none() + && self.mask_of.is_none() + && self.visible + && self.opacity >= 1.0 + && self.blend == Blend::Mode(BlendMode::Normal) + } +} + +/// The shape of a layer merged with the layers clipped to it. +enum Clip { + /// The layer's own transparency at each pixel of its canvas; the canvas holds its pixels at its + /// fill opacity, the clipped layers composited over them. + Shape(Vec), + /// The whole canvas of the file (a layer that covers it without drawing): the canvas holds the + /// clipped layers alone. + Whole, +} + +enum Body<'i> { + /// A layer record's pixels. + Layer(usize), + /// A group's layers. + Group(&'i [Item]), + /// Layers merged into one image. + Merged(Canvas), +} + +/// A unit's body drawn alone. +enum Drawn { + /// It draws nothing. + Nothing, + Canvas(Canvas), + /// Its canvas would need more memory than allowed. + TooLarge, +} + +/// What the warnings say. +#[derive(Default)] +struct Notes { + types: usize, + adjustments: usize, + fills: usize, + effects: usize, + vector_masks: usize, + knockouts: usize, + /// Hidden layers left out, and layers clipped to them. + hidden: usize, + merged: Vec<&'static str>, + unmerged: usize, + /// Clipped layers left unclipped: the merge needs more memory than allowed. + unclipped: usize, + /// Layers clipped to a fill that isn't drawn. + clipped_out: usize, +} + +/// The counts a merge that can't be made gives back. +#[derive(Clone, Copy)] +struct Counts { + skipped: usize, + merged: usize, + unmerged: usize, + unclipped: usize, + clipped_out: usize, +} + +/// `n` things: "1 layer was", "2 layers were". +fn count(n: usize, one: &str, many: &str) -> String { + if n == 1 { format!("1 {one} was") } else { format!("{n} {many} were") } +} + +impl Notes { + fn warnings(self) -> Vec { + let mut out = vec![]; + if self.types > 0 { + let n = if self.types == 1 { "1 type layer".to_string() } else { format!("{} type layers", self.types) }; + out.push(format!("{n} came in as pixels: the text isn't editable")); + } + if self.adjustments > 0 { + out.push(format!("{} not applied", count(self.adjustments, "adjustment layer", "adjustment layers"))); + } + if self.fills > 0 { + out.push(format!( + "{} not drawn (a gradient or pattern fill, a color that can't be read, or a shape that stores no pixels)", + count(self.fills, "fill layer", "fill layers") + )); + } + let layers = |n: usize| if n == 1 { "1 layer".to_string() } else { format!("{n} layers") }; + if self.effects > 0 { + out.push(format!("the layer effects of {} weren't drawn", layers(self.effects))); + } + if self.vector_masks > 0 { + out.push(format!("the vector masks of {} weren't applied", layers(self.vector_masks))); + } + if self.knockouts > 0 { + out.push(format!("the knockout of {} wasn't applied", layers(self.knockouts))); + } + if self.hidden > 0 { + out.push(format!("{} left out (hidden layers and the layers clipped to them)", count(self.hidden, "layer", "layers"))); + } + if self.clipped_out == 1 { + out.push("1 layer clipped to a fill layer that wasn't drawn was left out".into()); + } else if self.clipped_out > 1 { + out.push(format!("{} layers clipped to fill layers that weren't drawn were left out", self.clipped_out)); + } + if !self.merged.is_empty() { + out.push(format!("layers in blend modes VectorCraft doesn't have ({}) were merged with the layers below them", self.merged.join(", "))); + } + if self.unmerged > 0 { + out.push(format!( + "{} in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed", + layers(self.unmerged) + )); + } + if self.unclipped > 0 { + out.push(format!("{} clipped to the layer below came in unclipped: the merge needs more memory than allowed", layers(self.unclipped))); + } + out + } + + fn counts(&self, skipped: usize) -> Counts { + Counts { skipped, merged: self.merged.len(), unmerged: self.unmerged, unclipped: self.unclipped, clipped_out: self.clipped_out } + } +} + +struct Builder<'a, F> { + h: &'a Header<'a>, + records: &'a [Record<'a>], + /// Hidden layers come in, hidden. + hidden: bool, + max_alloc: u64, + /// Bytes held by the copies of what is below the pass-through groups being merged. + held: u64, + cmyk: F, + doc: Document, + /// Canvas pixels to points. + to_doc: Affine, + ppi: Option<(f64, f64)>, + layers: usize, + notes: Notes, + /// The records [`Notes`] counted. + noted: Vec, + /// The records counted as hidden layers left out. + skipped: Vec, + /// Those records, in the order they were counted. + skip_log: Vec, + /// Bytes of layer and mask pixels decoded, and of canvases the merges made, so far. + spent: Cell, +} + +impl [f32; 3]> Builder<'_, F> { + fn record(&self, i: usize) -> Result<&Record<'_>, String> { + self.records.get(i).ok_or_else(|| DAMAGED.to_string()) + } + + /// Refuse to allocate `bytes` past the memory allowed. + fn fits(&self, bytes: u64) -> Result<(), String> { + if bytes > self.max_alloc { Err("a layer of the PSD file is too large to read".into()) } else { Ok(()) } + } + + /// Count `bytes` of decoded pixels, or of a canvas a merge makes, against the whole import's + /// allowance, [`TOTAL_DECODED`] times the memory allowed for one image. + fn spend(&self, bytes: u64) -> Result<(), String> { + let total = self.spent.get().saturating_add(bytes); + self.spent.set(total); + if total > self.max_alloc.saturating_mul(TOTAL_DECODED) { + return Err("the PSD file's layers take more memory to read than allowed".into()); + } + Ok(()) + } + + /// Record `i`'s pixels as straight RGBA, at their place on the canvas; `None` when it has none. + fn image(&self, i: usize) -> Result, String> { + let rec = self.record(i)?; + let h = self.h; + let max_side = if h.psb { 300_000 } else { 30_000 }; + let (w, rows) = match rec.content { + Content::Adjustment => return Ok(None), + // A fill layer that stores no pixels (a shape layer stores its shape's; one that + // doesn't, with only its vector mask, isn't drawn). + Content::Solid(Some(paint)) if rec.color_over_canvas() => { + let (w, rows) = (h.width as usize, h.height as usize); + self.fits(4 * w as u64 * rows as u64)?; + self.spend(4 * w as u64 * rows as u64)?; + let rgb = match paint { + Paint::Rgb(rgb) => rgb, + Paint::Cmyk(ink) => (self.cmyk)(ink), + }; + let px = image::Rgba([byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), 255]); + return Ok(Some((0, 0, RgbaImage::from_pixel(h.width, h.height, px)))); + } + Content::Pixels | Content::Type | Content::Solid(_) | Content::Fill => rec.bounds.size(), + }; + if rec.stores_nothing() { + return Ok(None); + } + if w > max_side || rows > max_side { + return Err(DAMAGED.into()); + } + let colors = h.mode.colors(); + let row = row_bytes(w, h.depth); + self.fits(((colors as u64 + 1) * row as u64).saturating_add(4 * w as u64).saturating_mul(rows as u64))?; + self.spend(4 * w as u64 * rows as u64)?; + let plane = |id: i16| -> Result>, String> { + match rec.channels.iter().find(|(c, _)| *c == id) { + Some((_, data)) => layer_channel(data, rows, row, h.psb, h.depth).map(Some), + None => Ok(None), + } + }; + let planes = (0..colors as i16).map(plane).collect::, _>>()?; + let alpha = plane(-1)?; + let linear = h.depth == 32; + let img = RgbaImage::from_fn(w as u32, rows as u32, |x, y| { + let (x, y) = (x as usize, y as usize); + let at = |p: &Option>, linear: bool, missing: f32| { + p.as_ref().and_then(|p| p.get(y * row..(y + 1) * row)).map_or(missing, |r| sample(r, x, h.depth, linear)) + }; + let rgb = to_rgb(h, |c| planes.get(c).map_or(0.0, |p| at(p, linear, 0.0)), &self.cmyk); + image::Rgba([byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), byte(at(&alpha, false, 1.0))]) + }); + Ok(Some((i64::from(rec.bounds.left), i64::from(rec.bounds.top), img))) + } + + /// Record `i`'s layer mask as gray values at their place, with the value outside them; `None` + /// when it has none, or it is disabled and not `even_disabled`. + fn mask(&self, i: usize, even_disabled: bool) -> Result, String> { + let rec = self.record(i)?; + let Some(m) = rec.mask.filter(|m| even_disabled || !m.disabled) else { return Ok(None) }; + let Some((_, data)) = rec.channels.iter().find(|(c, _)| *c == -2) else { return Ok(None) }; + let (w, rows) = m.bounds.size(); + let max_side = if self.h.psb { 300_000 } else { 30_000 }; + if w > max_side || rows > max_side { + return Err(DAMAGED.into()); + } + let row = row_bytes(w, self.h.depth); + self.fits((row as u64 + w as u64).saturating_mul(rows as u64))?; + self.spend(w as u64 * rows as u64)?; + let plane = layer_channel(data, rows, row, self.h.psb, self.h.depth)?; + let read = |v: u8| if m.inverted { 255 - v } else { v }; + let img = image::GrayImage::from_fn(w as u32, rows as u32, |x, y| { + let r = plane.get(y as usize * row..(y as usize + 1) * row).unwrap_or_default(); + image::Luma([read(byte(sample(r, x as usize, self.h.depth, false)))]) + }); + Ok(Some((i64::from(m.bounds.left), i64::from(m.bounds.top), img, read(m.default)))) + } + + /// Multiply `c`'s transparency by record `i`'s layer mask. + fn apply_mask(&self, c: &mut Canvas, i: usize) -> Result<(), String> { + let Some((left, top, m, default)) = self.mask(i, false)? else { return Ok(()) }; + let (w, h) = (i64::from(m.width()), i64::from(m.height())); + for (k, p) in c.px.iter_mut().enumerate() { + let (x, y) = (c.left + (k % c.width.max(1)) as i64, c.top + (k / c.width.max(1)) as i64); + let inside = (left..left + w).contains(&x) && (top..top + h).contains(&y); + let v = if inside { m.get_pixel((x - left) as u32, (y - top) as u32).0[0] } else { default }; + p[3] *= f32::from(v) / 255.0; + } + Ok(()) + } + + /// What `items` (a layer list, bottom first) become, before the modes VectorCraft lacks are + /// merged: layers clipped to the one below are merged into it, and hidden ones are left out + /// unless `keep_hidden`. Hidden layers a clip would merge are always left out. + fn units<'i>(&mut self, items: &'i [Item], keep_hidden: bool) -> Result>, String> { + let mut out: Vec> = vec![]; + // The layer the next clipped layers clip to: its index in `out`, or `None` when it was left + // out (its clipped layers go with it). + let mut base: Option> = None; + // The next clipped layers come in unclipped: a merge with their base didn't fit. + let mut unclipped = false; + for item in items { + let (i, body) = match item { + Item::Layer(i) => (*i, Body::Layer(*i)), + Item::Group(i, children) => (*i, Body::Group(children)), + }; + let rec = self.record(i)?; + let (name, hidden, clipped, blend) = (rec.name.clone(), rec.hidden, rec.clipped, rec.blend); + let opacity = rec.opacity * rec.fill; + if clipped && let Some(b) = base { + match b { + // Clipped to a layer left out, or hidden itself: left out. + None => self.skip(i), + Some(_) if hidden => self.skip(i), + // A hidden base (with `keep_hidden`) would need the merge: it is left out with + // the layers clipped to it. + Some(b) if out.get(b).is_some_and(|u| !u.visible) => { + if b < out.len() { + let u = out.remove(b); + self.skip(u.record); + } + self.skip(i); + base = Some(None); + } + Some(b) => { + // Counted even when it draws nothing (an adjustment layer). + self.note(i)?; + if !unclipped && self.clip(&mut out, b, &body, i, &name, blend, opacity)? { + continue; + } + // The merge doesn't fit: this layer and the next ones clipped to the same + // base come in as layers of their own. + unclipped = true; + self.notes.unclipped += 1; + out.push(Unit { name, visible: true, opacity, blend, mask_of: Some(i), record: i, body, clip: None }); + } + } + continue; + } + unclipped = false; + if hidden && !keep_hidden { + self.skip(i); + base = Some(None); + continue; + } + self.note(i)?; + out.push(Unit { name, visible: !hidden, opacity, blend, mask_of: Some(i), record: i, body, clip: None }); + base = Some(Some(out.len() - 1)); + } + for u in &mut out { + self.finish_clip(u); + } + Ok(out) + } + + /// Merge the layer of record `i` (`body`, named `name`, drawn alone with its layer mask, at + /// `opacity` in `blend`) into unit `b` of `out`, which it is clipped to. The base's own layer + /// mask stays its opacity mask, which hides the clipped layers with it. `false`, with `out` as + /// it was, when the canvases would need more memory than allowed. + #[allow(clippy::too_many_arguments)] + fn clip(&mut self, out: &mut [Unit<'_>], b: usize, body: &Body<'_>, i: usize, name: &str, blend: Blend, opacity: f32) -> Result { + let Some(u) = out.get(b) else { return Ok(true) }; + let (whole, undrawn, fill, base_opacity) = { + let r = self.record(u.record)?; + let layer = matches!(u.body, Body::Layer(_)); + (layer && r.covers_without_drawing(), layer && r.undrawn_shape(), r.fill, r.opacity) + }; + // A fill whose shape is a vector mask isn't drawn, and nothing clipped to it is either. + if undrawn && u.clip.is_none() { + self.notes.clipped_out += 1; + return Ok(true); + } + let layer = match self.draw_body(body, Some(i))? { + Drawn::Nothing => return Ok(true), + Drawn::TooLarge => return Ok(false), + Drawn::Canvas(c) => c, + }; + let room = self.room(); + let layer_bytes = 16 * layer.px.len() as u64; + let Some(u) = out.get_mut(b) else { return Ok(true) }; + if u.clip.is_none() { + let (canvas, clip) = if whole { + (Canvas::default(), Clip::Whole) + } else { + match self.draw_body(&u.body, None)? { + Drawn::TooLarge => return Ok(false), + // A base that draws nothing shows nothing of its clipped layers either. + Drawn::Nothing => (Canvas::default(), Clip::Shape(vec![])), + Drawn::Canvas(mut c) => { + // Its transparency, 4 bytes a pixel, is kept beside the canvas. + if (20 * c.px.len() as u64).saturating_add(layer_bytes) > room { + return Ok(false); + } + self.spend(4 * c.px.len() as u64)?; + let shape = c.px.iter().map(|p| p[3]).collect(); + for p in &mut c.px { + p[3] = fill; + } + (c, Clip::Shape(shape)) + } + } + }; + u.body = Body::Merged(canvas); + u.clip = Some(clip); + u.opacity = base_opacity; + } + let Body::Merged(c) = &mut u.body else { return Ok(true) }; + match &u.clip { + Some(Clip::Shape(_)) => { + if (20 * c.px.len() as u64).saturating_add(layer_bytes) > room { + return Ok(false); + } + clip_onto(c, &layer, blend, opacity, i); + } + _ => { + let mut acc = (!c.px.is_empty()).then(|| std::mem::take(c)); + let laid = blend_onto(&mut acc, &layer, blend, opacity, i, room, &|n| self.spend(n)); + *c = acc.unwrap_or_default(); + if !laid? { + return Ok(false); + } + } + } + u.name.push_str(" + "); + u.name.push_str(name); + Ok(true) + } + + /// Unit `u`'s merged canvas, once every layer clipped to it is in: transparent outside its + /// shape. + fn finish_clip(&self, u: &mut Unit<'_>) { + let Some(clip) = u.clip.take() else { return }; + let Body::Merged(c) = &mut u.body else { return }; + match clip { + Clip::Shape(shape) => c.px.iter_mut().zip(shape).for_each(|(p, a)| p[3] *= a), + Clip::Whole => { + let (w, h, width) = (i64::from(self.h.width), i64::from(self.h.height), c.width.max(1)); + for (k, p) in c.px.iter_mut().enumerate() { + let (x, y) = (c.left + (k % width) as i64, c.top + (k / width) as i64); + if !(0..w).contains(&x) || !(0..h).contains(&y) { + p[3] = 0.0; + } + } + } + } + } + + /// Count what record `i` holds that doesn't come in. + fn note(&mut self, i: usize) -> Result<(), String> { + // Once, though a merge may draw it again. + match self.noted.get_mut(i) { + Some(true) => return Ok(()), + Some(n) => *n = true, + None => {} + } + let (content, empty, effects, vector_mask, knockout) = { + let rec = self.record(i)?; + (rec.content, rec.stores_nothing(), rec.effects, rec.vector_mask, rec.knockout) + }; + match content { + Content::Type => self.notes.types += 1, + Content::Adjustment => self.notes.adjustments += 1, + // A fill drawn from neither its pixels nor its color. + Content::Fill | Content::Solid(None) if empty => self.notes.fills += 1, + Content::Solid(Some(_)) if empty && vector_mask => self.notes.fills += 1, + _ => {} + } + self.notes.effects += usize::from(effects); + self.notes.vector_masks += usize::from(vector_mask); + self.notes.knockouts += usize::from(knockout); + Ok(()) + } + + /// Count record `i` as a hidden layer left out (once). + fn skip(&mut self, i: usize) { + if let Some(s) = self.skipped.get_mut(i) + && !*s + { + *s = true; + self.notes.hidden += 1; + self.skip_log.push(i); + } + } + + /// `items`' units with the layers in blend modes VectorCraft lacks merged with those below. + fn merged<'i>(&mut self, items: &'i [Item], keep_hidden: bool) -> Result>, String> { + let mut out: Vec> = vec![]; + for mut u in self.units(items, keep_hidden)? { + let Blend::Other(other) = u.blend else { + out.push(u); + continue; + }; + // A hidden layer that would need the merge is left out. + if !u.visible { + self.skip(u.record); + continue; + } + // A merge that can't be made counts nothing its groups would have counted. + let saved = self.notes.counts(self.skip_log.len()); + let made = match out.as_mut_slice() { + // Onto the previous merge alone: composited onto its canvas, in place. + [prev] if prev.is_merge() => { + let mut acc = match &mut prev.body { + Body::Merged(c) => Some(std::mem::take(c)), + _ => None, + }; + let made = self.composite(&mut acc, &u); + if let Body::Merged(c) = &mut prev.body { + *c = acc.unwrap_or_default(); + } + if made? { + prev.name.push_str(" + "); + prev.name.push_str(&u.name); + true + } else { + false + } + } + _ => match self.merge(&out, &u)? { + Some(canvas) => { + let mut name = String::new(); + for (k, b) in out.iter_mut().filter(|b| b.visible).enumerate() { + if k == 0 { + name = std::mem::take(&mut b.name); + } else { + name.push_str(" + "); + name.push_str(&b.name); + } + } + if out.iter().any(|b| b.visible) { + name.push_str(" + "); + } + name.push_str(&u.name); + // Hidden layers below are left out with the rest. + for b in out.iter().filter(|b| !b.visible).map(|b| b.record).collect::>() { + self.skip(b); + } + out.clear(); + out.push(Unit { + name, + visible: true, + opacity: 1.0, + blend: Blend::Mode(BlendMode::Normal), + mask_of: None, + record: u.record, + body: Body::Merged(canvas), + clip: None, + }); + true + } + None => false, + }, + }; + if made { + if !self.notes.merged.contains(&other.label()) { + self.notes.merged.push(other.label()); + } + } else { + self.restore(saved); + self.notes.unmerged += 1; + u.blend = Blend::Mode(BlendMode::Normal); + out.push(u); + } + } + Ok(out) + } + + /// The counts as they were before a merge that couldn't be made. + fn restore(&mut self, c: Counts) { + for i in self.skip_log.split_off(c.skipped) { + if let Some(s) = self.skipped.get_mut(i) { + *s = false; + self.notes.hidden = self.notes.hidden.saturating_sub(1); + } + } + self.notes.merged.truncate(c.merged); + self.notes.unmerged = c.unmerged; + self.notes.unclipped = c.unclipped; + self.notes.clipped_out = c.clipped_out; + } + + /// The visible units `below` with `top` composited over them; `None` when the canvas would need + /// more memory than allowed. + fn merge(&mut self, below: &[Unit<'_>], top: &Unit<'_>) -> Result, String> { + let mut acc: Option = None; + for u in below.iter().filter(|u| u.visible).chain([top]) { + if !self.composite(&mut acc, u)? { + return Ok(None); + } + } + Ok(Some(acc.unwrap_or_default())) + } + + /// The bytes a merge step may still take: the memory allowed, less the copies of what is below + /// the pass-through groups being merged. + fn room(&self) -> u64 { + self.max_alloc.saturating_sub(self.held) + } + + /// Unit `u` composited onto `acc`. A pass-through group lays its layers onto `acc` one by one, + /// so that they blend with what is below it; at less than full opacity or with a layer mask, the + /// result is then faded back toward `acc` as it was by the opacity and the mask. Anything else + /// is drawn alone first. `false` when the canvas would need more memory than allowed. + fn composite(&mut self, acc: &mut Option, u: &Unit<'_>) -> Result { + if let (Body::Group(items), Blend::Pass) = (&u.body, u.blend) { + let mask = match u.mask_of { + Some(i) if self.has_mask(Some(i))? => self.mask(i, false)?, + _ => None, + }; + if u.opacity >= 1.0 && mask.is_none() { + return self.lay(acc, items); + } + let bytes = acc.as_ref().map_or(0, |c| 16 * c.px.len() as u64); + if bytes > self.room() { + return Ok(false); + } + self.spend(bytes)?; + let below = acc.clone(); + self.held = self.held.saturating_add(bytes); + let laid = self.lay(acc, items); + self.held = self.held.saturating_sub(bytes); + if !laid? { + return Ok(false); + } + if let Some(after) = acc.as_mut() { + fade(after, below.as_ref(), u.opacity, mask.as_ref()); + } + return Ok(true); + } + let c = match self.draw_body(&u.body, u.mask_of)? { + Drawn::Nothing => return Ok(true), + Drawn::Canvas(c) => c, + Drawn::TooLarge => return Ok(false), + }; + blend_onto(acc, &c, u.blend, u.opacity, u.record, self.room(), &|n| self.spend(n)) + } + + /// `items`' layers composited onto `acc` one by one; `false` when a canvas would need more + /// memory than allowed. + fn lay(&mut self, acc: &mut Option, items: &[Item]) -> Result { + for c in self.merged(items, false)? { + if !self.composite(acc, &c)? { + return Ok(false); + } + } + Ok(true) + } + + /// Does record `mask_of` have a layer mask that is switched on? + fn has_mask(&self, mask_of: Option) -> Result { + let Some(i) = mask_of else { return Ok(false) }; + let rec = self.record(i)?; + Ok(rec.mask.is_some_and(|m| !m.disabled) && rec.channels.iter().any(|(c, _)| *c == -2)) + } + + /// A unit's body drawn alone, with the layer mask of record `mask_of`, before its own opacity + /// and blend mode. Its canvas counts against the import's allowance. + fn draw_body(&mut self, body: &Body<'_>, mask_of: Option) -> Result { + let mut c = match body { + Body::Merged(c) => { + let bytes = 16 * c.px.len() as u64; + if bytes > self.room() { + return Ok(Drawn::TooLarge); + } + self.spend(bytes)?; + c.clone() + } + Body::Layer(i) => match self.image(*i)? { + Some((left, top, img)) => { + let bytes = 16 * u64::from(img.width()) * u64::from(img.height()); + if bytes > self.room() { + return Ok(Drawn::TooLarge); + } + self.spend(bytes)?; + Canvas::from_image(left, top, &img) + } + None => return Ok(Drawn::Nothing), + }, + Body::Group(items) => { + let mut acc: Option = None; + if !self.lay(&mut acc, items)? { + return Ok(Drawn::TooLarge); + } + let Some(acc) = acc else { return Ok(Drawn::Nothing) }; + acc + } + }; + if let Some(i) = mask_of { + self.apply_mask(&mut c, i)?; + } + Ok(Drawn::Canvas(c)) + } + + /// A new layer of the document named `name`, colored as the next new layer would be. + fn layer(&mut self, name: &str) -> Node { + let n = Node::layer(self.doc.alloc_id(), name, LayerColor::Preset((self.layers % LAYER_COLORS.len()) as u8)); + self.layers += 1; + n + } + + /// `img` at `left`, `top` on the canvas as an image of the document named `name`. + fn image_node(&mut self, name: &str, left: i64, top: i64, img: RgbaImage) -> Result { + let RasterImage { key, blob, width, height, .. } = as_png(img, None, self.ppi).map_err(|e| e.to_string())?; + let xf = self.to_doc * Affine::translate((left as f64, top as f64)); + let mut n = Node::new( + self.doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })), + ); + n.name = Some(name.to_string()); + self.doc.images.insert(key, blob); + Ok(n) + } + + /// Layers of the document for `items`, bottom first. + fn list(&mut self, items: &[Item]) -> Result>, String> { + let mut out = vec![]; + for u in self.merged(items, self.hidden)? { + if let Some(n) = self.node(u)? { + out.push(Arc::new(n)); + } + } + Ok(out) + } + + /// The layer of the document unit `u` becomes; `None` for a layer that draws nothing (an + /// adjustment layer, or one without pixels). + fn node(&mut self, u: Unit<'_>) -> Result, String> { + let children = match &u.body { + Body::Layer(i) => match self.image(*i)? { + Some((left, top, img)) => vec![Arc::new(self.image_node(&u.name, left, top, img)?)], + None => return Ok(None), + }, + Body::Merged(c) => match c.to_image() { + Some((left, top, img)) => vec![Arc::new(self.image_node(&u.name, left, top, img)?)], + None => return Ok(None), + }, + Body::Group(items) => self.list(items)?, + }; + let mut layer = self.layer(&u.name); + layer.visible = u.visible; + layer.opacity = u.opacity.clamp(0.0, 1.0); + (layer.blend, layer.isolate) = match u.blend { + Blend::Mode(m) => (m, matches!(u.body, Body::Group(_))), + Blend::Pass | Blend::Other(_) => (BlendMode::Normal, false), + }; + if let Some(c) = layer.children_mut() { + *c = children; + } + if let Some(i) = u.mask_of { + layer.extras_mut().mask = self.opacity_mask(i)?.map(Box::new); + } + Ok(Some(layer)) + } + + /// Record `i`'s layer mask as an opacity mask: a gray image, outside which the layer is hidden + /// or shown as the mask's default color says (a mask of no pixels hides all of it, or none). + fn opacity_mask(&mut self, i: usize) -> Result, String> { + let disabled = self.record(i)?.mask.is_some_and(|m| m.disabled); + let Some((left, top, gray, default)) = self.mask(i, true)? else { return Ok(None) }; + let (art, clip) = if gray.width() == 0 || gray.height() == 0 { + if default == 255 { + return Ok(None); + } + let black = RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 255])); + (self.image_node("Layer Mask", left, top, black)?, true) + } else { + self.fits(5 * u64::from(gray.width()) * u64::from(gray.height()))?; + let rgba = RgbaImage::from_fn(gray.width(), gray.height(), |x, y| { + let v = gray.get_pixel(x, y).0[0]; + image::Rgba([v, v, v, 255]) + }); + (self.image_node("Layer Mask", left, top, rgba)?, default == 0) + }; + let mut mask = OpacityMask::new(art, clip); + mask.disabled = disabled; + Ok(Some(mask)) + } +} + +/// The coverage of a pixel at `x`, `y` of the canvas with `alpha`, 0..1, in `blend`: in Dissolve +/// fully opaque or fully transparent, opaque at a share of the pixels equal to `alpha`, chosen by a +/// fixed hash of the pixel's place and of `record`, so that each layer has its own pattern; `alpha` +/// in every other mode. +fn coverage(blend: Blend, alpha: f32, x: i64, y: i64, record: usize) -> f32 { + if !matches!(blend, Blend::Other(Other::Dissolve)) { + return alpha; + } + let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) + ^ (record as u64).wrapping_mul(0x1656_67B1_9E37_79F9); + h ^= h >> 31; + h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); + h ^= h >> 29; + // The top 24 bits, 0..1. + let r = (h >> 40) as f32 / (1u64 << 24) as f32; + if r < alpha { 1.0 } else { 0.0 } +} + +/// `layer` (of record `record`) composited onto `base`, the canvas of the layer it is clipped to, +/// in `blend` at `opacity`, where the two overlap. +fn clip_onto(base: &mut Canvas, layer: &Canvas, blend: Blend, opacity: f32, record: usize) { + let (x0, x1) = (base.left.max(layer.left), (base.left + base.width as i64).min(layer.left + layer.width as i64)); + let (y0, y1) = (base.top.max(layer.top), (base.top + base.height as i64).min(layer.top + layer.height as i64)); + for y in y0..y1 { + for x in x0..x1 { + let Some(mut s) = layer.get(x, y) else { continue }; + let k = (y - base.top) as usize * base.width + (x - base.left) as usize; + let Some(b) = base.px.get_mut(k) else { continue }; + s[3] = coverage(blend, s[3] * opacity, x, y, record); + *b = over(blend, *b, s); + } + } +} + +/// `after` faded back toward `before` (transparent where it has no pixels) by `opacity` times the +/// value of `mask` (its place, its gray values and the value outside it) at each pixel, in +/// premultiplied color. +fn fade(after: &mut Canvas, before: Option<&Canvas>, opacity: f32, mask: Option<&(i64, i64, image::GrayImage, u8)>) { + let width = after.width.max(1); + for (k, p) in after.px.iter_mut().enumerate() { + let (x, y) = (after.left + (k % width) as i64, after.top + (k / width) as i64); + let m = mask.map_or(255, |(left, top, m, default)| { + let inside = (*left..*left + i64::from(m.width())).contains(&x) && (*top..*top + i64::from(m.height())).contains(&y); + if inside { m.get_pixel((x - left) as u32, (y - top) as u32).0[0] } else { *default } + }); + let t = opacity * f32::from(m) / 255.0; + let b = before.and_then(|c| c.get(x, y)).unwrap_or([0.0; 4]); + let alpha = (1.0 - t) * b[3] + t * p[3]; + if alpha <= 0.0 { + *p = [0.0; 4]; + continue; + } + let mix = |i: usize| ((1.0 - t) * b[3] * b[i] + t * p[3] * p[i]) / alpha; + *p = [mix(0), mix(1), mix(2), alpha]; + } +} + +/// `src` (of record `record`) at `opacity` composited onto `acc` (nothing when `None`) in `blend`, +/// on a canvas covering both (`acc` itself when it covers `src`; a larger one is counted with +/// `spend`). `false`, with `acc` as it was, when the canvases held, `acc` and `src` and a larger +/// canvas when one is needed, would take more than `max_alloc` bytes. +fn blend_onto( + acc: &mut Option, + src: &Canvas, + blend: Blend, + opacity: f32, + record: usize, + max_alloc: u64, + spend: &dyn Fn(u64) -> Result<(), String>, +) -> Result { + let (mut left, mut top, mut right, mut bottom) = (src.left, src.top, src.left + src.width as i64, src.top + src.height as i64); + if let Some(a) = acc.as_ref() { + left = left.min(a.left); + top = top.min(a.top); + right = right.max(a.left + a.width as i64); + bottom = bottom.max(a.top + a.height as i64); + } + let (Ok(width), Ok(height)) = (usize::try_from(right - left), usize::try_from(bottom - top)) else { return Ok(false) }; + let held = (src.px.len() as u64).saturating_add(acc.as_ref().map_or(0, |a| a.px.len() as u64)); + let covers = acc.as_ref().is_some_and(|a| (a.left, a.top, a.width, a.height) == (left, top, width, height)); + if covers { + if held.saturating_mul(16) > max_alloc { + return Ok(false); + } + } else { + let bytes = (width as u64).saturating_mul(height as u64).saturating_mul(16); + if bytes.saturating_add(held.saturating_mul(16)) > max_alloc { + return Ok(false); + } + spend(bytes)?; + let mut out = Canvas { left, top, width, height, px: vec![[0.0; 4]; width * height] }; + if let Some(a) = acc.take() { + for (y, row) in a.px.chunks(a.width.max(1)).enumerate() { + let at = (a.top - top) as usize + y; + let start = at * width + (a.left - left) as usize; + if let Some(dst) = out.px.get_mut(start..start + row.len()) { + dst.copy_from_slice(row); + } + } + } + *acc = Some(out); + } + let Some(out) = acc.as_mut() else { return Ok(false) }; + // Only the source's rows and columns change. + let (sw, x0, y0) = (src.width.max(1), (src.left - left) as usize, (src.top - top) as usize); + for (k, s) in src.px.iter().enumerate() { + let Some(p) = out.px.get_mut((y0 + k / sw) * width + x0 + k % sw) else { continue }; + let a = coverage(blend, s[3] * opacity, src.left + (k % sw) as i64, src.top + (k / sw) as i64, record); + *p = over(blend, *p, [s[0], s[1], s[2], a]); + } + Ok(true) +} + +/// PSD or PSB file `bytes` with its layers as layers of the document (see the module +/// documentation), with warnings for what doesn't come in. `None` when the file has no layers (its +/// merged image is all of it). CMYK converts through `cmyk` (ink amounts 0..1 → sRGB). +pub(super) fn import( + bytes: &[u8], + hidden: bool, + max_alloc: u64, + cmyk: impl Fn([f32; 4]) -> [f32; 3], +) -> Result)>, String> { + let mut r = Reader::new(bytes); + let h = header(&mut r)?; + // Layer info that holds only the count (which says whether the merged image has transparency) + // has no layers either. + let Some(info) = layer_info(h.layers, h.psb).filter(|info| info.len() > 2) else { return Ok(None) }; + let records = records(info, h.psb)?; + if records.is_empty() { + return Ok(None); + } + let items = tree(&records)?; + let ppi = super::ppi::resolution(bytes).filter(|(x, y)| x.is_finite() && y.is_finite() && *x > 0.0 && *y > 0.0); + let (sx, sy) = crate::cmd::place::pt_per_px(ppi); + let doc = Document::new(f64::from(h.width) * sx, f64::from(h.height) * sy); + let mut b = Builder { + h: &h, + records: &records, + hidden, + max_alloc, + held: 0, + cmyk, + doc, + to_doc: Affine::scale_non_uniform(sx, sy), + ppi, + layers: 0, + notes: Notes::default(), + noted: vec![false; records.len()], + skipped: vec![false; records.len()], + skip_log: vec![], + spent: Cell::new(0), + }; + let layers = b.list(&items)?; + let Builder { mut doc, notes, .. } = b; + if !layers.is_empty() { + doc.layers = layers; + } + // Groups nest up to `MAX_DEPTH`, deeper than a document may: what is too deep merges into + // the group around it, with a warning. + let mut warnings = notes.warnings(); + doc.limit_depth_noting(&mut warnings); + Ok(Some((doc, warnings))) +} diff --git a/crates/engine/src/cmd/fileio/psdread.rs b/crates/engine/src/cmd/fileio/psdread.rs index 84ac8203aa..93e7b61f17 100644 --- a/crates/engine/src/cmd/fileio/psdread.rs +++ b/crates/engine/src/cmd/fileio/psdread.rs @@ -1,7 +1,8 @@ //! Reading Photoshop documents (PSD, and PSB, the large document format) for File › Open and //! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers, //! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification" -//! (). Layers aren't read. +//! (). File › Open reads the +//! layers with [`super::psdlayers`], which shares this reader. //! //! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its //! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw, @@ -16,7 +17,7 @@ use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; /// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Mode { +pub(super) enum Mode { Bitmap, Gray, Indexed, @@ -27,7 +28,7 @@ enum Mode { impl Mode { /// The channels holding colour (an alpha channel may follow them). - fn colors(self) -> usize { + pub(super) fn colors(self) -> usize { match self { Mode::Bitmap | Mode::Gray | Mode::Indexed => 1, Mode::Rgb | Mode::Lab => 3, @@ -46,68 +47,68 @@ pub fn is_psb(bytes: &[u8]) -> bool { is_psd(bytes) && bytes.get(4..6) == Some(&[0, 2]) } -const TRUNCATED: &str = "the PSD file is cut short"; +pub(super) const TRUNCATED: &str = "the PSD file is cut short"; const DAMAGED: &str = "the PSD file's header is damaged"; /// Big-endian reads through `b`, each checked against its end. -struct Reader<'a> { +pub(super) struct Reader<'a> { b: &'a [u8], at: usize, } impl<'a> Reader<'a> { - fn new(b: &'a [u8]) -> Self { + pub(super) fn new(b: &'a [u8]) -> Self { Self { b, at: 0 } } - fn take(&mut self, n: usize) -> Result<&'a [u8], String> { + pub(super) fn take(&mut self, n: usize) -> Result<&'a [u8], String> { let s = self.at.checked_add(n).and_then(|end| self.b.get(self.at..end)).ok_or(TRUNCATED)?; self.at += n; Ok(s) } - fn array(&mut self) -> Result<[u8; N], String> { + pub(super) fn array(&mut self) -> Result<[u8; N], String> { self.take(N)?.try_into().map_err(|_| TRUNCATED.to_string()) } - fn u8(&mut self) -> Result { + pub(super) fn u8(&mut self) -> Result { self.array::<1>().map(|[v]| v) } - fn u16(&mut self) -> Result { + pub(super) fn u16(&mut self) -> Result { self.array().map(u16::from_be_bytes) } - fn u32(&mut self) -> Result { + pub(super) fn u32(&mut self) -> Result { self.array().map(u32::from_be_bytes) } /// A section length: 4 bytes in a PSD, 8 in a PSB. - fn length(&mut self, psb: bool) -> Result { + pub(super) fn length(&mut self, psb: bool) -> Result { let n = if psb { self.array().map(u64::from_be_bytes)? } else { u64::from(self.u32()?) }; usize::try_from(n).map_err(|_| TRUNCATED.to_string()) } /// A section: its length, then that many bytes. - fn section(&mut self, psb: bool) -> Result<&'a [u8], String> { + pub(super) fn section(&mut self, psb: bool) -> Result<&'a [u8], String> { let n = self.length(psb)?; self.take(n) } - fn rest(&self) -> &'a [u8] { + pub(super) fn rest(&self) -> &'a [u8] { self.b.get(self.at..).unwrap_or_default() } } /// The fixed-size header and the sections before the image data. -struct Header<'a> { - psb: bool, +pub(super) struct Header<'a> { + pub(super) psb: bool, channels: usize, - width: u32, - height: u32, - depth: u16, - mode: Mode, + pub(super) width: u32, + pub(super) height: u32, + pub(super) depth: u16, + pub(super) mode: Mode, /// Colour mode data: an indexed image's palette (256 reds, then greens, then blues). palette: &'a [u8], /// The image resources. resources: &'a [u8], /// The layer and mask information. - layers: &'a [u8], + pub(super) layers: &'a [u8], } -fn header<'a>(r: &mut Reader<'a>) -> Result, String> { +pub(super) fn header<'a>(r: &mut Reader<'a>) -> Result, String> { if !is_psd(r.b) { return Err("not a PSD file".into()); } @@ -183,41 +184,45 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { /// count is negative: in the layer info, or for 16- and 32-bit documents (whose layer info is /// empty) in their `Lr16` / `Lr32` block. fn merged_alpha(layers: &[u8], psb: bool) -> bool { - let count_negative = |info: &[u8]| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0); + layer_info(layers, psb).is_some_and(|info| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0)) +} + +/// Do tagged blocks with this key give an 8-byte length in a PSB? +pub(super) fn long_key(key: &[u8; 4]) -> bool { + matches!(key, b"Lr16" | b"Lr32" | b"Layr" | b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD") +} + +/// The layer info: the layer count, the layer records and their channels. It is the first part of +/// the layer and mask information, or for 16- and 32-bit documents (whose first part is empty) the +/// `Lr16` / `Lr32` block after it. `None` when the file has neither. +pub(super) fn layer_info(layers: &[u8], psb: bool) -> Option<&[u8]> { let mut r = Reader::new(layers); - let Ok(info) = r.section(psb) else { return false }; + let info = r.section(psb).ok()?; if !info.is_empty() { - return count_negative(info); + return Some(info); } // The global layer mask info, then tagged blocks: signature, key, length (8 bytes for the // layer blocks of a PSB), data padded to 4 bytes. - if r.section(false).is_err() { - return false; - } + r.section(false).ok()?; while r.rest().len() >= 12 { - let (Ok(_), Ok(key)) = (r.take(4), r.array::<4>()) else { return false }; - let layer_block = matches!(&key, b"Lr16" | b"Lr32" | b"Layr"); - let long = layer_block || matches!(&key, b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD"); - let Ok(data) = r.section(psb && long) else { - return false; - }; - if layer_block { - return count_negative(data); - } - if r.take((4 - data.len() % 4) % 4).is_err() { - return false; + r.take(4).ok()?; + let key = r.array::<4>().ok()?; + let data = r.section(psb && long_key(&key)).ok()?; + if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { + return Some(data); } + r.take((4 - data.len() % 4) % 4).ok()?; } - false + None } /// The most bytes one channel row may take (a PSB at 32 bits). -fn row_bytes(width: usize, depth: u16) -> usize { +pub(super) fn row_bytes(width: usize, depth: u16) -> usize { if depth == 1 { width.div_ceil(8) } else { width * usize::from(depth / 8) } } /// Undo PackBits: `src` into `dst` (a row; a short or overlong run leaves the rest as it is). -fn unpack_bits(mut src: &[u8], dst: &mut [u8]) { +pub(super) fn unpack_bits(mut src: &[u8], dst: &mut [u8]) { let mut at = 0; while let Some((&n, rest)) = src.split_first() { src = rest; @@ -269,32 +274,77 @@ fn channel_data(data: &[u8], h: &Header<'_>, channels: usize, row: usize) -> Res } Ok(out) } - 2 | 3 => { - let mut out = Vec::new(); - flate2::read::ZlibDecoder::new(r.rest()) - .take(wanted as u64) - .read_to_end(&mut out) - .map_err(|e| format!("the PSD file's image data can't be read: {e}"))?; - if out.len() < wanted { - return Err(TRUNCATED.into()); - } - if method == 3 { - // Each row stores the difference from the sample before it. - match h.depth { - 8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))), - 16 => out.chunks_exact_mut(row).for_each(|r| { - let mut prev = 0u16; - for s in r.as_chunks_mut::<2>().0 { - prev = prev.wrapping_add(u16::from_be_bytes(*s)); - *s = prev.to_be_bytes(); - } - }), - _ => return Err("zipped 32-bit PSD image data with prediction can't be read".into()), + 2 | 3 => inflate(r.rest(), wanted, method == 3, h.depth, row), + m => Err(format!("the PSD file's image data uses an unknown compression ({m})")), + } +} + +/// `wanted` bytes of zipped image data, rows of `row` bytes; with `predicted`, each row stores the +/// difference from the sample before it. In 32-bit data a row stores the first bytes of its samples, +/// then their second bytes, and so on, and the differences run byte by byte along the whole row (as +/// psd-tools, MIT, reads it: , `compression/__init__.py`). +fn inflate(src: &[u8], wanted: usize, predicted: bool, depth: u16, row: usize) -> Result, String> { + let mut out = Vec::new(); + flate2::read::ZlibDecoder::new(src) + .take(wanted as u64) + .read_to_end(&mut out) + .map_err(|e| format!("the PSD file's image data can't be read: {e}"))?; + if out.len() < wanted { + return Err(TRUNCATED.into()); + } + if predicted && row > 0 { + match depth { + 8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))), + 16 => out.chunks_exact_mut(row).for_each(|r| { + let mut prev = 0u16; + for s in r.as_chunks_mut::<2>().0 { + prev = prev.wrapping_add(u16::from_be_bytes(*s)); + *s = prev.to_be_bytes(); } + }), + 32 => { + let w = row / 4; + let mut planes = vec![0u8; row]; + for r in out.chunks_exact_mut(row) { + let mut prev = 0u8; + for (dst, &b) in planes.iter_mut().zip(r.iter()) { + prev = prev.wrapping_add(b); + *dst = prev; + } + for (x, s) in r.as_chunks_mut::<4>().0.iter_mut().enumerate() { + for (k, v) in s.iter_mut().enumerate() { + *v = planes.get(k * w + x).copied().unwrap_or(0); + } + } + } + } + // 1-bit. + _ => return Err("zipped 1-bit PSD image data with prediction can't be read".into()), + } + } + Ok(out) +} + +/// One channel of a layer: its compression method, then `rows` rows of `row` bytes (PackBits rows +/// after their byte counts, 4 bytes each in a PSB). +pub(super) fn layer_channel(data: &[u8], rows: usize, row: usize, psb: bool, depth: u16) -> Result, String> { + let mut r = Reader::new(data); + let method = r.u16()?; + let wanted = rows.checked_mul(row).ok_or(TRUNCATED)?; + match method { + 0 => r.take(wanted).map(<[u8]>::to_vec), + 1 => { + let size = if psb { 4 } else { 2 }; + let counts = r.take(rows.checked_mul(size).ok_or(TRUNCATED)?)?; + let mut out = vec![0; wanted]; + for (dst, n) in out.chunks_exact_mut(row.max(1)).zip(counts.chunks_exact(size)) { + let n = n.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)); + unpack_bits(r.take(n)?, dst); } Ok(out) } - m => Err(format!("the PSD file's image data uses an unknown compression ({m})")), + 2 | 3 => inflate(r.rest(), wanted, method == 3, depth, row), + m => Err(format!("the PSD file's layer data uses an unknown compression ({m})")), } } @@ -304,7 +354,7 @@ fn srgb_encode(v: f32) -> f32 { } /// Sample `x` of a channel row, 0..1. -fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { +pub(super) fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { match depth { 1 => { // Bitmap: a set bit is black. @@ -321,10 +371,25 @@ fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { } } -fn byte(v: f32) -> u8 { +pub(super) fn byte(v: f32) -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } +/// The sRGB color of a pixel whose color channels `v` gives (0..1, as stored), in `h`'s mode. +pub(super) fn to_rgb(h: &Header<'_>, v: impl Fn(usize) -> f32, cmyk: &impl Fn([f32; 4]) -> [f32; 3]) -> [f32; 3] { + match h.mode { + Mode::Bitmap | Mode::Gray => [v(0); 3], + Mode::Indexed => { + let i = usize::from(byte(v(0))); + [0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0)) + } + Mode::Rgb => [v(0), v(1), v(2)], + // Inks are stored as 1 − amount. + Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))), + Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }), + } +} + /// A Photoshop document's merged image as straight RGBA. CMYK converts through `cmyk` (ink /// amounts 0..1 → sRGB). Fails, without allocating, when the image would need more than /// `max_alloc` bytes. @@ -348,17 +413,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64, cmyk: impl Fn([f32; 4]) -> [f32; 3]) let planes: Vec<&[u8]> = (0..channels).map(|c| data.get((c * rows + y) * row..(c * rows + y + 1) * row).unwrap_or_default()).collect(); for (x, px) in line.enumerate() { let v = |c: usize| planes.get(c).map_or(0.0, |p| sample(p, x, h.depth, linear && c < colors)); - let rgb = match h.mode { - Mode::Bitmap | Mode::Gray => [v(0); 3], - Mode::Indexed => { - let i = usize::from(byte(v(0))); - [0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0)) - } - Mode::Rgb => [v(0), v(1), v(2)], - // Inks are stored as 1 − amount. - Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))), - Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }), - }; + let rgb = to_rgb(&h, v, &cmyk); let a = if alpha { v(colors) } else { 1.0 }; // The merged image is stored on white: take the white back out. let straight = |c: f32| if a <= 0.0 { 0.0 } else { (c - (1.0 - a)) / a }; diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index 05ca07019f..d5f5149bab 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -267,6 +267,66 @@ mod native { synth::container(&[("doc.dat", &doc, method)], Some(&png())) } + /// A version-12 document at 144 ppi holding a 100 px square filled with a red-to-blue conical + /// gradient centered at (50, 40) px, 40 px across: its unit +y points down the page, or up + /// (`mirror`). + fn conical_document(mirror: bool) -> Vec { + let stop = |id: u32, c: F| F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]); + let positions = [[0.0f32, 0.5], [1.0, 0.5]].iter().map(|p| p.iter().flat_map(|v| v.to_le_bytes()).collect()).collect(); + let grad = F::Obj( + tag(b"Grad"), + vec![ + (tag(b"Cols"), F::Shared(vec![stop(23, rgba(1.0, 0.0, 0.0, 1.0)), stop(24, rgba(0.0, 0.0, 1.0, 1.0))])), + (tag(b"Posn"), F::Records(8, positions)), + ], + ); + let fill = F::Def(22, vec![tag(b"FilG")], vec![(tag(b"Type"), F::Enum(3, 0)), (tag(b"Grad"), grad)]); + let unit = F::F64s(vec![40.0, 0.0, 50.0, 0.0, if mirror { -40.0 } else { 40.0 }, 40.0]); + let gradient = F::Def(21, vec![tag(b"FDsc")], vec![(tag(b"FDeX"), unit), (tag(b"FDeF"), fill)]); + let square = F::Def( + 20, + vec![tag(b"ShpN")], + vec![ + (tag(b"Shpe"), F::Def(25, vec![tag(b"ShNR")], vec![])), + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 100.0, 100.0])), + (tag(b"BFFl"), F::Shared(vec![gradient])), + ], + ); + let spread = + F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 200.0])), (tag(b"Chld"), F::Shared(vec![square]))]); + let doc = synth::stream(&[ + (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])), + (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])), + ]); + synth::container(&[("doc.dat", &doc, Method::Stored)], Some(&png())) + } + + #[test] + fn conical_gradients_open_as_conic_gradients() { + use vectorcraft_color::{GradientKind, Paint}; + for mirror in [false, true] { + let mut s = Session::new(); + let r = open_bytes(&mut s, "conic.af", &conical_document(mirror), None).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("conical")), "{r}"); + let mut paint = None; + s.doc().unwrap().doc.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + paint = Some(g.clone()); + } + }); + let g = paint.expect("a gradient fill"); + assert_eq!(g.gradient.kind, GradientKind::Conic); + let geom = g.geom.unwrap(); + // At 144 ppi: centered at (25, 20) pt, 20 pt across, red where the sweep starts. + assert!(geom.start.distance(kurbo::Point::new(25.0, 20.0)) < 1e-9 && (geom.length() - 20.0).abs() < 1e-9, "{geom:?}"); + assert_eq!(geom.is_mirrored(), mirror, "{geom:?}"); + // A quarter turn on lies along the unit +y: below the center, or above it mirrored. + let quarter = kurbo::Point::new(25.0, if mirror { 10.0 } else { 30.0 }); + assert!((geom.param_at(GradientKind::Conic, quarter) - 0.25).abs() < 1e-9, "{geom:?}"); + assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000"); + } + } + #[test] fn layers_artboards_curves_and_paints_open_as_editable_art() { for method in [Method::Stored, Method::Zlib, Method::Zstd] { diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 8e2c1d429a..db667e6ef2 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -218,6 +218,106 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() { assert!(l3.contains("/FlateDecode filter >> image")); } +/// The PNG thumbnail of an EPS file, decoded. +fn thumbnail_png(bytes: &[u8]) -> image::RgbaImage { + let png = vectorcraft_eps::thumbnail(bytes).expect("a thumbnail"); + image::load_from_memory_with_format(&png, image::ImageFormat::Png).unwrap().to_rgba8() +} + +/// The TIFF preview of an EPS file, decoded. +fn preview_tiff(bytes: &[u8]) -> image::RgbaImage { + let tiff = vectorcraft_eps::sections(bytes).unwrap().1.expect("a preview"); + image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap().to_rgba8() +} + +/// The size of a thumbnail render of the rect an EPS file's preview shows (its bounding box). +fn thumbnail_size(bytes: &[u8]) -> (u32, u32) { + let b: Vec = dsc(&postscript(bytes), "BoundingBox").unwrap().split(' ').map(|v| v.parse().unwrap()).collect(); + let rect = vectorcraft_geom::Rect::new(b[0], b[1], b[2], b[3]); + let long = rect.width().max(rect.height()); + vectorcraft_render::raster_size(rect, if long > 128.0 { 128.0 / long } else { 1.0 }).unwrap() +} + +/// The thumbnail is the color preview made smaller, the area average of its pixels at the size a +/// 128 px render has, not a second render of the document; it shows the same picture as one. +#[test] +fn the_thumbnail_is_made_from_the_preview() { + let mut s = session(1000.0, 700.0, 1); + s.execute("paint.setFill", &json!({"color": "#2060c0"})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 37.3, "y": 41.9, "width": 811.7, "height": 563.1})).unwrap(); + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "tiffColor", "transparentPreview": false})).unwrap(); + let bytes = b64(&r); + let preview = preview_tiff(&bytes); + assert_eq!(preview.dimensions(), (1000, 700)); + let thumb = thumbnail_png(&bytes); + assert_eq!(thumb.dimensions(), (128, 90)); + // An opaque preview's straight pixels are its premultiplied ones. + let shown = vectorcraft_render::Rendered { width: 1000, height: 700, pixels: preview.into_raw() }; + assert!(thumb.as_raw() == &shown.area_resized(128, 90).unwrap().pixels, "the thumbnail isn't the preview made smaller"); + let doc = &s.doc().unwrap().doc; + let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 700.0), 0.128, true); + let mut diffs: Vec = thumb.as_raw().iter().zip(&fresh.pixels).map(|(a, b)| a.abs_diff(*b)).collect(); + diffs.sort_unstable(); + let mean = diffs.iter().map(|d| f64::from(*d)).sum::() / diffs.len() as f64; + let p95 = diffs[diffs.len() * 95 / 100]; + assert!(mean <= 8.0 && p95 <= 32, "mean {mean}, 95th percentile {p95}"); +} + +/// A thumbnail is opaque color on white whatever the preview: a transparent color preview's, and a +/// black-and-white preview's, which keeps the art's colors. +#[test] +fn thumbnails_of_transparent_and_black_and_white_previews_are_opaque_color() { + let mut s = session(400.0, 200.0, 1); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap(); + let doc = &s.doc().unwrap().doc; + let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 400.0, 200.0), 0.32, true); + let red: [u8; 4] = fresh.pixels[(32 * 128 + 10) * 4..][..4].try_into().unwrap(); + for preview in ["tiffColor", "tiffBw"] { + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap(); + let thumb = thumbnail_png(&b64(&r)); + assert_eq!(thumb.dimensions(), (128, 64), "{preview}"); + assert_eq!(thumb.get_pixel(10, 32).0, red, "{preview}"); + assert_eq!(thumb.get_pixel(120, 32).0, [255, 255, 255, 255], "{preview}"); + assert!(thumb.pixels().all(|p| p[3] == 255), "{preview}"); + } +} + +/// A page that fits in a thumbnail gets its preview's own pixels, unresampled. +#[test] +fn a_small_page_s_thumbnail_is_its_preview() { + let mut s = session(100.0, 60.0, 1); + s.execute("shape.ellipse", &json!({"x": 10.5, "y": 7.25, "width": 70, "height": 41})).unwrap(); + let bytes = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "transparentPreview": false})).unwrap()); + let thumb = thumbnail_png(&bytes); + assert_eq!(thumb.dimensions(), (100, 60)); + assert!(thumb == preview_tiff(&bytes), "the thumbnail isn't the preview"); +} + +/// Every file has a thumbnail at the size a thumbnail render has, with any page shape, on Save +/// too and without a preview; with thumbnails off there is none. +#[test] +fn every_file_s_thumbnail_has_the_thumbnail_size() { + let mut s = session(333.3, 77.7, 2); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("shape.rectangle", &json!({"x": 3.7, "y": 5.1, "width": 300, "height": 61.3})).unwrap(); + s.execute("shape.ellipse", &json!({"x": second + 1.0, "y": 2.0, "width": 20, "height": 70})).unwrap(); + for preview in ["tiffColor", "tiffBw", "none"] { + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 3); + for f in files { + let bytes = b64(f); + assert_eq!(thumbnail_png(&bytes).dimensions(), thumbnail_size(&bytes), "{preview} {}", f["name"]); + } + } + assert_eq!(thumbnail_size(&b64(&s.execute("document.exportEps", &json!({"range": "1"})).unwrap())), (128, 30)); + let saved = b64(&s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap()); + assert_eq!(thumbnail_png(&saved).dimensions(), thumbnail_size(&saved)); + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "thumbnails": false})).unwrap(); + assert!(r["files"].as_array().unwrap().iter().all(|f| vectorcraft_eps::thumbnail(&b64(f)).is_none())); +} + /// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings` /// holds the note raster exports give, once, without naming either image's key. #[test] diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs new file mode 100644 index 0000000000..271f7b86e2 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -0,0 +1,104 @@ +//! HP-GL plotter files through the commands: `document.open` by extension or by content, File › +//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't +//! HP-GL refused with a message. + +use serde_json::json; +use vectorcraft_doc::NodeKind; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// An inch-wide red square and a black diagonal, with the thinnest pen. +const PLOT: &[u8] = b"IN;SP2;PW0;PA0,0;PD1016,0,1016,1016,0,1016,0,0;PU;SP1;PD1016,1016;PU;"; + +#[test] +fn document_open_reads_a_plot_file() { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(PLOT)})).unwrap(); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("hpgl"), Some("part.plt")), "{r}"); + let st = s.doc().unwrap(); + let board = st.doc.artboards[0].rect; + assert!((board.width() - 72.25).abs() < 1e-9 && (board.height() - 72.25).abs() < 1e-9, "{board:?}"); + let mut paths = vec![]; + st.doc.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + paths.push(n.clone()); + } + }); + assert_eq!(paths.len(), 2); + // Opened as artwork: Save asks for a name. + assert!(st.path.is_none()); + + // A plot file opens by its content in any of its names, and under a name no format reads. + for name in ["drawing.prn", "PART.HPGL", "part.hgl", "part.hpg"] { + let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(PLOT)})).unwrap(); + assert_eq!(r["format"].as_str(), Some("hpgl"), "{name}: {r}"); + } +} + +#[test] +fn file_place_places_a_plot_file_as_one_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let p = json!({"name": "part.plt", "dataBase64": b64(PLOT), "thumbnail": 16}); + let info = s.execute("file.place.info", &p).unwrap(); + assert_eq!(info["format"], "hpgl", "{info}"); + assert!((info["width"].as_f64().unwrap() - 72.25).abs() < 1e-9, "{info}"); + let r = s.execute("file.place", &p).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("hpgl"), Some(1)), "{r}"); + let doc = &s.doc().unwrap().doc; + let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + let NodeKind::Group { children, clip, .. } = &group.kind else { panic!("a group, got {:?}", group.kind) }; + assert_eq!((children.len(), *clip), (2, false), "the art's bounds, not clipped"); +} + +#[test] +fn files_that_are_not_hpgl_are_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let before = s.doc().unwrap().doc.artboards[0].rect.size(); + let script = b"set terminal png\nplot sin(x)\n"; + let e = s.execute("document.open", &json!({"name": "graph.plt", "dataBase64": b64(script)})).expect_err("refused"); + assert!(e.to_string().contains("not an HP-GL file"), "{e}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), before); + assert!(s.execute("file.place", &json!({"name": "graph.plt", "dataBase64": b64(script)})).is_err()); + // Text that isn't a plot opens by its name, not as HP-GL. + assert!(detect("notes.txt", b"IN THE BEGINNING").is_none_or(|f| f.id != "hpgl")); + // Only a plot file's own names, and names no other format reads, are read by their content: + // text, other formats' files and the formats named as unsupported (a DWG drawing starts with + // `AC` and digits) aren't, even when they start like a plot. + for name in ["notes.txt", "notes.svg", "picture.tga", "drawing.dxf", "site.dwg", "old.pct"] { + for bytes in [&b"IN"[..], b"in 5;", b"AC1032\0\0", PLOT] { + assert!(detect(name, bytes).is_none_or(|f| f.id != "hpgl"), "{name}: {:?}", String::from_utf8_lossy(bytes)); + } + } + let text = s.execute("file.place", &json!({"name": "notes.txt", "dataBase64": b64(b"IN 5; PA 10;")})).unwrap(); + assert_eq!(text["format"], "txt", "{text}"); +} + +/// A plot file named as one opens when its first instruction is one a plot file seldom starts with; +/// under another name it isn't taken for HP-GL. +#[test] +fn a_plot_file_by_its_name_may_start_with_any_instruction() { + let plot = b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"; + let r = Session::new().execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(plot)})).unwrap(); + assert_eq!(r["format"].as_str(), Some("hpgl"), "{r}"); + assert!(detect("drawing.prn", plot).is_none_or(|f| f.id != "hpgl")); +} + +#[test] +fn the_dialogs_and_the_format_list_name_hpgl() { + let exts = ["plt", "hpgl", "hgl", "hpg"]; + for e in exts { + assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}"); + assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("hpgl"), "{e}"); + } + let filter = ("HP-GL", &exts[..]); + assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); + let r = Session::new().execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false))); +} diff --git a/crates/engine/src/cmd/fileio/tests_psdlayers.rs b/crates/engine/src/cmd/fileio/tests_psdlayers.rs new file mode 100644 index 0000000000..31f670d7ff --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_psdlayers.rs @@ -0,0 +1,1017 @@ +//! Opening PSD and PSB files with their layers as layers (`document.open`'s `layers` and +//! `hiddenLayers`): names, visibility, opacity, blend modes, groups, layer masks, clipped layers +//! and blend modes VectorCraft lacks merged, 16-bit and PSB files, VectorCraft's own PSD export +//! read back, and damaged files read without a panic. + +use serde_json::{Value, json}; +use vectorcraft_color::BlendMode; +use vectorcraft_doc::{Node, NodeKind}; + +use super::*; + +/// One layer record of a synthetic file. +#[derive(Clone)] +struct L { + name: &'static str, + /// Top, left, bottom, right. + rect: [i32; 4], + /// Red, green, blue and alpha, the same for every pixel. + rgba: [u8; 4], + blend: &'static [u8; 4], + opacity: u8, + clipped: bool, + hidden: bool, + divider: Option, + /// Rect, default color, flags and the mask's pixels. + mask: Option<([i32; 4], u8, u8, Vec)>, + /// Extra tagged blocks. + blocks: Vec<([u8; 4], Vec)>, + /// Zipped channels: with prediction when `true`. + zip: Option, + /// The record lists no channels, whatever its rect. + bare: bool, + /// The Pascal name's bytes, with no Unicode name block. + legacy: Option<&'static [u8]>, +} + +fn layer(name: &'static str, rect: [i32; 4], rgba: [u8; 4]) -> L { + L { + name, + rect, + rgba, + blend: b"norm", + opacity: 255, + clipped: false, + hidden: false, + divider: None, + mask: None, + blocks: vec![], + zip: None, + bare: false, + legacy: None, + } +} + +fn group_end() -> L { + L { divider: Some(3), ..layer("", [0; 4], [0; 4]) } +} + +fn group(name: &'static str, blend: &'static [u8; 4]) -> L { + L { divider: Some(1), blend, ..layer(name, [0; 4], [0; 4]) } +} + +/// A tagged block, its length padded to even. +fn block(key: &[u8; 4], data: &[u8], psb: bool) -> Vec { + let mut data = data.to_vec(); + data.resize(data.len().next_multiple_of(2), 0); + let mut b = b"8BIM".to_vec(); + b.extend(key); + if psb && matches!(key, b"Lr16" | b"Lr32") { + b.extend((data.len() as u64).to_be_bytes()); + } else { + b.extend((data.len() as u32).to_be_bytes()); + } + b.extend(data); + b +} + +/// One row of a zipped channel with prediction: each sample stored as the difference from the one +/// before it, and in 32 bits the row's first bytes, then its second bytes and so on, each byte the +/// difference from the byte before it. +fn predict(row: &[u8], depth: u16) -> Vec { + let bytewise = |b: &[u8]| -> Vec { b.iter().scan(0u8, |prev, &v| Some(v.wrapping_sub(std::mem::replace(prev, v)))).collect() }; + match depth { + 16 => { + let mut prev = 0u16; + row.chunks(2) + .flat_map(|s| { + let v = u16::from_be_bytes([s[0], s[1]]); + v.wrapping_sub(std::mem::replace(&mut prev, v)).to_be_bytes() + }) + .collect() + } + 32 => bytewise(&(0..4).flat_map(|k| row.chunks(4).map(move |s| s[k])).collect::>()), + _ => bytewise(row), + } +} + +/// A channel of `n` samples of `v` (in 32 bits, `v` / 255 as a float) at `depth`, raw, zipped +/// (with prediction when `zip` is `Some(true)`) or PackBits rows of `width` when `rle`. +fn channel(v: u8, n: usize, width: usize, depth: u16, rle: bool, psb: bool, zip: Option) -> Vec { + let sample = if depth == 32 { (f32::from(v) / 255.0).to_be_bytes().to_vec() } else { vec![v; usize::from(depth / 8)] }; + let row: Vec = std::iter::repeat_n(sample.as_slice(), width).flatten().copied().collect(); + if let Some(predicted) = zip { + let rows = n.checked_div(width).unwrap_or(0); + let one = if predicted { predict(&row, depth) } else { row }; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + for _ in 0..rows { + std::io::Write::write_all(&mut z, &one).unwrap(); + } + let mut d = vec![0, if predicted { 3 } else { 2 }]; + d.extend(z.finish().unwrap()); + return d; + } + if !rle || n == 0 { + let mut d = vec![0, 0]; + d.extend(std::iter::repeat_n(sample.as_slice(), n).flatten()); + return d; + } + // Each row as literal runs of at most 128 bytes. + let mut packed = vec![]; + for run in row.chunks(128) { + packed.push((run.len() - 1) as u8); + packed.extend(run); + } + let rows = n / width; + let mut d = vec![0, 1]; + for _ in 0..rows { + if psb { + d.extend((packed.len() as u32).to_be_bytes()); + } else { + d.extend((packed.len() as u16).to_be_bytes()); + } + } + for _ in 0..rows { + d.extend(&packed); + } + d +} + +/// The layer info: the count (negative: the merged image has transparency), the records, then +/// their channels. +fn layer_info(layers: &[L], depth: u16, rle: bool, psb: bool) -> Vec { + let mut records: Vec = vec![]; + let mut data: Vec = vec![]; + for l in layers { + let [t, lf, b, r] = l.rect; + let (w, h) = ((r - lf).max(0) as usize, (b - t).max(0) as usize); + let mut channels: Vec<(i16, Vec)> = [(-1, l.rgba[3]), (0, l.rgba[0]), (1, l.rgba[1]), (2, l.rgba[2])] + .iter() + .filter(|_| !l.bare) + .map(|&(id, v)| (id, channel(v, w * h, w, depth, rle, psb, l.zip))) + .collect(); + let mut mask = vec![]; + if let Some(([mt, ml, mb, mr], default, flags, pixels)) = &l.mask { + let mut d = vec![0, 0]; + for &p in pixels { + d.extend(std::iter::repeat_n(p, usize::from(depth / 8))); + } + channels.push((-2, d)); + for v in [mt, ml, mb, mr] { + mask.extend(v.to_be_bytes()); + } + mask.extend([*default, *flags, 0, 0]); + } + for v in l.rect { + records.extend(v.to_be_bytes()); + } + records.extend((channels.len() as u16).to_be_bytes()); + for (id, d) in &channels { + records.extend(id.to_be_bytes()); + if psb { + records.extend((d.len() as u64).to_be_bytes()); + } else { + records.extend((d.len() as u32).to_be_bytes()); + } + data.extend(d); + } + records.extend(b"8BIM"); + records.extend(l.blend); + records.extend([l.opacity, u8::from(l.clipped), 0x08 | if l.hidden { 2 } else { 0 }, 0]); + let mut extra = (mask.len() as u32).to_be_bytes().to_vec(); + extra.extend(mask); + extra.extend(0u32.to_be_bytes()); + let ascii = l.legacy.unwrap_or(l.name.as_bytes()); + extra.push(ascii.len() as u8); + extra.extend(ascii); + // The Pascal string padded to a multiple of 4 (what comes before it is too). + extra.resize(extra.len().next_multiple_of(4), 0); + if l.legacy.is_none() { + let units: Vec = l.name.encode_utf16().collect(); + let mut luni = (units.len() as u32).to_be_bytes().to_vec(); + luni.extend(units.iter().flat_map(|u| u.to_be_bytes())); + extra.extend(block(b"luni", &luni, psb)); + } + if let Some(kind) = l.divider { + extra.extend(block(b"lsct", &[&kind.to_be_bytes()[..], b"8BIM", l.blend].concat(), psb)); + } + for (key, d) in &l.blocks { + extra.extend(block(key, d, psb)); + } + records.extend((extra.len() as u32).to_be_bytes()); + records.extend(extra); + } + let mut info = (-(layers.len() as i16)).to_be_bytes().to_vec(); + info.extend(records); + info.extend(data); + info.resize(info.len().next_multiple_of(2), 0); + info +} + +/// An RGB document of `w` × `h` pixels at 72 ppi holding `layers` (bottom first). 16- and 32-bit +/// documents keep their layers in an `Lr16` or `Lr32` block. +fn file(w: u32, h: u32, layers: &[L], depth: u16, rle: bool, psb: bool) -> Vec { + let info = layer_info(layers, depth, rle, psb); + let length = |n: usize, out: &mut Vec| { + if psb { + out.extend((n as u64).to_be_bytes()); + } else { + out.extend((n as u32).to_be_bytes()); + } + }; + let mut section = vec![]; + if depth == 8 { + length(info.len(), &mut section); + section.extend(info); + section.extend(0u32.to_be_bytes()); + } else { + length(0, &mut section); + section.extend(0u32.to_be_bytes()); + section.extend(block(if depth == 32 { b"Lr32" } else { b"Lr16" }, &info, psb)); + } + let mut f = b"8BPS".to_vec(); + f.extend(if psb { 2u16 } else { 1 }.to_be_bytes()); + f.extend([0; 6]); + f.extend(4u16.to_be_bytes()); + f.extend(h.to_be_bytes()); + f.extend(w.to_be_bytes()); + f.extend(depth.to_be_bytes()); + f.extend(3u16.to_be_bytes()); + f.extend(0u32.to_be_bytes()); + f.extend(0u32.to_be_bytes()); + length(section.len(), &mut f); + f.extend(section); + // The merged image: raw, mid gray, opaque. + f.extend([0, 0]); + let n = (w * h) as usize * usize::from(depth / 8); + f.extend(std::iter::repeat_n(128, n * 3)); + f.extend(std::iter::repeat_n(255, n)); + f +} + +/// The warning for one hidden layer left out. +const HIDDEN_1: &str = "1 layer was left out (hidden layers and the layers clipped to them)"; + +fn open(f: &[u8], p: Value) -> (Session, Value) { + let mut s = Session::new(); + let r = s.execute("document.open", &merge(json!({"name": "layers.psd", "dataBase64": vectorcraft_format::base64_encode(f)}), p)).unwrap(); + (s, r) +} + +fn name(n: &Node) -> &str { + n.name.as_deref().unwrap_or("") +} + +/// The layer's one image, named as the layer: its place (points) and its pixels. +fn image_of(s: &Session, l: &Node) -> ((f64, f64), image::RgbaImage) { + let Some([c]) = l.children().map(Vec::as_slice) else { panic!("{} holds {:?}", name(l), l.children().map(Vec::len)) }; + assert_eq!(name(c), name(l)); + pixels_of(s, c) +} + +/// An image's place (points) and its pixels. +fn pixels_of(s: &Session, c: &Node) -> ((f64, f64), image::RgbaImage) { + let NodeKind::Image(img) = &c.kind else { panic!("{} isn't an image", name(c)) }; + let blob = &s.doc().unwrap().doc.images[&img.key]; + let px = image::load_from_memory(&blob.bytes).unwrap().to_rgba8(); + assert_eq!((px.width(), px.height()), (img.width, img.height)); + let o = img.xf * kurbo::Point::ZERO; + ((o.x, o.y), px) +} + +/// Bottom, a pass-through group of a multiply layer at 50 % and a hidden layer, a screen group, and +/// a layer at 40 % fill opacity. +fn stack() -> Vec { + let mut fill = layer("Faded", [1, 1, 3, 3], [0, 0, 255, 255]); + fill.blocks.push((*b"iOpa", vec![102])); + vec![ + layer("Bottom", [0, 0, 8, 8], [255, 0, 0, 255]), + group_end(), + L { blend: b"mul ", opacity: 128, ..layer("Inner A", [0, 0, 4, 4], [0, 255, 0, 255]) }, + L { hidden: true, ..layer("Inner B", [4, 4, 8, 8], [0, 0, 255, 255]) }, + group("Folder", b"pass"), + group_end(), + layer("Lit", [2, 2, 6, 6], [10, 20, 30, 128]), + group("Screens", b"scrn"), + fill, + ] +} + +#[test] +fn layers_open_as_layers_with_their_names_visibility_opacity_and_blend_modes() { + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({})); + assert_eq!((&r["format"], &r["warnings"]), (&json!("psd"), &json!([HIDDEN_1])), "{r}"); + let d = &s.doc().unwrap().doc; + assert!((d.artboards[0].rect.width() - 8.0).abs() < 1e-9 && (d.artboards[0].rect.height() - 8.0).abs() < 1e-9); + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom", "Folder", "Screens", "Faded"], "bottom first"); + // The bottom layer's image covers the canvas in its color. + let ((x, y), px) = image_of(&s, &d.layers[0]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(3, 3).0), ((0.0, 0.0), (8, 8), [255, 0, 0, 255])); + // A pass-through group: a layer of sublayers that blend with what is below; its hidden layer + // is left out. + let folder = &d.layers[1]; + assert!(matches!(folder.kind, NodeKind::Layer { .. }) && !folder.isolate && folder.blend == BlendMode::Normal); + let inner: Vec<&str> = folder.children().unwrap().iter().map(|c| name(c)).collect(); + assert_eq!(inner, ["Inner A"]); + let a = &folder.children().unwrap()[0]; + assert!(a.blend == BlendMode::Multiply && (a.opacity - 128.0 / 255.0).abs() < 1e-6 && a.visible); + // Another group mode: isolated, in its mode; a layer keeps its transparency and place. + let screens = &d.layers[2]; + assert!(screens.isolate && screens.blend == BlendMode::Screen); + let ((x, y), px) = image_of(&s, &screens.children().unwrap()[0]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((2.0, 2.0), (4, 4), [10, 20, 30, 128])); + // Fill opacity multiplies the opacity. + assert!((d.layers[3].opacity - 102.0 / 255.0).abs() < 1e-6, "{}", d.layers[3].opacity); + // Hidden layers come in hidden when asked for. + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({"hiddenLayers": true})); + assert_eq!(r["warnings"], json!([]), "{r}"); + let folder = &s.doc().unwrap().doc.layers[1]; + let inner: Vec<(&str, bool)> = folder.children().unwrap().iter().map(|c| (name(c), c.visible)).collect(); + assert_eq!(inner, [("Inner A", true), ("Inner B", false)]); + // `layers: false` opens the merged image, as before. + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({"layers": false})); + let d = &s.doc().unwrap().doc; + assert_eq!((d.layers.len(), d.layers[0].children().map(Vec::len)), (1, Some(1)), "{r}"); + assert_eq!(pixels_of(&s, &d.layers[0].children().unwrap()[0]).1.get_pixel(0, 0).0, [128, 128, 128, 255]); + let bad = Session::new().execute("document.open", &json!({"name": "a.psd", "dataBase64": "AAAA", "hiddenLayers": "yes"})); + assert!(bad.unwrap_err().to_string().contains("hiddenLayers must be true or false")); +} + +#[test] +fn sixteen_bit_packbits_and_psb_layers_read() { + for (depth, rle, psb) in [(16, false, false), (8, true, false), (8, true, true), (16, true, true)] { + let (s, r) = open(&file(8, 8, &stack(), depth, rle, psb), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom", "Folder", "Screens", "Faded"], "{depth} {rle} {psb}: {r}"); + let px = image_of(&s, &d.layers[2].children().unwrap()[0]).1; + assert_eq!(px.get_pixel(1, 1).0, [10, 20, 30, 128], "{depth} {rle} {psb}"); + } +} + +/// Zipped channels, with and without prediction, at 8, 16 and 32 bits (32-bit colors are linear, +/// so the test uses 0 and 1, which sRGB's curve keeps). +#[test] +fn zipped_and_32_bit_layers_read() { + for depth in [8, 16, 32] { + for zip in [None, Some(false), Some(true)] { + if depth != 32 && zip.is_none() { + continue; + } + let layers = [L { zip, ..layer("Back", [0, 0, 4, 4], [0, 0, 255, 255]) }, L { zip, ..layer("Front", [1, 1, 3, 4], [255, 0, 255, 128]) }]; + let (s, r) = open(&file(4, 4, &layers, depth, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Back", "Front"], "{depth} {zip:?}: {r}"); + let ((x, y), px) = image_of(&s, &d.layers[1]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(2, 1).0), ((1.0, 1.0), (3, 2), [255, 0, 255, 128]), "{depth} {zip:?}"); + } + } +} + +/// A layer without a Unicode name takes the name its Pascal string holds, read as Mac Roman. +#[test] +fn names_without_a_unicode_name_read_as_mac_roman() { + let l = L { legacy: Some(b"Caf\x8E \xD2Old\xD3"), ..layer("", [0, 0, 1, 1], [0, 0, 0, 255]) }; + let (s, r) = open(&file(1, 1, &[l], 8, false, false), json!({})); + assert_eq!(name(&s.doc().unwrap().doc.layers[0]), "Café “Old”", "{r}"); +} + +#[test] +fn layer_masks_become_opacity_masks() { + let mut masked = layer("Masked", [0, 0, 8, 8], [255, 255, 255, 255]); + masked.mask = Some(([2, 2, 4, 4], 0, 0, vec![255, 128, 0, 255])); + let mut shown = layer("Shown outside", [0, 0, 8, 8], [0, 0, 0, 255]); + shown.mask = Some(([0, 0, 1, 2], 255, 0, vec![0, 0])); + let mut off = layer("Off", [0, 0, 8, 8], [0, 0, 0, 255]); + off.mask = Some(([0, 0, 1, 1], 0, 2, vec![0])); + // Flag bit 2: the mask's values and its default color are read inverted. + let mut inverted = layer("Inverted", [0, 0, 8, 8], [0, 0, 0, 255]); + inverted.mask = Some(([0, 0, 1, 2], 0, 4, vec![255, 64])); + let (s, _) = open(&file(8, 8, &[masked, shown, off, inverted], 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let mask = |i: usize| d.layers[i].extras().mask.as_deref().expect("a mask"); + let m = mask(0); + assert!(m.clip && !m.disabled, "outside a mask with default 0 is hidden"); + let ((x, y), px) = pixels_of(&s, &m.art); + assert_eq!(((x, y), px.dimensions()), ((2.0, 2.0), (2, 2))); + assert_eq!([px.get_pixel(0, 0).0, px.get_pixel(1, 0).0, px.get_pixel(0, 1).0], [[255, 255, 255, 255], [128, 128, 128, 255], [0, 0, 0, 255]]); + assert!(!mask(1).clip, "outside a mask with default 255 shows"); + assert!(mask(2).disabled, "a disabled mask is kept, switched off"); + let m = mask(3); + assert!(!m.clip, "an inverted default of 0 shows outside the mask"); + let px = pixels_of(&s, &m.art).1; + assert_eq!([px.get_pixel(0, 0).0, px.get_pixel(1, 0).0], [[0, 0, 0, 255], [191, 191, 191, 255]]); +} + +#[test] +fn clipped_layers_merge_into_the_layer_below() { + let base = L { opacity: 200, ..layer("Base", [4, 4, 8, 8], [255, 255, 0, 255]) }; + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 8, 6], [0, 0, 255, 255]) }; + let hidden = L { clipped: true, hidden: true, ..layer("Gone", [0, 0, 8, 8], [0, 255, 0, 255]) }; + let (s, r) = + open(&file(8, 8, &[layer("Under", [0, 0, 2, 2], [1, 2, 3, 255]), base, clipped, hidden], 8, false, false), json!({"hiddenLayers": true})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Base + Clipped"], "{r}"); + assert_eq!(r["warnings"], json!([HIDDEN_1]), "a hidden clipped layer is left out: {r}"); + // The base keeps its place, transparency and opacity; the clipped layer shows only on it. + let merged = &d.layers[1]; + assert!((merged.opacity - 200.0 / 255.0).abs() < 1e-6); + let ((x, y), px) = image_of(&s, merged); + assert_eq!(((x, y), px.dimensions()), ((4.0, 4.0), (4, 4))); + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(3, 3).0), ([0, 0, 255, 255], [255, 255, 0, 255])); + // A base's layer mask stays the merged layer's opacity mask, over the unmasked pixels. + let mut masked = layer("Masked", [0, 0, 4, 4], [255, 255, 0, 255]); + masked.mask = Some(([0, 0, 1, 1], 0, 0, vec![255])); + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[masked, clipped], 8, false, false), json!({})); + let merged = &s.doc().unwrap().doc.layers[0]; + assert_eq!(name(merged), "Masked + Clipped", "{r}"); + assert!(merged.extras().mask.as_deref().is_some_and(|m| m.clip && !m.disabled), "{r}"); + let px = image_of(&s, merged).1; + assert_eq!((px.dimensions(), px.get_pixel(3, 3).0), ((4, 4), [0, 0, 255, 255])); + // A hidden base would need the merge: even with hidden layers asked for, it is left out with + // the layers clipped to it. + let hidden_base = L { hidden: true, ..layer("Hidden base", [0, 0, 4, 4], [255, 255, 0, 255]) }; + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let layers = [layer("Under", [0, 0, 2, 2], [1, 2, 3, 255]), hidden_base, clipped, layer("Over", [0, 0, 1, 1], [9, 9, 9, 255])]; + for hidden in [false, true] { + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({"hiddenLayers": hidden})); + let names: Vec<&str> = s.doc().unwrap().doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Over"], "{hidden}: {r}"); + assert_eq!(r["warnings"], json!(["2 layers were left out (hidden layers and the layers clipped to them)"]), "{hidden}: {r}"); + } +} + +/// A masked base merged with its clipped layers, then with a layer in a mode VectorCraft lacks, +/// still hides what its mask hides. +#[test] +fn a_merged_base_keeps_its_mask_in_a_later_merge() { + let mut masked = layer("Masked", [0, 0, 4, 4], [255, 0, 0, 255]); + masked.mask = Some(([0, 0, 1, 1], 0, 0, vec![255])); + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let dodge = L { blend: b"lddg", ..layer("Dodge", [3, 3, 4, 4], [0, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 4, 4], [0, 255, 0, 255]), masked, clipped, dodge]; + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + assert_eq!(name(&d.layers[0]), "Bottom + Masked + Clipped + Dodge", "{r}"); + let px = image_of(&s, &d.layers[0]).1; + // Inside the mask the clipped layer shows; outside it, the layer below. + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(2, 2).0), ([0, 0, 255, 255], [0, 255, 0, 255])); +} + +#[test] +fn layers_in_modes_vectorcraft_lacks_merge_with_the_layers_below() { + let dodge = L { blend: b"lddg", ..layer("Dodge", [0, 0, 2, 2], [100, 100, 100, 255]) }; + let hidden = L { hidden: true, ..layer("Hidden", [0, 0, 8, 8], [0, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), hidden, dodge, layer("Top", [6, 6, 8, 8], [0, 0, 0, 255])]; + let (s, r) = open(&file(8, 8, &layers, 8, false, false), json!({"hiddenLayers": true})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Dodge", "Top"], "hidden layers a merge takes in are left out: {r}"); + let (_, px) = image_of(&s, &d.layers[0]); + // Linear Dodge adds: 100 + 100, 50 + 100, 0 + 100. + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(5, 5).0), ([200, 150, 100, 255], [100, 50, 0, 255])); + assert!(r["warnings"].to_string().contains("Linear Dodge"), "{r}"); + // Darker Color keeps the color whose channels add up to less: (0, 0, 200) over (100, 50, 0) + // has the larger total but the lower luminance, so the base stays. + let darker = L { blend: b"dkCl", ..layer("Darker", [0, 0, 2, 2], [0, 0, 200, 255]) }; + let (s, _) = open(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), darker], 8, false, false), json!({})); + let (_, px) = image_of(&s, &s.doc().unwrap().doc.layers[0]); + assert_eq!(px.get_pixel(0, 0).0, [100, 50, 0, 255]); + // A hidden layer in such a mode would need the merge: left out, hidden layers asked for or not. + let hidden_dodge = L { hidden: true, blend: b"lddg", ..layer("Hidden dodge", [0, 0, 2, 2], [100, 100, 100, 255]) }; + let (s, r) = open(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), hidden_dodge], 8, false, false), json!({"hiddenLayers": true})); + let names: Vec<&str> = s.doc().unwrap().doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!((names, &r["warnings"]), (vec!["Bottom"], &json!([HIDDEN_1])), "{r}"); +} + +/// A merge whose canvases would need more memory than allowed leaves the layer in the Normal mode, +/// with a warning. +#[test] +fn a_merge_past_the_memory_allowed_leaves_the_layer_normal() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let layers = [layer("Bottom", [0, 0, 2, 2], [100, 50, 0, 255]), L { blend: b"lddg", ..layer("Dodge", [2, 2, 4, 4], [100, 100, 100, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + // Each 2 × 2 layer fits 128 bytes (16 a pixel), and so does the first canvas of the merge with + // the layer beside it; the 4 × 4 canvas of both doesn't. + let (doc, warnings) = psdlayers::import(&f, false, 128, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Bottom", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + // With room for it, the merge is made. + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Dodge"]); + // A merge onto a canvas that already covers the layer holds both: two 4 × 4 canvases take 512 + // bytes, past 300, although the canvas of the two 1 × 1 layers below fits (288 bytes). + let layers = [ + layer("Bottom", [0, 0, 1, 1], [100, 50, 0, 255]), + layer("Corner", [3, 3, 4, 4], [0, 50, 100, 255]), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 4, 4], [100, 100, 100, 255]) }, + ]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 300, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom", "Corner", "Dodge"]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Corner + Dodge"]); + // A group below whose layers each fit, but whose canvas (4 × 4 with both 1 × 1 layers held, 288 + // bytes) doesn't fit 100: the layers stay, and the layer comes in Normal. + let layers = [ + group_end(), + layer("Left", [0, 0, 1, 1], [100, 50, 0, 255]), + layer("Right", [3, 3, 4, 4], [0, 50, 100, 255]), + group("Pair", b"norm"), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [100, 100, 100, 255]) }, + ]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 100, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Pair", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(doc.layers[0].children().unwrap().iter().map(|c| name(c)).collect::>(), ["Left", "Right"]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Pair + Dodge"]); + // A layer below whose pixels fit (128 bytes) but whose canvas (16 bytes a pixel, 256) doesn't + // fit 200: the same. + let layers = [layer("Big", [0, 0, 4, 4], [100, 50, 0, 255]), L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [100, 100, 100, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 200, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Big", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + // A clipped layer whose merge doesn't fit (the base's canvas and its transparency, 20 bytes a + // pixel, beside the clipped layer's canvas, 16: 576 bytes, past 300) comes in unclipped. + let layers = [layer("Base", [0, 0, 4, 4], [255, 0, 0, 255]), L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 300, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Base", "Clipped"]); + assert_eq!(warnings, ["1 layer clipped to the layer below came in unclipped: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Base + Clipped"]); +} + +/// A merge that can't be made counts nothing that the merges inside it counted: here the merge +/// in a group at half opacity fails inside the outer merge's attempt (the copy of what is below the +/// group takes the room) and succeeds when the group comes in. +#[test] +fn a_merge_that_cant_be_made_counts_only_itself() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let layers = [ + layer("Under", [0, 0, 2, 2], [100, 50, 0, 255]), + group_end(), + layer("Inner", [0, 0, 2, 2], [10, 10, 10, 255]), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 2, 2], [20, 20, 20, 255]) }, + L { opacity: 128, ..group("Pass", b"pass") }, + L { blend: b"diss", ..layer("Top", [0, 0, 2, 2], [0, 0, 0, 255]) }, + ]; + let (doc, warnings) = psdlayers::import(&file(2, 2, &layers, 8, false, false), false, 150, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Under", BlendMode::Normal), ("Pass", BlendMode::Normal), ("Top", BlendMode::Normal)]); + assert_eq!(doc.layers[1].children().unwrap().iter().map(|c| name(c)).collect::>(), ["Inner + Dodge"]); + assert_eq!( + warnings, + [ + "layers in blend modes VectorCraft doesn't have (Linear Dodge) were merged with the layers below them", + "1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed", + ] + ); +} + +/// Layers in modes VectorCraft lacks, one after another, merge onto the canvas the first merge made +/// without copying it: 300 of them over a 64 × 64 canvas (64 KiB) open within an allowance that a +/// copy for each would pass. Merges that need a copy of that canvas each, past the allowance, open +/// the merged image instead. +#[test] +fn merges_onto_a_merge_reuse_its_canvas() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let dodge = |rect: [i32; 4]| L { blend: b"lddg", ..layer("d", rect, [1, 1, 1, 255]) }; + let mut layers = vec![layer("Bottom", [0, 0, 1, 1], [0, 0, 0, 255]), dodge([63, 63, 64, 64])]; + layers.extend((0..300).map(|_| dodge([1, 1, 2, 2]))); + let (doc, warnings) = psdlayers::import(&file(64, 64, &layers, 8, false, false), false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!((doc.layers.len(), warnings.len()), (1, 1), "{warnings:?}"); + assert!(name(&doc.layers[0]).starts_with("Bottom + d + d") && name(&doc.layers[0]).matches(" + d").count() == 301); + // A Normal layer between each pair: every merge copies the 64 KiB canvas, and 150 of them pass + // eight times the allowance of 1 MiB. + let mut layers = vec![layer("Bottom", [0, 0, 1, 1], [0, 0, 0, 255]), dodge([63, 63, 64, 64])]; + for _ in 0..150 { + layers.extend([layer("n", [1, 1, 2, 2], [1, 1, 1, 255]), dodge([2, 2, 3, 3])]); + } + let e = psdlayers::import(&file(64, 64, &layers, 8, false, false), false, 1 << 20, cms).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("take more memory to read than allowed")), "{e:?}"); +} + +/// A Dissolve layer at half opacity merges as pixels of its own color or of the color below, +/// about half of each, and none in between. +#[test] +fn dissolve_layers_merge_as_opaque_or_transparent_pixels() { + let dissolve = L { blend: b"diss", opacity: 128, ..layer("Dissolve", [0, 0, 16, 16], [255, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 16, 16], [255, 255, 255, 255]), dissolve]; + let (s, r) = open(&file(16, 16, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Dissolve"], "{r}"); + let (_, px) = image_of(&s, &d.layers[0]); + let red = px.pixels().filter(|p| p.0 == [255, 0, 0, 255]).count(); + let white = px.pixels().filter(|p| p.0 == [255, 255, 255, 255]).count(); + assert_eq!(red + white, 256, "every pixel is the layer's color or the color below"); + assert!((96..=160).contains(&red), "{red} of 256 pixels are the layer's"); + // A second Dissolve layer at half opacity over the first has its own pattern: about a quarter + // of the pixels still show the first. + let blue = L { blend: b"diss", opacity: 128, ..layer("Blue", [0, 0, 16, 16], [0, 0, 255, 255]) }; + let layers = [layers[0].clone(), layers[1].clone(), blue]; + let (s, r) = open(&file(16, 16, &layers, 8, false, false), json!({})); + let (_, px) = image_of(&s, &s.doc().unwrap().doc.layers[0]); + let red = px.pixels().filter(|p| p.0 == [255, 0, 0, 255]).count(); + assert!((32..=96).contains(&red), "{red} of 256 pixels show the lower layer: {r}"); +} + +#[test] +fn what_doesnt_come_in_is_said() { + let mut text = layer("Words", [0, 0, 2, 2], [0, 0, 0, 255]); + text.blocks.push((*b"TySh", vec![0; 4])); + let mut adjust = layer("Levels", [0, 0, 0, 0], [0; 4]); + adjust.blocks.push((*b"levl", vec![0; 4])); + let mut fx = layer("Shadowed", [0, 0, 2, 2], [0, 0, 0, 255]); + fx.blocks.push((*b"lfx2", effects(true, true))); + fx.blocks.push((*b"knko", vec![1, 0, 0, 0])); + // Effects with the master switch off, or none switched on, aren't counted. + let mut fx_off = layer("Switched off", [0, 0, 1, 1], [0, 0, 0, 255]); + fx_off.blocks.push((*b"lfx2", effects(false, true))); + let mut fx_none = layer("None on", [0, 0, 1, 1], [0, 0, 0, 255]); + fx_none.blocks.push((*b"lfx2", effects(true, false))); + // An adjustment layer clipped to the layer below draws nothing, and is still counted. + let mut clipped_adjust = L { clipped: true, ..layer("Curves", [0, 0, 0, 0], [0; 4]) }; + clipped_adjust.blocks.push((*b"curv", vec![0; 4])); + let mut solid = layer("Fill", [0, 0, 0, 0], [0; 4]); + let mut soco = vec![0; 8]; + for (key, v) in [(b"Rd doub", 255.0f64), (b"Grn doub", 0.0), (b"Bl doub", 51.0)] { + soco.extend(key); + soco.extend(v.to_be_bytes()); + } + solid.blocks.push((*b"SoCo", soco.clone())); + // A shape layer: a solid color fill with a vector mask and the shape's pixels stored. + let mut shape = layer("Shape", [1, 1, 3, 2], [255, 0, 51, 255]); + shape.blocks.push((*b"SoCo", soco.clone())); + shape.blocks.push((*b"vmsk", vec![0; 8])); + // One with its vector mask only, no pixels: not drawn, rather than its color over the canvas, + // and the layer clipped to it isn't either. + let mut outline = layer("Outline", [0, 0, 0, 0], [0; 4]); + outline.blocks.push((*b"SoCo", soco)); + outline.blocks.push((*b"vsms", vec![0; 8])); + let on_outline = L { clipped: true, ..layer("On outline", [0, 0, 2, 2], [1, 1, 1, 255]) }; + let layers = [solid, text, clipped_adjust, adjust, fx, fx_off, fx_none, shape, outline, on_outline]; + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Fill", "Words", "Shadowed", "Switched off", "None on", "Shape"], "an adjustment layer draws nothing"); + assert_eq!(image_of(&s, &d.layers[0]).1.get_pixel(3, 3).0, [255, 0, 51, 255], "a solid color fill covers the canvas"); + let ((x, y), px) = image_of(&s, &d.layers[5]); + assert_eq!(((x, y), px.dimensions()), ((1.0, 1.0), (1, 2)), "a shape layer comes in as the pixels it stores"); + let w = r["warnings"].to_string(); + for said in [ + "1 type layer came in as pixels", + "2 adjustment layers were not applied", + "1 fill layer was not drawn", + "the layer effects of 1 layer weren't drawn", + "the vector masks of 2 layers", + "the knockout of 1 layer wasn't applied", + "1 layer clipped to a fill layer that wasn't drawn was left out", + ] { + assert!(w.contains(said), "{said}: {w}"); + } +} + +/// An effects descriptor (`lfx2`) with its master switch and one effect's `enab` set: each key after +/// its length (0 for a 4-character key), then `bool` and a byte. +fn effects(master: bool, enabled: bool) -> Vec { + let mut d = vec![0; 8]; + d.extend(14u32.to_be_bytes()); + d.extend(b"masterFXSwitchbool"); + d.push(u8::from(master)); + d.extend(0u32.to_be_bytes()); + d.extend(b"enabbool"); + d.push(u8::from(enabled)); + d +} + +/// A layer's fill opacity fades only its own pixels when layers are clipped to it: they still show +/// in its shape, and the merged layer keeps the layer's opacity. +#[test] +fn a_clipping_base_fill_opacity_fades_only_its_own_pixels() { + for (fill, size, corner) in [(0u8, (4, 2), None), (128, (4, 4), Some([255, 0, 0, 128]))] { + let mut base = L { opacity: 200, ..layer("Base", [0, 0, 4, 4], [255, 0, 0, 255]) }; + base.blocks.push((*b"iOpa", vec![fill])); + let photo = L { clipped: true, ..layer("Photo", [0, 0, 2, 4], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[base, photo], 8, false, false), json!({})); + let merged = &s.doc().unwrap().doc.layers[0]; + assert_eq!(name(merged), "Base + Photo", "{r}"); + assert!((merged.opacity - 200.0 / 255.0).abs() < 1e-6, "{fill}: {}", merged.opacity); + let ((x, y), px) = image_of(&s, merged); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((0.0, 0.0), size, [0, 0, 255, 255]), "{fill}"); + if let Some(c) = corner { + assert!(px.get_pixel(3, 3).0.iter().zip(c).all(|(&a, b)| a.abs_diff(b) <= 1), "{fill}: {:?}", px.get_pixel(3, 3)); + } + } +} + +/// Layers clipped to an adjustment layer show over the whole canvas of the file, through the +/// adjustment layer's mask. +#[test] +fn layers_clipped_to_an_adjustment_layer_show_over_the_canvas() { + let mut curves = layer("Curves", [0, 0, 0, 0], [0; 4]); + curves.blocks.push((*b"curv", vec![0; 4])); + curves.mask = Some(([0, 0, 1, 1], 255, 0, vec![0])); + let photo = L { clipped: true, ..layer("Photo", [-1, -1, 2, 5], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[layer("Under", [0, 0, 4, 4], [255, 0, 0, 255]), curves, photo], 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Curves + Photo"], "{r}"); + // Cut to the canvas: 4 × 2 at its corner. + let ((x, y), px) = image_of(&s, &d.layers[1]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(1, 1).0), ((0.0, 0.0), (4, 2), [0, 0, 255, 255])); + assert!(d.layers[1].extras().mask.is_some(), "the adjustment layer's mask stays"); + assert!(r["warnings"].to_string().contains("1 adjustment layer was not applied"), "{r}"); +} + +/// Each blend mode VectorCraft lacks merges with the layer below by its formula, from the public +/// descriptions: +/// (Vivid Light, Pin Light, Hard Mix, Darker and Lighter Color) and +/// (Linear Burn, Linear Dodge, Linear Light, Subtract, +/// Divide). Color Dodge, Color Burn and Difference keep their mode. +#[test] +fn every_mode_vectorcraft_lacks_merges_by_its_formula() { + let b = [200u8, 100, 30]; + let s = [60u8, 180, 128]; + let burn = |b: f64, c: f64| if c <= 0.0 { if b >= 1.0 { 1.0 } else { 0.0 } } else { 1.0 - ((1.0 - b) / c).min(1.0) }; + let dodge = |b: f64, c: f64| if c >= 1.0 { if b <= 0.0 { 0.0 } else { 1.0 } } else { (b / (1.0 - c)).min(1.0) }; + let each = |f: &dyn Fn(f64, f64) -> f64| -> [u8; 3] { + [0, 1, 2].map(|i| (f(f64::from(b[i]) / 255.0, f64::from(s[i]) / 255.0).clamp(0.0, 1.0) * 255.0).round() as u8) + }; + let total = |c: [u8; 3]| c.iter().map(|&v| u32::from(v)).sum::(); + let cases: [(&[u8; 4], [u8; 3]); 10] = [ + (b"lbrn", each(&|b, s| b + s - 1.0)), + (b"lddg", each(&|b, s| b + s)), + (b"vLit", each(&|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) })), + (b"lLit", each(&|b, s| b + 2.0 * s - 1.0)), + (b"pLit", each(&|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) })), + (b"hMix", each(&|b, s| if (b + s) * 255.0 >= 254.999 { 1.0 } else { 0.0 })), + (b"fsub", each(&|b, s| b - s)), + (b"fdiv", each(&|b, s| if s <= 0.0 { 1.0 } else { b / s })), + (b"dkCl", if total(s) < total(b) { s } else { b }), + (b"lgCl", if total(s) > total(b) { s } else { b }), + ]; + for (key, want) in cases { + let top = L { blend: key, ..layer("Top", [0, 0, 1, 1], [s[0], s[1], s[2], 255]) }; + let (st, r) = open(&file(1, 1, &[layer("Bottom", [0, 0, 1, 1], [b[0], b[1], b[2], 255]), top], 8, false, false), json!({})); + let d = &st.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Top"], "{key:?}: {r}"); + let got = image_of(&st, &d.layers[0]).1.get_pixel(0, 0).0; + assert!( + got[..3].iter().zip(want).all(|(&g, w)| g.abs_diff(w) <= 1) && got[3] == 255, + "{}: {got:?}, want {want:?}", + String::from_utf8_lossy(key) + ); + } + for (key, mode) in [(b"div ", BlendMode::ColorDodge), (b"idiv", BlendMode::ColorBurn), (b"diff", BlendMode::Difference)] { + let top = L { blend: key, ..layer("Top", [0, 0, 1, 1], [s[0], s[1], s[2], 255]) }; + let (st, r) = open(&file(1, 1, &[layer("Bottom", [0, 0, 1, 1], [b[0], b[1], b[2], 255]), top], 8, false, false), json!({})); + let d = &st.doc().unwrap().doc; + let got: Vec<(&str, BlendMode)> = d.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Bottom", BlendMode::Normal), ("Top", mode)], "{r}"); + } +} + +/// A descriptor's numbers: each key (a 4-character key after a length of 0, a longer one after its +/// length), then `doub` and the value, or for `H `, `UntF`, `#Ang` and the value. +fn descriptor(values: &[(&[u8], f64)]) -> Vec { + let mut d = vec![0; 8]; + for (key, v) in values { + d.extend(if key.len() == 4 { 0u32 } else { key.len() as u32 }.to_be_bytes()); + d.extend(*key); + d.extend(if *key == b"H " { &b"UntF#Ang"[..] } else { b"doub" }); + d.extend(v.to_be_bytes()); + } + d +} + +/// Solid color fill layers that store no pixels come in as their color in every color model a +/// descriptor uses; one whose color can't be read, and a gradient fill that stores no pixels, are +/// counted; a gradient fill that stores pixels comes in as them. +#[test] +fn solid_color_fills_read_every_color_model() { + let fill = |name: &'static str, values: &[(&[u8], f64)]| { + let mut l = layer(name, [0, 0, 0, 0], [0; 4]); + l.blocks.push((*b"SoCo", descriptor(values))); + l + }; + let mut gradient = layer("Gradient", [0, 0, 0, 0], [0; 4]); + gradient.blocks.push((*b"GdFl", vec![0; 8])); + let mut gradient_shape = layer("Gradient shape", [1, 1, 2, 2], [7, 8, 9, 255]); + gradient_shape.blocks.push((*b"GdFl", vec![0; 8])); + // Bounds, but no channels: it stores no pixels either. + let mut pattern = L { bare: true, ..layer("Pattern", [0, 0, 4, 4], [0; 4]) }; + pattern.blocks.push((*b"PtFl", vec![0; 8])); + let layers = [ + fill("Float", &[(b"redFloat", 0.0), (b"greenFloat", 0.0), (b"blueFloat", 1.0)]), + fill("Cmyk", &[(b"Cyn ", 0.0), (b"Mgnt", 100.0), (b"Ylw ", 100.0), (b"Blck", 0.0)]), + fill("Gray", &[(b"Gry ", 25.0)]), + fill("Lab", &[(b"Lmnc", 100.0), (b"A ", 0.0), (b"B ", 0.0)]), + fill("Hsb", &[(b"H ", 120.0), (b"Strt", 100.0), (b"Brgh", 100.0)]), + fill("Unread", &[(b"Bk ", 1.0)]), + gradient, + gradient_shape, + pattern, + ]; + // A naive CMYK conversion, so that the colors don't depend on the color settings. + let naive = |c: [f32; 4]| [0, 1, 2].map(|i| (1.0 - c[i]) * (1.0 - c[3])); + let (doc, warnings) = psdlayers::import(&file(4, 4, &layers, 8, false, false), false, 1 << 20, naive).unwrap().unwrap(); + let names: Vec<&str> = doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Float", "Cmyk", "Gray", "Lab", "Hsb", "Gradient shape"]); + let mut s = Session::new(); + s.add_document(doc, None); + let d = &s.doc().unwrap().doc; + let colors: Vec<[u8; 4]> = d.layers[..5].iter().map(|l| image_of(&s, l).1.get_pixel(2, 2).0).collect(); + assert_eq!(colors[..3], [[0, 0, 255, 255], [255, 0, 0, 255], [191, 191, 191, 255]]); + assert!(colors[3].iter().all(|&v| v >= 253), "Lab 100, 0, 0 is white: {:?}", colors[3]); + assert_eq!(colors[4], [0, 255, 0, 255]); + assert_eq!(image_of(&s, &d.layers[5]).1.get_pixel(0, 0).0, [7, 8, 9, 255]); + assert_eq!(warnings, ["3 fill layers were not drawn (a gradient or pattern fill, a color that can't be read, or a shape that stores no pixels)"]); +} + +/// VectorCraft's own PSD export opens with the layers it wrote: groups as layers of sublayers, +/// hidden ones hidden, with their opacity and blend mode. +#[test] +fn psd_exports_open_with_their_layers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 30, "height": 40})).unwrap(); + s.execute("layer.new", &json!({"name": "Blue"})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 20, "height": 40})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50, "blend": "Multiply"})).unwrap(); + s.execute("layer.new", &json!({"name": "Off"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let off = s.doc().unwrap().doc.layers.last().unwrap().id; + s.execute("layer.setProps", &json!({"id": off.0, "visible": false})).unwrap(); + for (options, top) in [ + (json!({"hiddenLayers": true}), vec![("Layer 1", true), ("Blue", true), ("Off", false)]), + (json!({"maxEditability": true}), vec![("Layer 1", true), ("Blue", true)]), + ] { + let r = s.execute("document.export", &merge(json!({"format": "psd"}), options.clone())).unwrap(); + let f = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let (back, r) = open(&f, json!({"hiddenLayers": true})); + let d = &back.doc().unwrap().doc; + let got: Vec<(&str, bool)> = d.layers.iter().map(|l| (name(l), l.visible)).collect(); + assert_eq!(got, top, "{options}: {r}"); + if options["maxEditability"] == true { + // Each layer a group: its objects become sublayers, the rectangle's opacity and blend mode + // on its own. + let blue = d.layers[1].children().unwrap(); + assert_eq!(blue.len(), 1, "{options}"); + assert!(blue[0].blend == BlendMode::Multiply && (blue[0].opacity - 0.5).abs() < 0.01, "{options}"); + } + } +} + +#[test] +fn files_without_layers_or_with_damaged_layers_open_as_their_merged_image() { + // No layer records: the merged image, without a warning. + let flat = { + let mut f = file(2, 2, &[layer("x", [0, 0, 2, 2], [9, 9, 9, 255])], 8, false, false); + // The layer and mask section emptied: 4 length bytes of 0, after the header and two + // empty sections. + let at = 26 + 8; + let len = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize; + f.splice(at..at + 4 + len, 0u32.to_be_bytes()); + f + }; + let (_, r) = open(&flat, json!({})); + assert_eq!(r["warnings"], json!([]), "{r}"); + // Layer info of just the count (negative: the merged image has transparency) has no layers. + let count_only = { + let mut f = flat.clone(); + let at = 26 + 8; + f.splice(at..at + 4, [&10u32.to_be_bytes()[..], &2u32.to_be_bytes(), &(-1i16).to_be_bytes(), &[0, 0, 0, 0]].concat()); + f + }; + let (s, r) = open(&count_only, json!({})); + assert_eq!((&r["warnings"], s.doc().unwrap().doc.layers[0].children().map(Vec::len)), (&json!([]), Some(1)), "{r}"); + // A channel cut short: the merged image, with a warning. + let mut cut = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + let at = cut.windows(4).position(|w| w == [0, 0, 0, 18]).unwrap(); + cut[at..at + 4].copy_from_slice(&1000u32.to_be_bytes()); + let (s, r) = open(&cut, json!({})); + assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(Vec::len), Some(1)); + // Groups nested past the limit. + let mut deep: Vec = (0..=psdlayers::MAX_DEPTH).map(|_| group_end()).collect(); + deep.push(layer("x", [0, 0, 1, 1], [0, 0, 0, 255])); + let (_, r) = open(&file(2, 2, &deep, 8, false, false), json!({})); + assert!(r["warnings"][0].as_str().unwrap().contains("nested too deeply"), "{r}"); + // Groups within the limit but deeper than a document may nest: the deepest merge into the + // group around them, with the warning. + let mut deep: Vec = (0..40).map(|_| group_end()).collect(); + deep.push(layer("x", [0, 0, 1, 1], [0, 0, 0, 255])); + deep.extend((0..40).map(|_| group("g", b"norm"))); + let (s, r) = open(&file(2, 2, &deep, 8, false, false), json!({})); + assert_eq!(r["warnings"], json!([vectorcraft_doc::depth::merged_note()]), "{r}"); + fn depth(n: &Node) -> usize { + 1 + n.children().map_or(0, |c| c.iter().map(|c| depth(c)).max().unwrap_or(0)) + } + assert!(s.doc().unwrap().doc.layers.iter().map(|l| depth(l)).max().unwrap() <= vectorcraft_doc::MAX_DEPTH); +} + +/// A record whose layer mask or name is cut short opens the merged image with a warning, rather +/// than a layer without its mask or name. +#[test] +fn records_with_a_damaged_mask_or_name_open_as_the_merged_image() { + let good = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + // The record's extra data: an empty mask section, empty blending ranges, then the name. + let at = good.windows(10).position(|w| w == [0, 0, 0, 0, 0, 0, 0, 0, 1, b'x']).unwrap(); + // A mask section of 4 bytes, too short for a mask's place, default color and flags. + let mut short_mask = good.clone(); + short_mask[at + 3] = 4; + // A name longer than the record's extra data. + let mut long_name = good.clone(); + long_name[at + 8] = 0xff; + for f in [short_mask, long_name] { + let (s, r) = open(&f, json!({})); + assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(Vec::len), Some(1), "{r}"); + } +} + +#[test] +fn damaged_layers_never_panic() { + let mut masked = layer("Masked", [0, 0, 3, 3], [1, 2, 3, 255]); + masked.mask = Some(([1, 1, 2, 2], 0, 0, vec![200])); + let layers = [ + layer("A", [0, 0, 3, 3], [255, 0, 0, 255]), + group_end(), + masked, + L { clipped: true, ..layer("C", [0, 0, 3, 3], [0, 0, 255, 255]) }, + L { blend: b"lbrn", ..layer("D", [0, 0, 2, 2], [9, 9, 9, 255]) }, + group("G", b"pass"), + ]; + for (rle, psb) in [(false, false), (true, true)] { + let good = file(3, 3, &layers, 8, rle, psb); + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + assert!(psdlayers::import(&good, true, 1 << 30, cms).unwrap().is_some()); + for i in 0..good.len() { + for v in [0, 1, 0x7f, 0x80, 0xff] { + let mut f = good.clone(); + f[i] = v; + let _ = psdlayers::import(&f, true, 1 << 30, cms); + } + } + for n in 0..good.len() { + let _ = psdlayers::import(&good[..n], true, 1 << 30, cms); + } + // A layer past the memory allowed is refused, not read. + assert!(psdlayers::import(&good, true, 64, cms).unwrap_err().contains("too large")); + } + // Many layers that each fit the memory allowed for one image, but not all together (8 bytes a + // pixel to read an 8 × 8 layer; 4 a pixel decoded, against 8 times the allowance). + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let many: Vec = (0..20).map(|_| layer("x", [0, 0, 8, 8], [1, 2, 3, 255])).collect(); + assert!(psdlayers::import(&file(8, 8, &many[..16], 8, false, false), false, 512, cms).unwrap().is_some()); + let e = psdlayers::import(&file(8, 8, &many, 8, false, false), false, 512, cms).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("take more memory to read than allowed")), "{e:?}"); +} + +/// A layer in a mode VectorCraft lacks above a pass-through group merges with the group's layers +/// blending onto what is below the group, as they do without the merge. +#[test] +fn merges_keep_pass_through_groups_blending_with_what_is_below() { + let layers = [ + layer("Bottom", [0, 0, 2, 2], [100, 200, 50, 255]), + group_end(), + L { blend: b"mul ", ..layer("Multiply", [0, 0, 2, 2], [128, 128, 128, 255]) }, + group("Pass", b"pass"), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [0, 0, 0, 255]) }, + ]; + let (s, r) = open(&file(2, 2, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Pass + Dodge"], "{r}"); + // 100, 200 and 50 times 128/255. + assert_eq!(image_of(&s, &d.layers[0]).1.get_pixel(1, 1).0, [50, 100, 25, 255]); + // At half opacity, or with a layer mask, the group's layers still blend with what is below + // it, and the result fades back toward it: halfway from (100, 200, 50) to (50, 100, 25). + let mut masked = group("Pass", b"pass"); + masked.mask = Some(([0, 0, 2, 2], 0, 0, vec![128, 128, 128, 128])); + for top in [L { opacity: 128, ..group("Pass", b"pass") }, masked] { + let mut layers = layers.clone(); + layers[3] = top; + let (s, r) = open(&file(2, 2, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Pass + Dodge"], "{r}"); + let px = image_of(&s, &d.layers[0]).1.get_pixel(1, 1).0; + assert!(px.iter().zip([75, 150, 38, 255]).all(|(&a, b)| a.abs_diff(b) <= 1), "{px:?}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index d411496b02..08a46f0e10 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -141,6 +141,31 @@ fn packbits_zip_and_psb_files_read() { zip.extend(z.finish().unwrap()); let px = pixels(&psd(1, 1, (3, 1), 16, 1, &[], &[], &[], &zip)); assert_eq!(px.iter().map(|p| p[0]).collect::>(), [16, 32, 48]); + // Zipped with prediction, 32 bits: the row's first bytes, then its second bytes and so on, + // each byte stored as the difference from the byte before it. + let samples = [0.0f32, 1.0, 0.0].map(f32::to_be_bytes); + let planes: Vec = (0..4).flat_map(|k| samples.iter().map(move |s| s[k])).collect(); + let deltas: Vec = planes.iter().scan(0u8, |prev, &b| Some(b.wrapping_sub(std::mem::replace(prev, b)))).collect(); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, &deltas).unwrap(); + let mut zip = vec![0, 3]; + zip.extend(z.finish().unwrap()); + let px = pixels(&psd(1, 1, (3, 1), 32, 1, &[], &[], &[], &zip)); + assert_eq!(px.iter().map(|p| p[0]).collect::>(), [0, 255, 0]); + // The same layout as another implementation writes it: psd-tools 1.26.1 (MIT, + // ), `compression.compress(data, + // Compression.ZIP_WITH_PREDICTION, 3, 2, 32)` of the samples 0, 1, 0.25 and 0.5, 0.125, 1, + // reads as the same samples stored raw. + let theirs = [ + 0x78, 0x9c, 0x63, 0xb0, 0xff, 0x7f, 0xa8, 0x81, 0xa1, 0x81, 0x01, 0x04, 0xec, 0xff, 0x33, 0x1e, 0x64, 0x68, 0x80, 0xb0, 0x01, 0x59, 0xd1, + 0x06, 0x01, + ]; + let mut zip = vec![0, 3]; + zip.extend(theirs); + let samples: Vec = [0.0f32, 1.0, 0.25, 0.5, 0.125, 1.0].iter().flat_map(|v| v.to_be_bytes()).collect(); + let stored = pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &raw(&[&samples]))); + assert_eq!(pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &zip)), stored); + assert_eq!(stored.iter().map(|p| p[0]).collect::>()[..2], [0, 255]); } #[test] diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index f950f8c1da..02b4097684 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1,7 +1,10 @@ //! Object → Flatten Transparency: transparent art becomes opaque art that looks the same. //! -//! The targets (`ids` or the selection) split into groups of overlapping objects. Groups without -//! transparency stay as they are (their type and strokes are outlined on request). The others are +//! The targets (`ids` or the selection) split into groups: each object with transparency with the +//! objects it overlaps, and the objects without transparency whose stacking order with a member +//! would change when the group takes its topmost object's place. Objects without transparency +//! that overlap only others without it stay as they are (their type and strokes are outlined on +//! request), as do groups without transparency. The others are //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index eacd124052..cf7bdb7baa 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -99,17 +99,77 @@ pub(super) fn masked(n: &Node) -> bool { n.extras().mask.as_ref().is_some_and(|m| !m.disabled) } -/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). -pub(super) fn reach(n: &Node) -> Option { +/// Where `n` paints: its visual bounds grown by its effects. +pub(super) fn painted(n: &Node) -> Option { if !n.visible { return None; } let b = match n.children() { - Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, + Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| painted(c)).reduce(|a, b| a.union(b))?, _ => n.visual_bounds()?, }; let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max); - Some(b.inflate(fx + 1.0, fx + 1.0)) + Some(b.inflate(fx, fx)) +} + +/// Where `n` can paint: where it paints ([`painted`]) and a margin of 1 pt for antialiasing, +/// once, however deep its groups nest. +pub(super) fn reach(n: &Node) -> Option { + painted(n).map(|b| b.inflate(1.0, 1.0)) +} + +type Cancelled = vectorcraft_doc::cancel::Cancelled; + +/// Whether `b` has finite coordinates and isn't inside out. +pub(super) fn finite(b: &Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1 +} + +/// Whether boxes `a` and `b` overlap or touch. +fn touch(a: &Rect, b: &Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Indices joined into sets, each set knowing whether it holds an object with transparency. +struct Sets { + parent: Vec, + seeded: Vec, +} + +impl Sets { + fn find(&mut self, mut i: usize) -> usize { + while self.parent[i] != i { + self.parent[i] = self.parent[self.parent[i]]; + i = self.parent[i]; + } + i + } + + /// Joins the sets of `a` and `b`: → the set they are now, named by its lowest index. + fn join(&mut self, a: usize, b: usize) -> usize { + let (a, b) = (self.find(a), self.find(b)); + let (lo, hi) = (a.min(b), a.max(b)); + self.parent[hi] = lo; + self.seeded[lo] |= self.seeded[hi]; + lo + } + + /// The sets, each in index order, by their first index. + fn groups(&mut self) -> Vec> { + let mut groups: Vec> = vec![]; + let mut slot: Vec> = vec![None; self.parent.len()]; + for i in 0..self.parent.len() { + let r = self.find(i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups + } } /// Indices of `bounds` grouped by overlap (transitively), each group in index order. Boxes that @@ -121,16 +181,15 @@ pub(super) fn reach(n: &Node) -> Option { /// horizontal extent, would otherwise compare every pair). An open box inside a later box of /// its group, ending no further right, is closed early: whatever overlaps it overlaps that box. /// Fails only when cancelled ([`vectorcraft_doc::cancel`]). -pub(super) fn overlapping(bounds: &[Option]) -> Result>, vectorcraft_doc::cancel::Cancelled> { - fn find(p: &mut [usize], mut i: usize) -> usize { - while p[i] != i { - p[i] = p[p[i]]; - i = p[i]; - } - i - } - let mut parent: Vec = (0..bounds.len()).collect(); - let finite = |b: &Rect| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1; +pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { + Ok(sweep(bounds, None)?.groups()) +} + +/// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is +/// see-through, and an open box is closed early only inside a later see-through box. +fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { + let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() }; + let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]); let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect(); let n = boxes.len(); // Each box's place in the tree: by top (then index). @@ -190,29 +249,140 @@ pub(super) fn overlapping(bounds: &[Option]) -> Result>, ve stack.push((2 * node + 1, mid, hi)); stack.push((2 * node, lo, mid)); } + // Whatever overlaps a box inside this one overlaps this one, and joins it when this one is + // see-through. + let shadows = see_through.is_none_or(|s| s[boxes[k].0]); for &o in &met { - let (a, c) = (find(&mut parent, boxes[k].0), find(&mut parent, boxes[o].0)); - parent[a.max(c)] = a.min(c); + if joins(boxes[k].0, boxes[o].0) { + sets.join(boxes[k].0, boxes[o].0); + } let ob = boxes[o].1; - if ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { + if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { set(&mut low, leaf[o], f64::NEG_INFINITY); } } set(&mut low, leaf[k], b.y1); } - let mut groups: Vec> = vec![]; - let mut slot: Vec> = vec![None; bounds.len()]; - for i in 0..bounds.len() { - let r = find(&mut parent, i); - match slot[r] { - Some(g) => groups[g].push(i), - None => { - slot[r] = Some(groups.len()); - groups.push(vec![i]); + Ok(sets) +} + +/// Boxes in blocks of nearby ones, each block with the box around it, to find the boxes a box +/// touches. +struct Blocks { + order: Vec, + boxes: Vec>, + around: Vec, +} + +impl Blocks { + const SIZE: usize = 32; + + /// The finite boxes of `boxes`, in vertical strips by their centers and in blocks down each + /// strip. + fn new(boxes: &[Option]) -> Self { + let boxes: Vec> = boxes.iter().map(|b| b.filter(finite)).collect(); + let mut order: Vec = (0..boxes.len()).filter(|&i| boxes[i].is_some()).collect(); + let center = |i: usize| boxes[i].map_or_else(Default::default, |b| b.center()); + order.sort_by(|&a, &b| center(a).x.total_cmp(¢er(b).x)); + let strip = ((order.len() as f64 / Self::SIZE as f64).sqrt().ceil() as usize).max(1) * Self::SIZE; + for s in order.chunks_mut(strip) { + s.sort_by(|&a, &b| center(a).y.total_cmp(¢er(b).y)); + } + let around = order.chunks(Self::SIZE).filter_map(|c| c.iter().filter_map(|&i| boxes[i]).reduce(|a, b| a.union(b))).collect(); + Self { order, boxes, around } + } + + /// Puts the indices of the boxes that touch `q` in `out`. `work` counts the boxes looked at; + /// fails only when cancelled. + fn touching(&self, q: &Rect, out: &mut Vec, work: &mut usize) -> Result<(), Cancelled> { + out.clear(); + for (b, around) in self.around.iter().enumerate() { + *work += 1; + if work.is_multiple_of(4096) { + vectorcraft_doc::cancel::check()?; + } + if !touch(around, q) { + continue; + } + for &i in &self.order[b * Self::SIZE..((b + 1) * Self::SIZE).min(self.order.len())] { + *work += 1; + if self.boxes[i].is_some_and(|r| touch(&r, q)) { + out.push(i); + } } } + Ok(()) } - Ok(groups) +} + +/// Objects (in paint order) grouped for flattening, each group in paint order and the groups by +/// their first object; a group takes the place of its topmost object. `reach` is where each +/// object can paint ([`reach`]), `painted` where it paints ([`painted`]), and `see_through` whether +/// it has transparency. An object whose box is missing or not finite is a group of its own. +/// +/// An object joins every see-through object its reach touches. An object without transparency +/// that touches only others without transparency stays a group of its own, unless a group would +/// change its stacking order with it: it joins (and groups join each other) where its reach +/// touches a member's and moving the members to their group's top would put the two the other way +/// round. With a `fringe` (how far a group's image can reach past +/// its art and cover what is under it), an object or group under a group's top whose painted box +/// that far around a member's touches joins it too. Fails only when cancelled +/// ([`vectorcraft_doc::cancel`]). +pub(super) fn flatten_groups( + reach: &[Option], + painted: &[Option], + see_through: &[bool], + fringe: f64, +) -> Result>, Cancelled> { + let n = reach.len(); + let mut sets = sweep(reach, Some(see_through))?; + let mut members: Vec> = vec![vec![]; n]; + for i in 0..n { + let r = sets.find(i); + members[r].push(i); + } + // The topmost object of each set (by its name). + let mut top: Vec = (0..n).map(|r| members[r].last().copied().unwrap_or(r)).collect(); + let index = Blocks::new(reach); + // Each member of a set with transparency is looked at. + let mut work: Vec = vec![]; + for i in 0..n { + let r = sets.find(i); + if sets.seeded[r] { + work.push(i); + } + } + let (mut near, mut looked) = (vec![], 0usize); + while let Some(i) = work.pop() { + let Some(q) = reach[i].filter(finite) else { continue }; + // Painted boxes within the fringe have reaches within it too. + index.touching(&q.inflate(fringe, fringe), &mut near, &mut looked)?; + for &k in &near { + let (ri, rk) = (sets.find(i), sets.find(k)); + if ri == rk { + continue; + } + let (ti, tk) = (top[ri], top[rk]); + let crossed = reach[k].is_some_and(|b| touch(&q, &b)) && (i < k) != (ti < tk); + let covered = fringe > 0.0 && tk < ti && painted[i].zip(painted[k]).is_some_and(|(a, b)| touch(&a.inflate(fringe, fringe), &b)); + if !crossed && !covered { + continue; + } + // The members of a set whose top moves are looked at again, from there. + let t = ti.max(tk); + if ti != t { + work.extend_from_slice(&members[ri]); + } + if tk != t { + work.extend_from_slice(&members[rk]); + } + let r = sets.join(ri, rk); + let moved = std::mem::take(&mut members[if r == ri { rk } else { ri }]); + members[r].extend(moved); + top[r] = t; + } + } + Ok(sets.groups()) } #[cfg(test)] @@ -292,4 +462,153 @@ mod tests { let odd = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(f64::NAN, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 20.0, 20.0))]; assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]); } + + /// A filled rectangle, unstroked. + fn filled(id: u64, r: Rect) -> Node { + let look = vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), + vectorcraft_color::Paint::None, + 0.0, + ); + Node::path(vectorcraft_doc::NodeId(id), vectorcraft_geom::shapes::rectangle(r), look) + } + + /// The antialiasing margin goes around the outermost group once, not once per group. + #[test] + fn reach_adds_its_margin_once_however_deep_the_groups() { + let r = Rect::new(10.0, 20.0, 30.0, 50.0); + let inner = Node::group(vectorcraft_doc::NodeId(2), vec![Arc::new(filled(1, r))]); + let outer = Node::group(vectorcraft_doc::NodeId(3), vec![Arc::new(inner)]); + assert_eq!(painted(&outer), Some(r)); + assert_eq!(reach(&outer), Some(r.inflate(1.0, 1.0))); + assert_eq!(reach(&filled(1, r)), reach(&outer)); + } + + /// The group of each object, the topmost object of each group and whether it is see-through. + fn sets_of(groups: &[Vec], n: usize, see_through: &[bool]) -> (Vec, Vec, Vec) { + let mut of = vec![usize::MAX; n]; + for (g, ids) in groups.iter().enumerate() { + for &i in ids { + of[i] = g; + } + } + let top = groups.iter().map(|g| *g.last().unwrap()).collect(); + let seeded = groups.iter().map(|g| g.iter().any(|&i| see_through[i])).collect(); + (of, top, seeded) + } + + /// What [`flatten_groups`] must give: every object in one group, in paint order, the groups by + /// their first object; see-through objects with all that their reach touches; groups without + /// transparency of one object; no two objects that paint over each other the other way round + /// once each group takes its top's place; nothing within `fringe` of a member of a group under + /// that group's top. + fn check(reach: &[Option], painted: &[Option], see: &[bool], fringe: f64, groups: &[Vec], what: &str) { + let n = reach.len(); + let mut all: Vec = groups.iter().flatten().copied().collect(); + all.sort_unstable(); + assert_eq!(all, (0..n).collect::>(), "{what}: every object once"); + assert!(groups.iter().all(|g| g.is_sorted()) && groups.windows(2).all(|w| w[0][0] < w[1][0]), "{what}: in order"); + let (of, top, seeded) = sets_of(groups, n, see); + for (g, ids) in groups.iter().enumerate() { + assert!(seeded[g] || ids.len() == 1, "{what}: {ids:?} has no transparency"); + } + for i in 0..n { + for j in 0..n { + if i == j || of[i] == of[j] { + continue; + } + let (gi, gj) = (of[i], of[j]); + if let (Some(a), Some(b)) = (reach[i], reach[j]) { + assert!(!(touch(&a, &b) && (see[i] || see[j])), "{what}: {i} and {j} touch, one see-through"); + if seeded[gi] && touch(&a, &b) { + assert_eq!(i < j, top[gi] < top[gj], "{what}: {i} and {j} change places"); + } + } + let (Some(a), Some(b)) = (painted[i], painted[j]) else { continue }; + if seeded[gi] && fringe > 0.0 && touch(&a.inflate(fringe, fringe), &b) { + assert!(top[gj] > top[gi], "{what}: {j} under the image of {i}'s group"); + } + } + } + } + + /// Boxes of many sizes, some see-through, group as they must, with and without a fringe. + #[test] + fn flatten_groups_keep_stacking_order_and_leave_opaque_art_alone() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..60 { + let n = 4 + round; + let painted: Vec> = (0..n) + .map(|i| { + if i % 13 == 5 { + return None; + } + let (x, y) = (next(120.0).round(), next(120.0).round()); + Some(Rect::new(x, y, x + next(25.0).round(), y + next(25.0).round())) + }) + .collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see: Vec = (0..n).map(|_| next(1.0) < 0.15).collect(); + for fringe in [0.0, 0.7] { + let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap(); + check(&reach, &painted, &see, fringe, &groups, &format!("round {round}, fringe {fringe}")); + } + } + } + + /// A row of opaque boxes whose reaches touch, with see-through ones at its ends and far from + /// it: only the boxes next to a see-through one join it. + #[test] + fn an_opaque_row_stays_apart_from_the_see_through_boxes_at_its_ends() { + let n = 200; + // 10 pt boxes 1.5 pt apart: their 1 pt reaches overlap. + let painted: Vec> = (0..n).map(|i| Some(Rect::new(11.5 * i as f64, 0.0, 11.5 * i as f64 + 10.0, 10.0))).collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let mut see = vec![false; n]; + see[0] = true; + see[n - 1] = true; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + check(&reach, &painted, &see, 0.0, &groups, "row"); + assert_eq!(groups[0], vec![0, 1]); + assert_eq!(groups.last(), Some(&vec![n - 2, n - 1])); + assert_eq!(groups.len(), n - 2); + // Without transparency every box is a group of its own. + assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n); + } + + /// An opaque box between a member and its group's top that paints over the member joins, and + /// so on along a chain; one under the member stays, but for one within the fringe. + #[test] + fn boxes_the_group_would_cross_join_it() { + // In paint order: D under A, A, B over A, C over B, then the see-through T over A. + let d = Rect::new(40.0, 80.0, 65.0, 120.0); + let a = Rect::new(60.0, 50.0, 105.0, 90.0); + let b = Rect::new(20.0, 20.0, 65.0, 55.0); + let c = Rect::new(0.0, 0.0, 25.0, 25.0); + let t = Rect::new(100.0, 50.0, 140.0, 90.0); + let painted = [Some(d), Some(a), Some(b), Some(c), Some(t)]; + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see = [false, false, false, false, true]; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + assert_eq!(groups, vec![vec![0], vec![1, 2, 3, 4]]); + // C away from B: it stays. + let far = [Some(d), Some(a), Some(b), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_far: Vec> = far.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_far, &far, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // B 0.5 pt above A, not overlapping it: their antialiased edges can share pixels, so B joins. + let gap = [Some(d), Some(a), Some(Rect::new(20.0, 20.0, 65.0, 49.5)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_gap: Vec> = gap.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_gap, &gap, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // D a little left of A, under it: the fringe takes it in. + let near = [Some(Rect::new(40.0, 80.0, 59.8, 120.0)), Some(a), Some(b), Some(c), Some(t)]; + let reach_near: Vec> = near.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.0).unwrap()[0], vec![0]); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.24).unwrap(), vec![vec![0, 1, 2, 3, 4]]); + } } diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c1366dba3c..ef8425b362 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,4 +1,4 @@ -use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach}; +use super::analysis::{Scratch, finite, flatten_groups, has_effects, masked, outlined_text, overlapping, painted, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ @@ -115,7 +115,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). vectorcraft_doc::cancel::check()?; - let mut queue = overlapping(&reaches)?; + let mut queue = groups(src, roots, &plain, &reaches, &see_through, o)?; queue.reverse(); while let Some(g) = queue.pop() { vectorcraft_doc::cancel::check()?; @@ -196,6 +196,46 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: Ok(out) } +/// `roots` of `src` (`plain`: as plain art; `reaches`: where each can paint, clipped) in the +/// groups that flatten together ([`flatten_groups`]), each in paint order, by their first object. +/// Objects that overlap (as [`overlapping`] groups them) stay in one group when one of them is +/// inside a clip group or clipping layer: [`inside_and_under`] and [`place_of`] then decide where +/// they go. +fn groups( + src: &Document, + roots: &[NodeId], + plain: &[Node], + reaches: &[Option], + see_through: &[bool], + o: &FlattenOptions, +) -> Result>> { + // An image clipped to its regions, opaque over white, reaches a pixel past them; a group's + // image is no larger than the art it overlaps, so its pixels are no wider than this. + let opaque_edge = o.no_transparency.is_some() && o.clip_complex_regions && !o.preserve_alpha; + let ppi = o.line_art_ppi.min(o.gradient_ppi).max(1.0); + let extent = |ids: &mut dyn Iterator| ids.filter_map(|i| reaches[i].filter(finite)).reduce(|a, b| a.union(b)).unwrap_or_default(); + // Where the widest such pixel is wider than the margins of two reaches, objects that close + // overlap too. + let widest = if opaque_edge { 1.0 / raster_scale(extent(&mut (0..reaches.len())), ppi) } else { 0.0 }; + let grow = ((widest - 2.0) / 2.0).max(0.0); + let near: Vec> = reaches.iter().map(|b| b.map(|b| b.inflate(grow, grow))).collect(); + let mut out = vec![]; + for g in overlapping(if grow > 0.0 { &near } else { reaches })? { + let clipped = || clips_count() && g.iter().any(|&i| !clip_ancestors(src, roots[i]).is_empty()); + if g.len() < 2 || !g.iter().any(|&i| see_through[i]) || clipped() { + out.push(g); + continue; + } + let fringe = if opaque_edge { 1.0 / raster_scale(extent(&mut g.iter().copied()), ppi) } else { 0.0 }; + let local = |v: &[Option]| g.iter().map(|&i| v[i]).collect::>(); + let painted: Vec> = g.iter().map(|&i| painted(&plain[i])).collect(); + let see: Vec = g.iter().map(|&i| see_through[i]).collect(); + out.extend(flatten_groups(&local(reaches), &painted, &see, fringe)?.into_iter().map(|l| l.into_iter().map(|k| g[k]).collect::>())); + } + out.sort_by_key(|g| g.first().copied()); + Ok(out) +} + /// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and /// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping /// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area), @@ -482,11 +522,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool /// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. /// Fails when the image can't be encoded. fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { - let mut scale = ppi / 72.0; - scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); - // No side longer than the renderer draws: the image grows by a pixel on each side and is - // rounded up to whole pixels below. - scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)); + let scale = raster_scale(rect, ppi); let px = 1.0 / scale; let rect = rect.inflate(px, px); let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); @@ -559,6 +595,14 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip }) } +/// Pixels per point of an image of `rect` at `ppi`: lower when the image would be larger than +/// [`MAX_PIXELS`], or have a side longer than the renderer draws (the image grows by a pixel on +/// each side and is rounded up to whole pixels). +fn raster_scale(rect: Rect, ppi: f64) -> f64 { + let scale = (ppi / 72.0).min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)) +} + /// `n` at full opacity with Normal blending throughout (objects, fills and strokes). pub(super) fn opaque(n: &mut Node) { n.opacity = 1.0; @@ -877,4 +921,42 @@ mod tests { assert_eq!(flat.parent_of(g), Some(layer)); assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); } + + /// In a file without transparency with Clip Complex Regions on, art under a group's opaque + /// image within one of its pixels joins the group, also where a long image is made at a lower + /// resolution and its pixels are wider than at the preset's. + #[test] + fn art_under_the_edge_of_a_long_opaque_image_joins_its_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 31000, "height": 100})).unwrap(); + let rect = |s: &mut Session, x: f64, w: f64| { + let id = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap(); + NodeId(id) + }; + // 0.5 pt left of A: wider than a pixel at 300 ppi, narrower than one of a 30,000 pt image. + let d = rect(&mut s, 40.0, 19.5); + let a = rect(&mut s, 60.0, 40.0); + let t = rect(&mut s, 95.0, 30_000.0); + s.execute("transparency.set", &json!({"ids": [t.0], "opacity": 50})).unwrap(); + let o = FlattenOptions { no_transparency: Some(Default::default()), clip_complex_regions: true, ..FlattenOptions::default() }; + let doc = &s.doc().unwrap().doc; + let long = plan(doc, &[d, a, t], &o, false).unwrap(); + assert!(long.kept.is_empty(), "{:?}", long.kept); + assert_eq!(long.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![d, a, t]]); + // Short, the image has 300 ppi pixels: D stays. + let short = rect(&mut s, 95.0, 40.0); + s.execute("transparency.set", &json!({"ids": [short.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(plan(doc, &[d, a, short], &o, false).unwrap().kept, vec![d]); + // 100,000 pt long, its pixels are over 3 pt wide: art 3 pt away, whose reach doesn't touch + // A's, joins too. + let far = rect(&mut s, 40.0, 17.0); + let longer = rect(&mut s, 95.0, 100_000.0); + s.execute("transparency.set", &json!({"ids": [longer.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + let p = plan(doc, &[far, a, longer], &o, false).unwrap(); + assert!(p.kept.is_empty(), "{:?}", p.kept); + assert_eq!(p.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![far, a, longer]]); + } } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index c7926ddf84..9003e325a9 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right), aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial: the extent ellipse's height / width; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", has_doc, set_gradient_geom ), @@ -118,7 +118,7 @@ type Parsed = std::result::Result; /// Parse a kind name. fn parse_kind(k: &str) -> Parsed { - GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)")) + GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform)")) } /// The `method` param of `p`, if given: how the colours between the stops mix. @@ -274,9 +274,16 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { gp.angle = f64_or(g, "angle", 0.0); gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; + let aspect = geom.aspect.map(|a| aspect_for(kind, a)).transpose()?; + if matches!(kind, GradientKind::Conic | GradientKind::Diamond) { + no_focal(&geom)?; + } if let Some((start, end)) = geom.vector { - let mut g = GradientGeom { start, end, aspect: geom.aspect.unwrap_or(1.0), focal: None }; - g.set_focal(geom.focal.flatten()); + let mut g = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0), focal: None }; + // Only a radial gradient has a focal point (as `paint.setGradientGeom` keeps it). + if kind == GradientKind::Radial { + g.set_focal(geom.focal.flatten()); + } gp.angle = g.angle_deg(); gp.geom = Some(g); } @@ -286,13 +293,36 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { Ok(gp) } -/// The `aspect` param (a percentage) as a ratio. +/// The `aspect` param (a percentage) as a ratio of 0.005 to 327.67, its sign kept: a negative one +/// mirrors a conic gradient's sweep ([`aspect_for`]). fn aspect_param(p: &Value) -> Parsed> { p.get("aspect") - .map(|v| v.as_f64().map(|a| (a / 100.0).clamp(0.005, 327.67)).ok_or_else(|| "`aspect` must be a number (%)".to_string())) + .map(|v| { + v.as_f64() + .filter(|a| a.is_finite()) + .map(|a| (a.abs() / 100.0).clamp(0.005, 327.67).copysign(a)) + .ok_or_else(|| "`aspect` must be a number (%)".to_string()) + }) .transpose() } +/// Aspect ratio `a` ([`aspect_param`]) for a gradient of `kind`: a negative one mirrors a conic's +/// sweep, and is an error for the other kinds. +fn aspect_for(kind: GradientKind, a: f64) -> Parsed { + if a < 0.0 && kind != GradientKind::Conic { + return Err("a negative `aspect` applies to conic gradients".into()); + } + Ok(a) +} + +/// An error when `e` gives a focal point: a conic or diamond gradient has none (`null` is allowed). +fn no_focal(e: &GeomEdit) -> Parsed<()> { + match e.focal { + Some(Some(_)) => Err("`focal` applies to radial gradients".into()), + _ => Ok(()), + } +} + /// `paint` without the placement it had on the art it came from (a swatch, or the default paint /// for new art): a placed gradient keeps its angle and fits each object it lands on (freeform /// points are placed afresh, coloured along the stops, which follow the points' colours). @@ -400,7 +430,7 @@ fn apply_gradient_edit_in_model( if let Some(g) = &mut gp.geom { let r = a.to_radians(); let dir = vectorcraft_geom::Vec2::new(r.cos(), -r.sin()); - if gp.gradient.kind == GradientKind::Radial { + if gp.gradient.kind.is_centered() { g.end = g.start + dir * g.length(); } else { let c = g.start.midpoint(g.end); @@ -411,6 +441,7 @@ fn apply_gradient_edit_in_model( } } if let Some(asp) = aspect_param(p)? { + let asp = aspect_for(gp.gradient.kind, asp)?; if gp.geom.is_none() && let Some(b) = bounds { @@ -472,7 +503,20 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?; // Validate against the defaults first so bad params fail without touching the document. let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; - let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(p, &default_paint, &stored), None, &|_| true, model) + // A negative aspect for selected conic gradients gives a default of another kind its size. + let default_kind = str_param(p, "kind").and_then(GradientKind::parse).or(match &default_paint { + Paint::Gradient(g) => Some(g.gradient.kind), + _ => None, + }); + let for_default = match p.get("aspect").and_then(Value::as_f64) { + Some(a) if a < 0.0 && !ids.is_empty() && default_kind != Some(GradientKind::Conic) => { + let mut q = p.clone(); + q["aspect"] = json!(-a); + std::borrow::Cow::Owned(q) + } + _ => std::borrow::Cow::Borrowed(p), + }; + let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| { @@ -599,11 +643,23 @@ impl GeomEdit { /// `cur` with the vector, aspect and focal point of `e` (given in the document). `to_doc` maps /// the paint's space (text space for type runs) to the document and `bounds` (in that space) fits -/// an unplaced gradient, so what the document shows carries over where `e` keeps it. -fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option, model: Option) -> Option { - let from_doc = invert(to_doc)?; +/// an unplaced gradient, so what the document shows carries over where `e` keeps it. An error +/// when `e` gives what the gradient's kind can't take (a negative aspect other than a conic's, or +/// a conic's or diamond's focal point). +fn vector_paint( + cur: &Paint, + e: &GeomEdit, + to_doc: Affine, + bounds: Option, + model: Option, +) -> Parsed> { let mut gp = gradient_for_edit(cur, model); let kind = gp.gradient.kind; + let edit_aspect = e.aspect.map(|a| aspect_for(kind, a)).transpose()?; + if matches!(kind, GradientKind::Conic | GradientKind::Diamond) { + no_focal(e)?; + } + let Some(from_doc) = invert(to_doc) else { return Ok(None) }; let map = |mut g: GradientGeom, a: Affine| { if a != Affine::IDENTITY { g.transform(a, kind); @@ -611,8 +667,8 @@ fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option, g }; let shown = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map(|g| map(g, to_doc)); - let (start, end) = e.vector.or_else(|| shown.map(|g| (g.start, g.end)))?; - let aspect = e.aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0); + let Some((start, end)) = e.vector.or_else(|| shown.map(|g| (g.start, g.end))) else { return Ok(None) }; + let aspect = edit_aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0); let mut geom = GradientGeom { start, end, aspect, focal: None }; match (e.focal, shown) { _ if kind != GradientKind::Radial => {} @@ -623,7 +679,7 @@ fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option, let geom = map(geom, from_doc); gp.angle = geom.angle_deg(); gp.geom = Some(geom); - Some(Paint::Gradient(Box::new(gp))) + Ok(Some(Paint::Gradient(Box::new(gp)))) } fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { @@ -658,14 +714,15 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { run_stroke_weight(&mut r.style); } let (paint, b) = run_paint_mut(r, stroke, lb); - if let Some(np) = vector_paint(paint, &edit, xf, Some(b), model) { + if let Some(np) = vector_paint(paint, &edit, xf, Some(b), model).map_err(|e| bad(C, e))? { *paint = np; } } return Ok(()); } let b = item_paint_bounds(n, index, !stroke); - if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), &edit, Affine::IDENTITY, b, model) { + let cur = n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None); + if let Some(np) = vector_paint(cur, &edit, Affine::IDENTITY, b, model).map_err(|e| bad(C, e))? { n.appearance.set_paint_at(index, !stroke, np); } Ok(()) diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index f4b6c90c13..2c42494160 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -17,7 +17,9 @@ use vectorcraft_doc::{ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; use super::typecmd::refresh_bounds; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, named_id, ok, str_param}; +use super::{ + CommandSpec, EngineError, Result, Session, bad, cmd, f64_req, has_doc, has_selection, named_id, number_or, ok, str_param, switch_or, text_param, +}; mod axis; mod columns; diff --git a/crates/engine/src/cmd/graph/axis.rs b/crates/engine/src/cmd/graph/axis.rs index 47858d3951..b805efb432 100644 --- a/crates/engine/src/cmd/graph/axis.rs +++ b/crates/engine/src/cmd/graph/axis.rs @@ -1,4 +1,4 @@ -use super::{GraphSpec, Value}; +use super::{GraphSpec, Result, Value, bad}; pub(super) const LABEL_SIZE: f64 = 9.0; @@ -22,6 +22,16 @@ pub(super) fn count_param(p: &Value, key: &str, max: u64) -> Option { usize::try_from(n.min(max)).ok() } +/// [`count_param`], or `None` when `p` leaves `key` out or gives null. A negative number is an error, and so is any +/// other value, such as `"5"`. +pub(super) fn count_given(p: &Value, key: &str, max: u64, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) if !v.is_number() => Err(bad(cmd, format!("`{key}` must be a number, not {v}"))), + Some(_) => count_param(p, key, max).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be 0 or more"))), + } +} + /// A "nice" axis: (min, max, step). pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) { let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo.min(0.0), lo.max(0.0) + 1.0) } else { (lo, hi) }; diff --git a/crates/engine/src/cmd/graph/commands.rs b/crates/engine/src/cmd/graph/commands.rs index 5df961ee38..7084fe19f6 100644 --- a/crates/engine/src/cmd/graph/commands.rs +++ b/crates/engine/src/cmd/graph/commands.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "Create Graph", [], None, - "{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label", + "{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label; a `type` that is not a string, a `width` or `height` that is not a number, and data of the wrong type (as `graph.setData` reads it) are refused", has_doc, create ), @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Data…", ["Object", "Graph"], None, - "{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty); then one row per category: label; values…; an empty cell or a null is a blank value; a quoted number a label; a `|` in a label a line break, transpose?: true swaps rows and columns (Transpose row/column), switchXY?: true swaps each scatter series' y and x columns (Switch x/y)} replace the graph's data; no data → the current {csv, series, categories, rows}", + "{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty); then one row per category: label; values…; an empty cell or a null is a blank value; a quoted number a label; a `|` in a label a line break, transpose?: true swaps rows and columns (Transpose row/column), switchXY?: true swaps each scatter series' y and x columns (Switch x/y)} replace the graph's data; a `csv` that is not a string, `series` or `categories` that are not lists, `rows` that are not lists of numbers and nulls, or a `transpose` or `switchXY` that is not true or false is refused and the graph stays as it was; no data → the current {csv, series, categories, rows}", has_selection, set_data ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Type…", ["Object", "Graph"], None, - "{id?, type?, seriesIndexes?: [index], columnWidth?: % (1–1000; past 100 columns overlap), clusterWidth?: % (1–1000), legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?, valueAxis?: left|right|both (the bar graphs' value axis stays along the bottom), separateScales?: bool, rightTicks?: n, rightAxisMin?, rightAxisMax?, tickLength?: none|short|full, tickMarks?: n per division, rightTickLength?, rightTickMarks?, categoryTickLength?: none|short|full, categoryTickMarks?: n, ticksBetweenLabels?: bool, prefix?, suffix?, rightPrefix?, rightSuffix?: text around the value axis numbers, firstRowInFront?: bool, firstColumnInFront?: bool (which overlapping columns are drawn on top: the first category over the next within each series, the first series over the others), filledLines?: bool (Draw Filled Lines: connecting lines as 3 pt bands filled with the series colour), dropShadow?: bool (Add Drop Shadow: a translucent black shadow down and right of the columns, bars, lines and pie wedges), legendAcrossTop?: bool (Add Legend Across Top: the legend in rows above the plot), pieLegend?: none|standard|wedges (Legends in Wedges: the series labels inside their wedges), piePosition?: ratio|even|stacked (several pies sized by their totals, at one size (the default), or stacked), pieSort?: all|first|none (wedges largest first in each pie, in the first pie's order, or in data order (the default)); the query lists the pie options for pie graphs} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only and `valueAxis` (left|right; both is refused) puts them on that value axis, while the other options still apply to the whole graph (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0; with the value axis on both sides, separateScales gives the series on the right axis a scale of their own, set the same way with rightTicks, rightAxisMin and rightAxisMax); no options → the current ones", + "{id?, type?, seriesIndexes?: [index], columnWidth?: % (1–1000; past 100 columns overlap), clusterWidth?: % (1–1000), legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?: number|null (null = calculated), axisMax?: number|null, valueAxis?: left|right|both (the bar graphs' value axis stays along the bottom), separateScales?: bool, rightTicks?: n, rightAxisMin?, rightAxisMax?, tickLength?: none|short|full, tickMarks?: n per division, rightTickLength?, rightTickMarks?, categoryTickLength?: none|short|full, categoryTickMarks?: n, ticksBetweenLabels?: bool, prefix?, suffix?, rightPrefix?, rightSuffix?: text around the value axis numbers, firstRowInFront?: bool, firstColumnInFront?: bool (which overlapping columns are drawn on top: the first category over the next within each series, the first series over the others), filledLines?: bool (Draw Filled Lines: connecting lines as 3 pt bands filled with the series colour), dropShadow?: bool (Add Drop Shadow: a translucent black shadow down and right of the columns, bars, lines and pie wedges), legendAcrossTop?: bool (Add Legend Across Top: the legend in rows above the plot), pieLegend?: none|standard|wedges (Legends in Wedges: the series labels inside their wedges), piePosition?: ratio|even|stacked (several pies sized by their totals, at one size (the default), or stacked), pieSort?: all|first|none (wedges largest first in each pie, in the first pie's order, or in data order (the default)); the query lists the pie options for pie graphs} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only and `valueAxis` (left|right; both is refused) puts them on that value axis, while the other options still apply to the whole graph (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0; with the value axis on both sides, separateScales gives the series on the right axis a scale of their own, set the same way with rightTicks, rightAxisMin and rightAxisMax); a value of the wrong type, such as \"10\" for a number or \"true\" for a bool, or a negative count, is refused and the graph stays as it was; a null is read as a key left out, except that a null axis bound goes back to the calculated value; no options → the current ones", has_selection, set_type ), diff --git a/crates/engine/src/cmd/graph/data.rs b/crates/engine/src/cmd/graph/data.rs index f4eb3ef83c..f7f672325e 100644 --- a/crates/engine/src/cmd/graph/data.rs +++ b/crates/engine/src/cmd/graph/data.rs @@ -1,6 +1,6 @@ use super::edit::{regenerate, spec_of, target}; use super::series::{switch_xy, transpose}; -use super::{Document, EngineError, GraphKind, GraphSpec, NodeId, Result, Session, Value, bad, bool_or, json, str_param}; +use super::{Document, EngineError, GraphKind, GraphSpec, NodeId, Result, Session, Value, bad, json, switch_or, text_param}; // ---------- data ---------- @@ -101,7 +101,7 @@ pub(crate) fn apply_csv(d: &mut Document, id: NodeId, csv: &str) -> Result<()> { return Err(bad("graph.applyCsv", format!("node {} is not a graph", id.0))); }; let mut spec = current.clone(); - apply_data(&mut spec, &json!({ "csv": csv })); + apply_data(&mut spec, &json!({ "csv": csv }), "graph.applyCsv")?; keep_right_series(&mut spec); if spec == *current { return Ok(()); @@ -115,34 +115,58 @@ fn keep_right_series(g: &mut GraphSpec) { g.right_series.retain(|i| *i < count); } -pub(super) fn apply_data(g: &mut GraphSpec, p: &Value) -> bool { +/// Apply the data `p` gives (`csv`, `series`, `categories`, `rows`) to `g`; true when it gave any. A null is read as a +/// key left out. A `csv` that is not a string, a `series` or `categories` that is not a list, and `rows` that are not +/// lists of numbers and nulls are errors, and `g` is left as it was. +pub(super) fn apply_data(g: &mut GraphSpec, p: &Value, cmd: &str) -> Result { + let list = |key: &str| -> Result>> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(a)) => Ok(Some(a)), + Some(v) => Err(bad(cmd, format!("`{key}` must be a list, not {v}"))), + } + }; + let csv = text_param(p, "csv", cmd)?; + let strings = |a: &Vec| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::>(); + let series = list("series")?.map(strings); + let categories = list("categories")?.map(strings); + // A null is a blank cell. + let rows = list("rows")? + .map(|rows| { + rows.iter() + .map(|r| { + let bad_row = || bad(cmd, format!("`rows` must be lists of numbers or nulls, not {r}")); + r.as_array() + .ok_or_else(bad_row)? + .iter() + .map(|v| if v.is_null() { Ok(None) } else { v.as_f64().map(|v| Some(v).filter(|v| v.is_finite())).ok_or_else(bad_row) }) + .collect::>>() + }) + .collect::>>() + }) + .transpose()?; let mut changed = false; - if let Some(csv) = str_param(p, "csv") { + if let Some(csv) = csv { let (s, c, r) = parse_csv(csv); g.series = s; g.categories = c; g.set_cells(r); changed = true; } - let strings = - |v: &Value| v.as_array().map(|a| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::>()); - if let Some(s) = p.get("series").and_then(strings) { + if let Some(s) = series { g.series = s; changed = true; } - if let Some(c) = p.get("categories").and_then(strings) { + if let Some(c) = categories { g.categories = c; changed = true; } - if let Some(rows) = p.get("rows").and_then(Value::as_array) { - // `null` (or anything that isn't a finite number) is a blank cell. - g.set_cells( - rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(), - ); + if let Some(rows) = rows { + g.set_cells(rows); changed = true; } drop_empty_rows(g); - changed + Ok(changed) } /// Drop rows with neither a value nor a label; a label-only row stays, its values blank. Each kept row keeps its own @@ -184,13 +208,13 @@ pub(super) fn set_data(s: &mut Session, p: &Value) -> Result { const C: &str = "graph.setData"; let id = target(s, p, C)?; let mut spec = spec_of(s, id)?; - let flip = bool_or(p, "transpose", false); - let swap = bool_or(p, "switchXY", false); + let flip = switch_or(p, "transpose", false, C)?; + let swap = switch_or(p, "switchXY", false, C)?; if swap && spec.kind != GraphKind::Scatter { return Err(bad(C, "Switch x/y is for scatter graphs")); } // Transpose and Switch x/y act after any new data in the same call. - let changed = apply_data(&mut spec, p); + let changed = apply_data(&mut spec, p, C)?; if flip { // The categories become series: at most as many as a graph has series. if spec.cells().len() > vectorcraft_doc::MAX_GRAPH_SERIES { diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index c33be78d5a..c7f6d52ea8 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -1,12 +1,22 @@ -use super::axis::{MAX_TICK_MARKS, count_param, one_line}; +use super::axis::{MAX_TICK_MARKS, count_given, one_line}; use super::data::apply_data; use super::generate::{children_bounds, generate}; use super::series::{picked_series, reselect_series}; use super::{ - Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, bool_or, f64_or, - f64_req, json, named_id, str_param, + Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, f64_req, json, + named_id, number_or, switch_or, text_param, }; +/// An axis bound `p` gives for `key`: `None` when left out, `Some(None)` for null (calculated from the data), or the +/// number. Any other value, such as `"10"`, is an error. +fn bound_param(p: &Value, key: &str, cmd: &str) -> Result>> { + match p.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(v) => v.as_f64().map(|x| Some(Some(x))).ok_or_else(|| bad(cmd, format!("`{key}` must be a number or null, not {v}"))), + } +} + /// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last /// generation, which is recorded in the spec's `placed` bounds). pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { @@ -33,12 +43,14 @@ pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> R pub(super) fn create(s: &mut Session, p: &Value) -> Result { const C: &str = "graph.create"; - let kind = - str_param(p, "type").map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))).transpose()?.unwrap_or_default(); + let kind = text_param(p, "type", C)? + .map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))) + .transpose()? + .unwrap_or_default(); let (x, y) = (f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let (w, h) = (f64_or(p, "width", 200.0).abs().max(1.0), f64_or(p, "height", 150.0).abs().max(1.0)); + let (w, h) = (number_or(p, "width", 200.0, C)?.abs().max(1.0), number_or(p, "height", 150.0, C)?.abs().max(1.0)); let mut spec = GraphSpec { kind, rect: Rect::new(x, y, x + w, y + h), ..GraphSpec::default() }; - if !apply_data(&mut spec, p) { + if !apply_data(&mut spec, p, C)? { // Starter data so the new graph shows something (edit it with Object → Graph → Data…). spec.series = vec!["Series 1".into(), "Series 2".into()]; spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; @@ -150,7 +162,8 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { spec.right_series.retain(|i| *i < count); spec.right_series.sort_unstable(); spec.right_series.dedup(); - if p.get("seriesIndexes").is_some() && str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() { + let (new_type, new_axis) = (text_param(p, "type", C)?, text_param(p, "valueAxis", C)?); + if p.get("seriesIndexes").is_some() && new_type.is_none() && new_axis.is_none() { return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`")); } let picked = picked_series(s, p, id, count, C)?; @@ -196,7 +209,7 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { } // Series picked with Group Selection stay selected through the regeneration (their groups are new nodes). let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; - if let Some(t) = str_param(p, "type") { + if let Some(t) = new_type { let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?; if picked.is_empty() { spec.kind = kind; @@ -215,7 +228,7 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { } } } - if let Some(v) = str_param(p, "valueAxis") { + if let Some(v) = new_axis { let side = ValueAxisSide::parse(v).ok_or_else(|| bad(C, format!("unknown value axis `{v}` (left, right or both)")))?; if picked.is_empty() { spec.value_axis = side; @@ -231,54 +244,58 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { } } } - spec.separate_scales = bool_or(p, "separateScales", spec.separate_scales); + spec.separate_scales = switch_or(p, "separateScales", spec.separate_scales, C)?; for (key, slot) in [ ("tickLength", &mut spec.tick_length), ("rightTickLength", &mut spec.right_tick_length), ("categoryTickLength", &mut spec.category_tick_length), ] { - if let Some(v) = str_param(p, key) { + if let Some(v) = text_param(p, key, C)? { *slot = TickLength::parse(v).ok_or_else(|| bad(C, format!("unknown {key} `{v}` (none, short or full)")))?; } } for (key, slot) in [("tickMarks", &mut spec.tick_marks), ("rightTickMarks", &mut spec.right_tick_marks), ("categoryTickMarks", &mut spec.category_tick_marks)] { - *slot = count_param(p, key, MAX_TICK_MARKS as u64).map_or(*slot, |n| n.max(1)); + *slot = count_given(p, key, MAX_TICK_MARKS as u64, C)?.map_or(*slot, |n| n.max(1)); } - spec.ticks_between_labels = bool_or(p, "ticksBetweenLabels", spec.ticks_between_labels); - if let Some(v) = str_param(p, "piePosition") { + spec.ticks_between_labels = switch_or(p, "ticksBetweenLabels", spec.ticks_between_labels, C)?; + if let Some(v) = text_param(p, "piePosition", C)? { spec.pie_position = vectorcraft_doc::PiePosition::parse(v).ok_or_else(|| bad(C, format!("unknown piePosition `{v}` (even, ratio or stacked)")))?; } - if let Some(v) = str_param(p, "pieSort") { + if let Some(v) = text_param(p, "pieSort", C)? { spec.pie_sort = vectorcraft_doc::PieSort::parse(v).ok_or_else(|| bad(C, format!("unknown pieSort `{v}` (none, all or first)")))?; } for (key, slot) in [("prefix", &mut spec.prefix), ("suffix", &mut spec.suffix), ("rightPrefix", &mut spec.right_prefix), ("rightSuffix", &mut spec.right_suffix)] { - if let Some(v) = str_param(p, key) { + if let Some(v) = text_param(p, key, C)? { // A label, not a paragraph: one line, at most MAX_AFFIX characters. *slot = one_line(v); } } - spec.right_ticks = count_param(p, "rightTicks", 100).unwrap_or(spec.right_ticks); - if let Some(v) = p.get("rightAxisMin") { - spec.right_axis_min = v.as_f64(); + spec.right_ticks = count_given(p, "rightTicks", 100, C)?.unwrap_or(spec.right_ticks); + for (key, slot) in [ + ("rightAxisMin", &mut spec.right_axis_min), + ("rightAxisMax", &mut spec.right_axis_max), + ("axisMin", &mut spec.axis_min), + ("axisMax", &mut spec.axis_max), + ] { + if let Some(v) = bound_param(p, key, C)? { + *slot = v; + } } - if let Some(v) = p.get("rightAxisMax") { - spec.right_axis_max = v.as_f64(); - } - spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0); - spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 1000.0); - spec.legend = bool_or(p, "legend", spec.legend); - spec.drop_shadow = bool_or(p, "dropShadow", spec.drop_shadow); - spec.first_row_in_front = bool_or(p, "firstRowInFront", spec.first_row_in_front); - spec.first_column_in_front = bool_or(p, "firstColumnInFront", spec.first_column_in_front); - spec.filled_lines = bool_or(p, "filledLines", spec.filled_lines); - spec.legend_across_top = bool_or(p, "legendAcrossTop", spec.legend_across_top); + spec.column_width = number_or(p, "columnWidth", spec.column_width, C)?.clamp(1.0, 1000.0); + spec.cluster_width = number_or(p, "clusterWidth", spec.cluster_width, C)?.clamp(1.0, 1000.0); + spec.legend = switch_or(p, "legend", spec.legend, C)?; + spec.drop_shadow = switch_or(p, "dropShadow", spec.drop_shadow, C)?; + spec.first_row_in_front = switch_or(p, "firstRowInFront", spec.first_row_in_front, C)?; + spec.first_column_in_front = switch_or(p, "firstColumnInFront", spec.first_column_in_front, C)?; + spec.filled_lines = switch_or(p, "filledLines", spec.filled_lines, C)?; + spec.legend_across_top = switch_or(p, "legendAcrossTop", spec.legend_across_top, C)?; // After `legend`, which the Graph Type dialog sends too. - if let Some(v) = str_param(p, "pieLegend") { + if let Some(v) = text_param(p, "pieLegend", C)? { (spec.legend, spec.pie_legend_in_wedges) = match v.to_ascii_lowercase().as_str() { "none" => (false, false), "standard" => (true, false), @@ -286,16 +303,10 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, format!("unknown pieLegend `{v}` (none, standard or wedges)"))), }; } - spec.mark_points = bool_or(p, "markPoints", spec.mark_points); - spec.connect_points = bool_or(p, "connectPoints", spec.connect_points); - spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge); - spec.ticks = count_param(p, "ticks", 100).unwrap_or(spec.ticks); - if let Some(v) = p.get("axisMin") { - spec.axis_min = v.as_f64(); - } - if let Some(v) = p.get("axisMax") { - spec.axis_max = v.as_f64(); - } + spec.mark_points = switch_or(p, "markPoints", spec.mark_points, C)?; + spec.connect_points = switch_or(p, "connectPoints", spec.connect_points, C)?; + spec.edge_to_edge = switch_or(p, "edgeToEdge", spec.edge_to_edge, C)?; + spec.ticks = count_given(p, "ticks", 100, C)?.unwrap_or(spec.ticks); let label = format!("{} Graph", spec.kind.label()); s.edit("Graph Type", |d, sel| { regenerate(d, id, spec)?; diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index efcd043208..aef1918a6d 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -2638,9 +2638,12 @@ fn counts_come_back_from_the_dialog_as_floats_and_are_capped() { s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap(); let g = spec(&s, id); assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3)); - s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap(); + s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300})).unwrap(); let g = spec(&s, id); assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0)); + // A negative count is refused. + s.execute("graph.setType", &json!({"rightTicks": -2.0})).unwrap_err(); + assert_eq!(spec(&s, id).right_ticks, 0); // A file's values come back as drawn. let mut fields = s.execute("graph.setType", &json!({})).unwrap(); assert_eq!(fields["rightTickMarks"], 20); @@ -4498,3 +4501,93 @@ fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it let (first, next) = (bounds("2024"), bounds("20\n25")); assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}"); } + +/// A graph command given a value of the wrong type refuses it, naming the key, and leaves the graph and the undo +/// history as they were; a null axis bound still goes back to the calculated value. +#[test] +fn graph_commands_refuse_params_of_the_wrong_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 10})).unwrap(); + let spec = |s: &Session| s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().cloned().unwrap(); + let undo_len = |s: &Session| s.doc().unwrap().history.undo.len(); + let (before, steps) = (spec(&s), undo_len(&s)); + let refused = |s: &mut Session, cmd: &str, p: serde_json::Value, msg: &str| { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{cmd} {p}: {e}"); + }; + for (key, v, msg) in [ + ("axisMin", json!("10"), "`axisMin` must be a number or null, not \"10\""), + ("axisMax", json!(true), "`axisMax` must be a number or null"), + ("rightAxisMin", json!([1]), "`rightAxisMin` must be a number or null"), + ("rightAxisMax", json!({}), "`rightAxisMax` must be a number or null"), + ("columnWidth", json!("50"), "`columnWidth` must be a number, not \"50\""), + ("clusterWidth", json!(false), "`clusterWidth` must be a number"), + ("ticks", json!("5"), "`ticks` must be a number, not \"5\""), + ("ticks", json!(-2), "`ticks` must be 0 or more"), + ("rightTicks", json!("5"), "`rightTicks` must be a number"), + ("tickMarks", json!("2"), "`tickMarks` must be a number"), + ("rightTickMarks", json!(-1), "`rightTickMarks` must be 0 or more"), + ("categoryTickMarks", json!(true), "`categoryTickMarks` must be a number"), + ("legend", json!("false"), "`legend` must be true or false"), + ("markPoints", json!(0), "`markPoints` must be true or false"), + ("connectPoints", json!("no"), "`connectPoints` must be true or false"), + ("edgeToEdge", json!(1), "`edgeToEdge` must be true or false"), + ("separateScales", json!("true"), "`separateScales` must be true or false"), + ("ticksBetweenLabels", json!(1), "`ticksBetweenLabels` must be true or false"), + ("firstRowInFront", json!("yes"), "`firstRowInFront` must be true or false"), + ("firstColumnInFront", json!(0), "`firstColumnInFront` must be true or false"), + ("filledLines", json!("on"), "`filledLines` must be true or false"), + ("dropShadow", json!(1), "`dropShadow` must be true or false"), + ("legendAcrossTop", json!("x"), "`legendAcrossTop` must be true or false"), + ("type", json!(5), "`type` must be a string"), + ("valueAxis", json!(true), "`valueAxis` must be a string"), + ("tickLength", json!(1), "`tickLength` must be a string"), + ("rightTickLength", json!(1), "`rightTickLength` must be a string"), + ("categoryTickLength", json!(1), "`categoryTickLength` must be a string"), + ("prefix", json!(1), "`prefix` must be a string"), + ("suffix", json!(true), "`suffix` must be a string"), + ("rightPrefix", json!([]), "`rightPrefix` must be a string"), + ("rightSuffix", json!(2), "`rightSuffix` must be a string"), + ("pieLegend", json!(1), "`pieLegend` must be a string"), + ("piePosition", json!(1), "`piePosition` must be a string"), + ("pieSort", json!(1), "`pieSort` must be a string"), + ] { + refused(&mut s, "graph.setType", json!({"id": id.0, key: v}), msg); + } + for (key, v, msg) in [ + ("csv", json!(5), "`csv` must be a string"), + ("series", json!("a"), "`series` must be a list, not \"a\""), + ("categories", json!({}), "`categories` must be a list"), + ("rows", json!("1,2"), "`rows` must be a list"), + ("rows", json!([1, 2]), "`rows` must be lists of numbers or nulls, not 1"), + ("rows", json!([[1, "2"]]), "`rows` must be lists of numbers or nulls"), + ("transpose", json!("true"), "`transpose` must be true or false"), + ("switchXY", json!(1), "`switchXY` must be true or false"), + ] { + refused(&mut s, "graph.setData", json!({"id": id.0, key: v}), msg); + } + assert_eq!(spec(&s), before); + assert_eq!(undo_len(&s), steps); + let count = |s: &Session| s.doc().unwrap().doc.node_count(); + let objects = count(&s); + for (key, v, msg) in [ + ("type", json!(1), "`type` must be a string"), + ("width", json!("300"), "`width` must be a number, not \"300\""), + ("height", json!(true), "`height` must be a number"), + ("csv", json!(1), "`csv` must be a string"), + ("rows", json!([[true]]), "`rows` must be lists of numbers or nulls"), + ] { + let mut p = json!({"type": "column", "x": 0, "y": 0, "width": 100, "height": 100}); + p[key] = v; + refused(&mut s, "graph.create", p, msg); + } + assert_eq!((count(&s), undo_len(&s)), (objects, steps)); + // A null is a key left out, except for an axis bound, where it goes back to the calculated value. + s.execute("graph.setType", &json!({"id": id.0, "legend": null, "ticks": null, "prefix": null, "axisMin": null})).unwrap(); + assert_eq!(spec(&s).axis_min, None); + assert_eq!(spec(&s).axis_max, Some(10.0)); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, null], [2, 3]], "series": null, "transpose": null})).unwrap(); + assert_eq!(spec(&s).cells(), vec![vec![Some(1.0), None], vec![Some(2.0), Some(3.0)]]); +} diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index 0c1d36d35d..b54e92932c 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::Node; use vectorcraft_render::effects::addons::halftone::{DotShape, Halftone, art_outline, dot_nodes}; use super::edit::selected_roots; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, color_value, f64_or, has_selection, str_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, color_value, has_selection, number_or, switch_or, text_param}; pub fn specs() -> Vec { vec![cmd!( @@ -27,28 +27,28 @@ pub fn specs() -> Vec { fn halftone(s: &mut Session, p: &Value) -> Result { const C: &str = "object.vectorHalftone"; - let shape = match str_param(p, "shape") { + let shape = match text_param(p, "shape", C)? { Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?, None => DotShape::Circle, }; - let frequency = f64_or(p, "frequency", 20.0); + let frequency = number_or(p, "frequency", 20.0, C)?; if !(1.0..=300.0).contains(&frequency) { return Err(bad(C, "frequency must be between 1 and 300 lines per inch")); } - let angle = f64_or(p, "angle", 45.0); + let angle = number_or(p, "angle", 45.0, C)?; if !angle.is_finite() { return Err(bad(C, "invalid angle")); } - let mono = match str_param(p, "mode").unwrap_or("mono") { + let mono = match text_param(p, "mode", C)?.unwrap_or("mono") { "mono" => true, "cmyk" => false, other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))), }; let color = match p.get("color") { + None | Some(Value::Null) => Color::BLACK, Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?, - None => Color::BLACK, }; - let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false)); + let (invert, clip, keep) = (switch_or(p, "invert", false, C)?, switch_or(p, "clip", true, C)?, switch_or(p, "keepOriginal", false, C)?); let ht = Halftone { shape, frequency, angle, mono, color, from_art: false, invert, clip }; let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 020b483f3a..a134eb1c55 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}", + "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec { "Link Info", [], None, - "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size", + "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size; under automation read roots, a link whose file is outside the roots has no link, path or location, and fileSize and modified come only from the file", has_doc, info ), @@ -882,17 +882,35 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio } /// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in -/// the layers), with `refused`: why the path isn't followed, when it isn't. +/// the layers), with `refused`: why the path isn't followed, when it isn't. `path` is left out +/// where [`path_shown`] says so. fn rows<'a>(gs: impl Iterator, doc_path: Option<&str>) -> Vec { gs.filter(|g| !g.ids.is_empty()) .map(|g| { - let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) }); + let mut row = json!({ "name": g.link.name(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); note_refused(&mut row, &g.link, doc_path); row }) .collect() } +/// May a reply to the caller asking now carry the link path `path` (of the document saved at +/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a +/// relative path taken from the document's folder) lies inside them. +fn path_shown(path: &str, doc_path: Option<&str>) -> bool { + crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) +} + +/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. +fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { + if path_shown(path, doc_path) { + row[key] = json!(path); + } else if let Some(o) = row.as_object_mut() { + o.remove(key); + } +} + /// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]). fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) { if let Some(r) = refused(link, doc_path) { @@ -1188,10 +1206,12 @@ fn check(s: &mut Session, p: &Value) -> Result { let (status, found) = probe(g, LinkBase::of(st)); missing += usize::from(status == Status::Missing); modified += usize::from(status == Status::Modified); - let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) }); - note_refused(&mut row, &g.link, LinkBase::of(st).doc); + let doc_path = LinkBase::of(st).doc; + let mut row = json!({ "name": g.link.name(), "status": status.as_str(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); + note_refused(&mut row, &g.link, doc_path); if let Some(f) = found.filter(|f| f.path != g.link.path) { - row["found"] = json!(f.path); + note_path(&mut row, "found", &f.path, doc_path); } if stale(&st.doc, g) { row["preview"] = json!(true); @@ -1663,14 +1683,15 @@ fn list(s: &mut Session, p: &Value) -> Result { o.insert("linked".into(), json!(link.is_some())); o.insert("status".into(), json!(status)); } + let doc_path = LinkBase::of(st).doc; if let Some(l) = link { - row["path"] = json!(l.path); + note_path(&mut row, "path", &l.path, doc_path); if let Some(page) = l.page { row["page"] = json!(page); } } if let Some(f) = found { - row["found"] = json!(f); + note_path(&mut row, "found", f, doc_path); } if preview { row["preview"] = json!(true); @@ -1811,13 +1832,17 @@ fn placement_json(o: PlacementOptions) -> Value { /// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { + // Whether the path is shown is judged for the caller asking, before the opener's roots apply. + let shown = path_shown(&l.path, LinkBase::of(st).doc); // A confined caller reads within its own roots; anyone else within what the caller that // opened the document may read (never wider than the caller asking now). let roots = crate::file_access::current().or_else(|| st.opened_by.clone()); - crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out)); + crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, shown, out)); } -fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { +/// [`file_fields`]; without `shown`, the link's path (`path`, `link`) and folder (`location`) +/// and the size and date the document keeps of its file are left out. +fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, shown: bool, out: &mut Value) { let base = LinkBase::of(st); let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0); let doc_path = base.doc; @@ -1825,13 +1850,20 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va let stamp = path.as_deref().and_then(file_stamp); out["status"] = json!(status.as_str()); note_refused(out, l, doc_path); - out["path"] = json!(l.path); + if shown { + out["path"] = json!(l.path); + out["location"] = json!(l.folder()); + } else if let Some(o) = out.as_object_mut() { + for k in ["path", "link", "location"] { + o.remove(k); + } + } out["fileName"] = json!(l.name()); - out["location"] = json!(l.folder()); + let kept = |v: Option| v.filter(|_| shown); for (k, v) in [ ("page", l.page.map(u64::from)), - ("fileSize", stamp.map(|s| s.0).or(l.size)), - ("modified", stamp.and_then(|s| s.1).or(l.modified)), + ("fileSize", stamp.map(|s| s.0).or(kept(l.size))), + ("modified", stamp.and_then(|s| s.1).or(kept(l.modified))), ("created", path.as_deref().and_then(file_created)), ] { if let Some(v) = v { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 8ea016db65..b3992ac2a9 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -871,7 +871,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { Ok(out) } -fn has_image(s: &Session) -> std::result::Result<(), String> { +pub(crate) fn has_image(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 20dc187ab5..bd15d748ca 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; use vectorcraft_geom::{Point, Rect}; -use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param}; +use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "New Document Presets", [], None, - "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode}]}]}; file.new {preset: name} starts from one", + "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode, alignNewObjectsToPixelGrid (on in the Mobile and Web presets)}]}]}; file.new {preset: name} starts from one", always, list ), @@ -144,6 +144,10 @@ pub struct DocSettings { #[serde(deserialize_with = "de_ppi")] pub raster_effects_ppi: f64, pub preview_mode: PreviewMode, + /// Align New Objects to Pixel Grid ([`vectorcraft_doc::DocSetup::align_new_objects_to_pixel_grid`]): + /// on in the Mobile and Web presets, off in the others and in a saved or Recent preset from + /// before it. + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSettings { @@ -163,6 +167,7 @@ impl Default for DocSettings { color_mode: ColorMode::Rgb, raster_effects_ppi: 72.0, preview_mode: PreviewMode::Default, + align_new_objects_to_pixel_grid: false, } } } @@ -211,6 +216,7 @@ impl DocSettings { "colorMode": color_mode_id(self.color_mode), "rasterEffectsPpi": self.raster_effects_ppi, "previewMode": self.preview_mode.id(), + "alignNewObjectsToPixelGrid": self.align_new_objects_to_pixel_grid, }) } @@ -273,6 +279,7 @@ impl DocSettings { if let Some(m) = p.get("previewMode").filter(|v| !v.is_null()) { self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?; } + self.align_new_objects_to_pixel_grid = switch_or(p, "alignNewObjectsToPixelGrid", self.align_new_objects_to_pixel_grid, cmd)?; let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r)); if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD { return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size")); @@ -330,6 +337,7 @@ impl DocSettings { d.raster_effects_ppi = self.raster_effects_ppi; d.setup.bleed = self.bleed; d.setup.background = self.background; + d.setup.align_new_objects_to_pixel_grid = self.align_new_objects_to_pixel_grid; d.artboards = self .boards() .into_iter() @@ -543,6 +551,10 @@ pub const CATEGORIES: &[Category] = &[ ), ]; +/// The built-in categories whose presets start with Align New Objects to Pixel Grid on +/// (). +const PIXEL_ALIGNED_CATEGORIES: [&str; 2] = ["Mobile", "Web"]; + /// The category tabs in order: Recent, Saved, then the built-in categories. pub fn category_names() -> impl Iterator { ["Recent", "Saved"].into_iter().chain(CATEGORIES.iter().map(|c| c.0)) @@ -551,7 +563,8 @@ pub fn category_names() -> impl Iterator { /// The built-in presets of one category: screen sizes in pixels, the others in `general` /// (Preferences ▸ Units ▸ General). fn builtin(c: &Category, general: Unit) -> impl Iterator + '_ { - let (_, color_mode, ppi, presets) = *c; + let (category, color_mode, ppi, presets) = *c; + let align = PIXEL_ALIGNED_CATEGORIES.contains(&category); presets.iter().map(move |&(name, w, h, units)| DocSettings { name: name.into(), width: units.to_pt(w), @@ -559,6 +572,7 @@ fn builtin(c: &Category, general: Unit) -> impl Iterator + ' units: if units == Unit::Pixels { units } else { general }, color_mode, raster_effects_ppi: ppi, + align_new_objects_to_pixel_grid: align, ..DocSettings::default() }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c467f06e5a..5eee2d3fce 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -36,6 +36,9 @@ pub(crate) use arrange::group_nodes; pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; -pub(crate) use pixelgrid::{keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned}; +pub(crate) use pixelgrid::{ + align_new_object, can_align, keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, + snap_if_aligned, +}; pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/pixelgrid.rs b/crates/engine/src/cmd/object/pixelgrid.rs index 6441fda0c3..64d47ca0a9 100644 --- a/crates/engine/src/cmd/object/pixelgrid.rs +++ b/crates/engine/src/cmd/object/pixelgrid.rs @@ -22,6 +22,16 @@ pub(crate) fn can_align(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) } +/// For an object being drawn into `d`: when the document's Align New Objects to Pixel Grid is on +/// and [`can_align`] accepts `n`, turn `n`'s pixel-aligned property on. The edit that adds it snaps +/// it to the pixel grid ([`refresh`]). Pasted, duplicated, placed and imported objects do not come +/// through here. +pub(crate) fn align_new_object(d: &Document, n: &mut Node) { + if d.setup.align_new_objects_to_pixel_grid && can_align(n) { + n.extras_mut().pixel_aligned = true; + } +} + /// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected /// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be /// one [`can_align`] accepts, else nothing changes. When every object already has the value, the diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 1e8d9ced62..4cc1fa24c3 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::FillRule; use super::appearance::{ItemTarget, item_target}; use super::edit::selected_roots; -use super::{CommandSpec, Result, Session, bad, bool_or, cmd, f64_or, has_doc, ids_param, targets}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, ids_param, number_or, switch_or, targets}; pub fn specs() -> Vec { vec![ @@ -221,15 +221,16 @@ fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) -> } pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colors.overprintBlack"; let op = OverprintBlack { - remove: bool_or(p, "remove", false), - min_k: (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32, - fill: bool_or(p, "fill", true), - stroke: bool_or(p, "stroke", true), - rich: bool_or(p, "includeCmyBlacks", false), - spot: bool_or(p, "includeSpotBlacks", false), + remove: switch_or(p, "remove", false, C)?, + min_k: (number_or(p, "percentage", 100.0, C)? / 100.0).clamp(0.0, 1.0) as f32, + fill: switch_or(p, "fill", true, C)?, + stroke: switch_or(p, "stroke", true, C)?, + rich: switch_or(p, "includeCmyBlacks", false, C)?, + spot: switch_or(p, "includeSpotBlacks", false, C)?, }; - let ids = match ids_param(p, "ids", "edit.colors.overprintBlack")? { + let ids = match ids_param(p, "ids", C)? { Some(v) => v, None => selected_roots(s)?, }; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 673ad2cbb8..ee6d7e3c89 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -14,7 +14,7 @@ use serde_json::{Value, json}; use super::fileio::{create_dir, write_file}; use super::links::LinkBase; -use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name}; const C: &str = "file.package"; @@ -451,19 +451,20 @@ impl Session { /// name, the report's Document Info text and the document. fn prepare(s: &mut Session, p: &Value) -> Result { let o = Options { - copy_links: bool_or(p, "copyLinks", true), - links_folder: bool_or(p, "linksFolder", true), - relink: bool_or(p, "relink", true), - copy_fonts: bool_or(p, "copyFonts", true), - report: bool_or(p, "report", true), + copy_links: switch_or(p, "copyLinks", true, C)?, + links_folder: switch_or(p, "linksFolder", true, C)?, + relink: switch_or(p, "relink", true, C)?, + copy_fonts: switch_or(p, "copyFonts", true, C)?, + report: switch_or(p, "report", true, C)?, }; + let given_name = text_param(p, "name", C)?; + let folder = text_param(p, "folder", C)?; let doc_path = s.doc()?.path.clone().ok_or_else(|| bad(C, "save the document first: Package collects a saved document"))?; let stem = Path::new(&doc_path).file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned()); - let name = str_param(p, "name").map_or_else(|| format!("{stem} Folder"), str::to_string); + let name = given_name.map_or_else(|| format!("{stem} Folder"), str::to_string); if name.trim().is_empty() || name == "." || name == ".." || name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) { return Err(bad(C, format!("`{name}` can't name a folder"))); } - let folder = str_param(p, "folder"); let root = folder.map(|f| Path::new(f).join(&name)); let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None }; let doc = s.doc()?.doc.clone(); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b68378ba9d..b199f9a356 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -641,7 +641,7 @@ fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { } } -/// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets +/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial, conic or diamond gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result { @@ -656,7 +656,7 @@ fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result return Err("span: \"selection\" places a `gradient`, not a swatch".into()); } if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() { - return Err("span: \"selection\" places a linear or radial gradient, not a freeform one".into()); + return Err("span: \"selection\" places a linear, radial, conic or diamond gradient, not a freeform one".into()); } if g.geom.is_some() { return Err( diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 712ca8a179..91da35e51b 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Close Path", [], None, - "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor), independent?: bool}", + "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}", has_doc, close ), @@ -202,14 +202,18 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { ok() } -/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor. +/// Close the active open subpath of `id`, optionally setting the handles of the first anchor. The +/// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only +/// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape. fn close(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); - let h_in = match p.get("in").filter(|h| !h.is_null()) { - None => None, - Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?), + let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) { + None => Ok(None), + Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))), }; + let h_in = handle("in")?; + let h_out = handle("out")?; let independent = switch_or(p, "independent", false, "path.close")?; s.edit("Close Path", |d, sel| { let path = path_mut(d, id)?; @@ -218,11 +222,13 @@ fn close(s: &mut Session, p: &Value) -> Result { None => path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?, }; sp.closed = true; - if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) { - first.h_in = h; - if !independent { - first.h_out = first.p - (h - first.p); - first.kind = AnchorKind::Smooth; + if let Some(first) = sp.anchors.first_mut() { + if let Some(h) = h_in { + first.set_handle(false, h, independent); + } + if let Some(h) = h_out { + first.h_out = h; + first.kind = AnchorKind::Corner; } } let top = compound_or_self(d, id); diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 1ded346d86..a6e7f67286 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -5,7 +5,7 @@ //! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with //! `link` ([`super::links`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options -//! (`dxf`, [`fileio::dxfimport`]). +//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`). //! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by //! its art (`crop: "bounding"`). //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by @@ -47,7 +47,10 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; use super::fileio::{self, Format, RasterImage}; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, object_param, str_param, unique_name}; +use super::{ + CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, finite_numbers, has_doc, object_param, str_param, + switch_or, unique_name, +}; use crate::{EngineError, MAX_COORD}; const PLACE: &str = "file.place"; @@ -66,7 +69,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -261,7 +264,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result Result { } fn prepare(s: &Session, p: &Value) -> Result { - let replace = bool_or(p, "replace", false); - let template = bool_or(p, "template", false); + let replace = switch_or(p, "replace", false, PLACE)?; + let template = switch_or(p, "template", false, PLACE)?; + check_switches(p, &["link"], PLACE)?; let at = at_param(p)?; let rect = rect_param(p)?; if replace && (template || at.is_some() || rect.is_some()) { @@ -778,6 +782,8 @@ impl Session { /// desktop the Place dialog's OK with several files does this off the UI thread). pub fn prepare_place_queue(&self, params: &Value) -> Result { has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?; + check_switches(params, &["link", "template"], QUEUE)?; + check_numbers(params, &["thumbnail"], QUEUE)?; Ok(QueueJob { params: params.clone(), files: queued_files(params)? }) } @@ -846,6 +852,8 @@ fn info(_: &mut Session, p: &Value) -> Result { /// `file.place.info` with `p`, without the session (on the desktop the Place dialog reads its /// files' lines this way off the UI thread). pub fn read_info(p: &Value) -> Result { + check_switches(p, &["link"], "file.place.info")?; + check_numbers(p, &["thumbnail"], "file.place.info")?; // A PDF/.ai page linked or not has the same size and art. let mut l = load(p, "file.place.info", None, false)?; let natural = l.natural; diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index ab5562b5f9..25f5f36dbd 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -108,7 +108,7 @@ pub(in crate::cmd) fn windows_1252(b: u8) -> char { } /// Byte `b` of Mac Roman. -pub(super) fn mac_roman(b: u8) -> char { +pub(in crate::cmd) fn mac_roman(b: u8) -> char { const HIGH: [char; 128] = [ 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', // 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', // diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 110672da21..2d16bbc683 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -52,7 +52,7 @@ use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; -use vectorcraft_plugins::permissions::{self, Grant, Permission}; +use vectorcraft_plugins::permissions::{self, Grant, GrantToken, Permission}; /// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { @@ -741,17 +741,18 @@ fn install(s: &mut Session, p: &Value) -> Result { } // Installed plug-ins stay installed after a restart (#2541). The module is written first and its // grant only once it is there; when the grant can't be written, the store gets the module it - // held back. A failed write of that module is only logged, and the grant then doesn't cover - // the new module. A stored module that can't be read back isn't replaced by one that needs a - // grant. + // held back. A failed write of that module is only logged: the store then holds the new module, + // which the grant doesn't cover. A write that reports a failure may still have put the old + // module back, so the store is read again to tell which module it holds. A stored module that + // can't be read back isn't replaced by one that needs a grant. let previous = s.stored_plugin_bytes(plugin.id()); let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); + let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; - registry::grant(g.clone()); + registry::grant(GrantToken::after_engine_approval(g.clone())); if stored && !s.store_grant(&m.id, Some(g)) { - s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); - stored = false; + stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s); } } else { // This module takes the id over, so nothing the user approved for the id applies any more. @@ -766,7 +767,7 @@ fn install(s: &mut Session, p: &Value) -> Result { let writes = s.grant_writes(); if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); - stored = !back; + stored = !back && holds_new(s); if !back || matches!(previous, Ok(None)) { s.ignore_stored_grant(&m.id, writes); } @@ -788,7 +789,8 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - let mut v = describe_installed(®istry::install(plugin)); + let installed = registry::install(plugin).map_err(err)?; + let mut v = describe_installed(&installed); if !unknown.is_empty() { v["unknownCommands"] = json!(unknown); } @@ -887,7 +889,7 @@ fn load_folder(s: &Session, folder: &str) -> Value { None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), }; - match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { + match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) { Ok(r) => { for id in &r.loaded { s.attach_plugin_storage(id); @@ -1098,7 +1100,7 @@ pub fn specs() -> Vec { "Remove Plug-in", [], None, - "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", + "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed; when the settings can't be moved back either, the error names the folder they stay in, and the next load, install or removal of the plug-in moves them back); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", always, remove ), diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 541389bb89..51470e742d 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", "addon.pathtools.corner", @@ -396,6 +397,7 @@ pub(super) const ALLOWED: &[&str] = &[ "paraStyle.apply", "paraStyle.delete", "paraStyle.duplicate", + "paraStyle.edit", "paraStyle.list", "paraStyle.new", "paraStyle.redefine", @@ -484,6 +486,7 @@ pub(super) const ALLOWED: &[&str] = &[ "select.object.brushStrokes", "select.object.clippingMasks", "select.object.directionHandles", + "select.object.notAlignedToPixelGrid", "select.object.pointText", "select.object.slices", "select.object.strayPoints", @@ -578,6 +581,7 @@ pub(super) const ALLOWED: &[&str] = &[ "symbol.replace", "symbol.selectInstances", "symbol.setCurrent", + "symbol.sortByName", "symbol.spray", "symbol.unused", "symbol.update", diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 6dcdc9dc4b..74347caa04 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -13,9 +13,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{NotChanged, Recolor, Scope, left_out}; -use super::{ - CommandSpec, Result, Session, always, bad, check_switches, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param, -}; +use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param}; pub fn specs() -> Vec { vec![ @@ -566,8 +564,8 @@ fn apply(s: &mut Session, p: &Value) -> Result { if ids.is_empty() && group.is_none() { return Err(bad(C, "select artwork to recolour, or give a colour `group`")); } - check_switches(p, &["includeImages", "includePatterns"], C)?; - let scope = Scope { fill: true, stroke: true, ..Scope::of(p) }; + let scope = + Scope { fill: true, stroke: true, images: switch_or(p, "includeImages", true, C)?, patterns: switch_or(p, "includePatterns", true, C)? }; let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" }; // `model`: keep each colour's model (default), take the new colour's own, or the document's. // A given `model` must be a string; only a missing one means "keep". A row's own `model` diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 4cf070c6ae..b75066ec6a 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -111,8 +111,13 @@ pub trait RecoveryStore: Send + Sync { fn announced(&self, _area: &str) -> Option { None } - /// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder). + /// Tidy away area `area` once it holds no copies (its lock file and folder). The caller holds + /// the area until this returns. fn remove_area(&self, _area: &str) {} + /// Remove area `area`'s folder if it is empty, after [`RecoveryStore::remove_area`] and after the + /// caller let the area go. On some file systems a file that is open is removed only when it is + /// closed, so `remove_area` leaves the folder there while the area is held. + fn remove_empty_area(&self, _area: &str) {} /// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone). fn now(&self) -> Option { vectorcraft_doc::metadata::now_unix() @@ -295,6 +300,73 @@ impl FolderStore { .filter(|n| Self::plain(n)) .collect()) } + + /// Lock area folder `dir`, which was just made. Another app tidies an empty area away + /// (`tidy`) by removing its lock file and folder while it holds the lock. So when the folder + /// or the lock file is gone by now, or the lock taken is on a lock file the folder no longer + /// has, that app had the area: it is [`Lock::Busy`], and the caller tries another one. + pub(crate) fn lock_area(dir: &std::path::Path) -> std::result::Result { + if let Err(e) = Self::own_area(dir) { + let gone = std::fs::symlink_metadata(dir).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound); + return if gone { Ok(Lock::Busy) } else { Err(e) }; + } + let path = dir.join(LOCK_FILE); + match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) { + Ok(file) => Self::take(file, &path), + Err(e) => Self::open_failed(e, &path), + } + } + + /// Opening lock file `path` failed with `e`. When the folder is gone by now, another app had the + /// area: it is [`Lock::Busy`]. So it is when the lock file is there but can't be opened: some + /// file systems keep a removed file that another app still has open until that app closes it, + /// and refuse to open it meanwhile. A lock file that can't be made is an error. + pub(crate) fn open_failed(e: std::io::Error, path: &std::path::Path) -> std::result::Result { + match e.kind() { + std::io::ErrorKind::NotFound => Ok(Lock::Busy), + std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy), + _ => Err(format!("{}: {e}", path.display())), + } + } + + /// Lock `file`, opened as lock file `path`. The lock holds the area only while `path` still + /// names that file. + pub(crate) fn take(file: std::fs::File, path: &std::path::Path) -> std::result::Result { + match file.try_lock() { + Ok(()) if Self::still_named(&file, path) => Ok(Lock::Held(Box::new(file))), + Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), + // The lock file was removed meanwhile, and locking it failed instead. + Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy), + Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), + } + } + + /// Does `path` still name `file`? On Unix the two must be the same file (device and inode); on + /// Windows they must be the same file (volume serial number and file index); elsewhere `path` + /// must exist. When either file can't be read, it does not. + fn still_named(file: &std::fs::File, path: &std::path::Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + match (file.metadata(), std::fs::symlink_metadata(path)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(windows)] + { + let opened = file.try_clone().and_then(same_file::Handle::from_file); + match (opened, same_file::Handle::from_path(path)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } + #[cfg(not(any(unix, windows)))] + { + let _ = file; + std::fs::symlink_metadata(path).is_ok() + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -342,29 +414,23 @@ impl RecoveryStore for FolderStore { fn lock(&self, area: &str) -> std::result::Result { let dir = self.area(area)?; Self::create_area(&dir)?; - Self::own_area(&dir)?; - let path = dir.join(LOCK_FILE); - let file = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path) - .map_err(|e| format!("{}: {e}", path.display()))?; - match file.try_lock() { - Ok(()) => Ok(Lock::Held(Box::new(file))), - Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), - Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), - } + Self::lock_area(&dir) } fn remove_area(&self, area: &str) { // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty - // (copies came back meanwhile) stays. + // (copies came back meanwhile) stays. The caller still holds the lock here. if let Ok(dir) = self.area(area) { let _ = std::fs::remove_file(dir.join(LOCK_FILE)); let _ = std::fs::remove_dir(&dir); } } + fn remove_empty_area(&self, area: &str) { + // Best effort, as above. An app that made the folder again meanwhile and has not locked it + // yet finds it gone and takes another area (`lock_area`); one that has keeps it. + if let Ok(dir) = self.area(area) { + let _ = std::fs::remove_dir(&dir); + } + } } /// The area this session keeps its copies in, while it holds it. @@ -527,8 +593,10 @@ pub fn release(s: &mut Session) { for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { let _ = store.remove(name); } - drop(hold); + // Held until it is gone, as in `tidy`. store.remove_area(&area); + drop(hold); + store.remove_empty_area(&area); } /// Let go of this session's area but leave its copies, as a crash would, so the next app started @@ -967,7 +1035,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } } -/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). +/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The +/// area is removed while it is still held, so an app taking a new area of that name meanwhile +/// finds it held or gone and takes the next name. fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) { let names = store.list().unwrap_or_default(); let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect(); @@ -977,8 +1047,9 @@ fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) { for n in mine { let _ = store.remove(n); } - drop(hold); store.remove_area(area); + drop(hold); + store.remove_empty_area(area); } /// The copies of documents open now in this session. diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 6f64528cc0..8567a7ec21 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -45,6 +45,24 @@ pub fn specs() -> Vec { always, stop ), + cmd!( + query "script.engines", + "List Script Engines", + [], + None, + "{} the named engines (#targetengine) this session keeps, in the order they were made: a named engine keeps its globals between the runs that name it until it is dropped or the session ends; main, the engine of a script without #targetengine, is new for each run and is not listed; a session keeps at most 8, each with at most 64 MiB of heap (VectorCraft's own limits; none on the web) → {engines: [{name, heapBytes: its heap after its last run, running: a run has it now}], limit: 8, heapLimit: 67108864}", + always, + engines + ), + cmd!( + query "script.engine.drop", + "Drop Script Engine", + [], + None, + "{name: a named engine script.engines lists} drop the engine and the globals its runs left; the next run that names it gets a new one; main, an engine no run made and the engine of the running script are refused → {dropped: true, name}", + always, + engine_drop + ), cmd!( query "script.console", "Script Console", @@ -175,6 +193,22 @@ fn stop(s: &mut Session, _: &Value) -> Result { Ok(json!({"stopped": s.script_request_stop()})) } +fn engines(s: &mut Session, _: &Value) -> Result { + let list: Vec = + s.script_engines().into_iter().map(|(name, heap, running)| json!({"name": name, "heapBytes": heap, "running": running})).collect(); + Ok(json!({"engines": list, "limit": script::MAX_ENGINES, "heapLimit": script::ENGINE_HEAP_BYTES})) +} + +fn engine_drop(s: &mut Session, p: &Value) -> Result { + const C: &str = "script.engine.drop"; + let name = match p.get("name") { + Some(Value::String(name)) if !name.is_empty() => name.clone(), + _ => return Err(bad(C, "`name` must be the name of a script engine")), + }; + s.drop_script_engine(&name)?; + Ok(json!({"dropped": true, "name": name})) +} + fn console_lines(s: &mut Session, _: &Value) -> Result { Ok(s.script_console().to_json()) } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 25bf6db542..aeeac58855 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -7,6 +7,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind}; use vectorcraft_tools::builder::is_live_paint; +use super::object::can_align; use super::wand::{fill_of, stroke_of}; use super::{ CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index, ok, str_param, @@ -178,6 +179,15 @@ pub fn specs() -> Vec { cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n .path_data() .is_some_and(|p| !p.is_closed()))), + cmd!( + "select.object.notAlignedToPixelGrid", + "Not Aligned to Pixel Grid", + ["Select", "Object"], + None, + "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}", + has_doc, + not_pixel_aligned + ), cmd!( "select.same.graphicStyle", "Graphic Style", @@ -865,3 +875,19 @@ fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result { // The objects selected, after Lock Guides has left out guide paths. Ok(json!({ "count": s.doc()?.selection.objects.len() })) } + +/// Select › Object › Not Aligned to Pixel Grid: the objects that can take the pixel-aligned +/// property ([`can_align`]) and do not have it. A compound path counts as one object, so the walk +/// does not look at the paths inside it. +fn not_pixel_aligned(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let mut ids = vec![]; + walk_selectable(&st.doc, st.isolation, |n| { + if can_align(n) && !n.extras().pixel_aligned { + ids.push(n.id); + } + !matches!(n.kind, NodeKind::Compound { .. }) + }); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": s.doc()?.selection.objects.len() })) +} diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 06ec067934..29a357f730 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin use vectorcraft_geom::{Rect, shapes}; use super::{ - CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ids_param, index, - switch_or, targets, + CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or, + targets, }; /// The most slices one Divide Slices makes. @@ -380,7 +380,8 @@ fn from_selection(s: &mut Session, p: &Value) -> Result { fn duplicate(s: &mut Session, p: &Value) -> Result { let targets = selected(s)?; - let (dx, dy) = (f64_or(p, "dx", 10.0), f64_or(p, "dy", 10.0)); + let dx = number_or(p, "dx", 10.0, "object.slice.duplicate")?; + let dy = number_or(p, "dy", 10.0, "object.slice.duplicate")?; let ids = s.edit("Duplicate Slice", |d, sel| { let mut out = vec![]; for id in &targets { @@ -737,7 +738,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { fn select(s: &mut Session, p: &Value) -> Result { let slices = p.get("slices").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])); let ids = slice_targets(s, &json!({ "slices": slices }), "object.slice.select")?; - let toggle = bool_or(p, "toggle", false); + let toggle = switch_or(p, "toggle", false, "object.slice.select")?; s.select(|_, sel| { let mut now = if toggle { sel.slices.clone() } else { vec![] }; for id in ids { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d31031d5d4..bca9ed6ea9 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -13,11 +13,12 @@ //! to loses its characters' own fill (stroke) when the style has fills (strokes), so the style's //! paint shows instead of the character colour under it. +use std::borrow::Cow; use std::collections::HashMap; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared}; use vectorcraft_geom::Rect; use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name}; @@ -260,9 +261,14 @@ fn override_chars(n: &mut Node, ap: &Appearance) { } /// Give `n` style `g`: its appearance (a group or layer too: its own, above its contents; type its -/// characters' colour too with `chars`, Override Character Color) and transparency. -fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { - n.appearance = g.appearance_on(n).into_owned().into(); +/// characters' colour too with `chars`, Override Character Color) and transparency. Objects that +/// take `g`'s appearance as it is share one copy of it, made in `shared` the first time; an object +/// whose placed gradients land on its own bounds gets its own. +fn style_node(n: &mut Node, g: &GraphicStyle, shared: &mut Option>, chars: bool) { + n.appearance = match g.appearance_on(n) { + Cow::Borrowed(ap) => shared.get_or_insert_with(|| Shared::new(ap.clone())).clone(), + Cow::Owned(ap) => ap.into(), + }; if chars { override_chars(n, &g.appearance); } @@ -270,9 +276,9 @@ fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { } /// Give `id` style `g` ([`style_node`]) and link it. -fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { +fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, shared: &mut Option>, chars: bool) { if let Some(n) = d.node_mut(id) { - style_node(n, g, chars); + style_node(n, g, shared, chars); n.extras_mut().graphic_style = Some(g.id); super::object::snap_if_aligned(n); } @@ -313,7 +319,7 @@ impl Session { /// Color as set): the Graphic Styles panel's previews. pub fn styled(&self, n: &Node, g: &GraphicStyle) -> Node { let mut n = n.clone(); - style_node(&mut n, g, self.prefs.override_char_color); + style_node(&mut n, g, &mut None, self.prefs.override_char_color); n } } @@ -354,8 +360,9 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); + let mut shared = None; for id in &ids { - apply_style(d, *id, &g, chars); + apply_style(d, *id, &g, &mut shared, chars); } super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } @@ -474,8 +481,9 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); + let mut shared = None; for nid in &follow { - apply_style(d, *nid, &g, chars); + apply_style(d, *nid, &g, &mut shared, chars); } super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 7b0cad576a..2a9f480e89 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -14,7 +14,9 @@ use vectorcraft_doc::style_libs::{self, STYLE_LIBRARIES, STYLES_EXT, builtin_sty use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary}; use super::menucmds::squash; -use super::swatchlib::{FileData, LibraryFile, LibraryInfo, names_param, stem}; +use super::swatchlib::{ + FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, names_param, shown_id, shown_library_id, stem, +}; use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, has_doc, str_param, switch_or, text_param, unique_name}; pub fn specs() -> Vec { @@ -94,6 +96,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc if let Some(found) = s.style_libraries.get(key) { return Some(found); } + if let Some(found) = concealed_library_id(s, key).and_then(|id| s.style_libraries.get(&id)) { + return Some(found); + } if let Some(b) = STYLE_LIBRARIES.iter().find(|b| b.id == key) { return Some((builtin_info(b), builtin_style_library(key)?)); } @@ -108,20 +113,22 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc< fn list(s: &mut Session, _: &Value) -> Result { s.style_libraries.rescan(); + let concealed = concealed_library_ids(s, []); let libs: Vec = libraries(s) .into_iter() .map(|l| { let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); - json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.style_libraries.path(&l.id)}) + let (id, path) = (shown_id(&concealed, &l.id), s.style_libraries.shown_path(&l.id)); + json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path}) }) .collect(); - Ok(json!({ "libraries": libs, "userFolder": s.style_libraries.user_dir() })) + Ok(json!({ "libraries": libs, "userFolder": s.style_libraries.shown_user_dir() })) } fn get(s: &mut Session, p: &Value) -> Result { let (info, lib) = library_param(s, p, "graphicStyle.library")?; let styles: Vec = lib.styles.iter().map(super::style::style_json).collect(); - Ok(json!({"id": info.id, "name": info.name, "category": info.category, "styles": styles})) + Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "styles": styles})) } /// Pattern `name` of `lib` in `d`: the document's own when it has the same pattern (under any @@ -192,7 +199,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; let doc_name = |lib_name: &str| added.iter().find(|(l, _)| l == lib_name).map_or_else(|| lib_name.to_string(), |(_, n)| n.clone()); let mut out = json!({ - "library": info.id, + "library": shown_library_id(s, &info.id), "added": added.iter().map(|(_, n)| n).collect::>(), "existing": existing.iter().map(|g| &g.name).collect::>(), }); @@ -238,5 +245,5 @@ fn load(s: &mut Session, p: &Value) -> Result { } } })?; - Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) + Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()})) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 2421441b91..c809d58090 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -1,6 +1,6 @@ //! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting. -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; @@ -222,9 +222,10 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { /// The `names` list and `name` parameters, without repeats; at least one is required. pub(super) fn names_param(p: &Value, cmd: &str) -> Result> { + let mut seen: HashSet = HashSet::new(); let mut names: Vec = vec![]; for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) { - if !names.contains(&n) { + if seen.insert(n.clone()) { names.push(n); } } @@ -234,17 +235,73 @@ pub(super) fn names_param(p: &Value, cmd: &str) -> Result> { Ok(names) } +/// Take swatches `names` out of the document in that order, as [`Document::remove_swatch`] for +/// each name in turn would, in one pass over the swatches: a name takes the first swatch of that +/// name still there (ungrouped first, then each colour group's) when `take` accepts it, and +/// nothing when `take` refuses it or no swatch has the name. +fn take_swatches(d: &mut Document, names: &[String], take: impl Fn(&Swatch) -> bool) -> Vec { + let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); + // Each wanted name's swatches in lookup order: (list, index, accepted), where list 0 holds the + // ungrouped swatches and list g + 1 colour group g's. + let mut places: HashMap<&str, VecDeque<(usize, usize, bool)>> = HashMap::new(); + let lists = std::iter::once(&d.swatches[..]).chain(d.swatch_groups.iter().map(|g| &g.swatches[..])); + for (l, list) in lists.enumerate() { + for (i, w) in list.iter().enumerate() { + if let Some(n) = wanted.get(w.name.as_str()) { + places.entry(n).or_default().push_back((l, i, take(w))); + } + } + } + let mut picked: Vec<(usize, usize)> = vec![]; + for n in names { + if let Some(q) = places.get_mut(n.as_str()) + && let Some(&(l, i, true)) = q.front() + { + q.pop_front(); + picked.push((l, i)); + } + } + if picked.is_empty() { + return vec![]; + } + let hit: HashSet<(usize, usize)> = picked.iter().copied().collect(); + let hit_lists: HashSet = picked.iter().map(|&(l, _)| l).collect(); + let mut taken: HashMap<(usize, usize), Swatch> = HashMap::with_capacity(picked.len()); + let mut split = |l: usize, list: &mut Vec| { + for (i, w) in std::mem::take(list).into_iter().enumerate() { + if hit.contains(&(l, i)) { + taken.insert((l, i), w); + } else { + list.push(w); + } + } + }; + // Both lists are copied on write, so each is only written when a swatch leaves it. + if hit_lists.contains(&0) { + split(0, &mut d.swatches); + } + if hit_lists.iter().any(|&l| l > 0) { + for (g, group) in d.swatch_groups.iter_mut().enumerate() { + if hit_lists.contains(&(g + 1)) { + split(g + 1, &mut group.swatches); + } + } + } + picked.iter().filter_map(|k| taken.remove(k)).collect() +} + fn swatch_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.delete"; let names = names_param(p, C)?; check_switches(p, &["unlink"], C)?; let d = &s.doc()?.doc; + let by_name = swatches_by_name(d); // Global swatches going away (named ones and those in named groups): their links are dropped. - let mut global: Vec = vec![]; + let mut global: HashSet = HashSet::new(); for n in &names { let swatches: Vec<&Swatch> = match d.swatch_groups.iter().find(|g| g.name == *n) { Some(g) => g.swatches.iter().collect(), - None => vec![d.swatch(n).ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?], + None => vec![by_name.get(n.as_str()).copied().ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?], }; if swatches.iter().any(|w| w.is_reserved()) { return Err(bad(C, format!("`{n}` can't be deleted"))); @@ -258,10 +315,9 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { |_: &mut Color, link: &mut Option, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some(); let colorized_ok = super::paint::check_colorized(d).is_ok(); let unlinked = s.edit("Delete Swatch", |d, _| { - d.swatch_groups.retain(|g| !names.contains(&g.name)); - for n in &names { - d.remove_swatch(n); - } + let named: HashSet<&str> = names.iter().map(String::as_str).collect(); + d.swatch_groups.retain(|g| !named.contains(g.name.as_str())); + take_swatches(d, &names, |_| true); let unlinked = map_swatch_links(d, &mut unlink); super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; Ok(unlinked) @@ -347,6 +403,8 @@ fn gradient_key(g: &Gradient) -> Option> { GradientKind::Linear => 0, GradientKind::Radial => 1, GradientKind::Freeform => 2, + GradientKind::Conic => 3, + GradientKind::Diamond => 4, }; let method = match g.method { GradientMethod::Classic => 0, @@ -419,15 +477,9 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { new.extend(art.into_iter().map(|c| (c, to_global))); } let name = d.free_swatch_name(requested.as_deref().unwrap_or("Color Group")); - let mut group = SwatchGroup { name: name.clone(), swatches: vec![] }; // Colour groups hold solid colours only. - for n in &names { - if d.swatch(n).is_some_and(|sw| matches!(sw.paint, Paint::Solid { .. })) - && let Some(sw) = d.remove_swatch(n) - { - group.swatches.push(sw); - } - } + let swatches = take_swatches(d, &names, |sw| matches!(sw.paint, Paint::Solid { .. })); + let mut group = SwatchGroup { name: name.clone(), swatches }; // The global swatches made from the art's colours, which its unlinked paints link to. let mut links: Vec<(Color, String)> = vec![]; let mut free = d.free_swatch_names(); @@ -483,8 +535,9 @@ fn swatch_move(s: &mut Session, p: &Value) -> Result { insert_before(&mut d.swatch_groups, items, at.as_deref(), |g| &g.name); return Ok(()); } + let by_name = swatches_by_name(d); for n in &names { - let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?; + let sw = by_name.get(n.as_str()).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?; if sw.is_reserved() { return Err(bad(C, format!("`{n}` can't be moved"))); } @@ -503,7 +556,7 @@ fn swatch_move(s: &mut Session, p: &Value) -> Result { } } let at = anchor(list(d, dest), to, &names, |w| &w.name); - let items: Vec = names.iter().filter_map(|n| d.remove_swatch(n)).collect(); + let items = take_swatches(d, &names, |_| true); insert_before(list(d, dest), items, at.as_deref(), |w| &w.name); Ok(()) })?; @@ -955,8 +1008,9 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.merge"; let names = names_param(p, C)?; let d = &s.doc()?.doc; + let by_name = swatches_by_name(d); for n in &names { - let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?; + let sw = by_name.get(n.as_str()).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?; if sw.is_reserved() { return Err(bad(C, format!("`{n}` can't be merged"))); } @@ -970,9 +1024,10 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { } let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?; let (color, link) = (d.linked_color(kept.paint.color().unwrap_or_default(), kept.spot), kept.global.then(|| keep.clone())); + let merged_names: HashSet<&str> = merged.iter().map(String::as_str).collect(); // Tints keep their tint (of the kept colour). let mut relink = |c: &mut Color, l: &mut Option, t: &mut f32| { - if !l.as_ref().is_some_and(|l| merged.contains(l)) { + if !l.as_ref().is_some_and(|l| merged_names.contains(l.as_str())) { return false; } *c = color.tinted(*t); @@ -981,9 +1036,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { }; let colorized_ok = super::paint::check_colorized(d).is_ok(); let relinked = s.edit("Merge Swatches", |d, _| { - for n in merged { - d.remove_swatch(n); - } + take_swatches(d, merged, |_| true); let relinked = map_swatch_links(d, &mut relink); super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; Ok(relinked) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index fe2816ed16..45320066cf 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -6,7 +6,7 @@ //! pattern) into the document; `swatch.library.save` writes the document's ([`palette_io`]). //! `swatch.library.copyToUser` copies a loaded library into the user library folder. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -211,6 +211,22 @@ impl Libraries { self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref()) } + /// The ids [`Libraries::get`] finds: the User Defined and loaded libraries', and the loaded ids of + /// libraries copied into the user library folder. + fn ids(&self) -> impl Iterator + '_ { + self.extra.iter().map(|e| e.info.id.clone()).chain(self.copied.iter().map(|(loaded, _)| loaded.clone())) + } + + /// The file library `id` was read from, when the request being served may read it. + pub fn shown_path(&self, id: &str) -> Option<&str> { + self.path(id).filter(|p| readable(p)) + } + + /// The user library folder, when the request being served may read it. + pub fn shown_user_dir(&self) -> Option<&str> { + self.user_dir().filter(|d| readable(d)) + } + /// The User Defined library whose file has the extension of file name `file` and holds `bytes`. fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra> { let ext = extension(file); @@ -281,7 +297,10 @@ impl Libraries { self.saved_as .insert(format!("user/{file}").to_lowercase(), SavedAs { name: name.to_string(), hash: vectorcraft_doc::links::hash_bytes(bytes) }); self.rescan(); - out["path"] = json!(path); + // The folder is the app's own: a confined request sees the file only inside its read roots. + if readable(&path) { + out["path"] = json!(path); + } out["library"] = json!(format!("user/{file}")); } else if let Some(path) = path { write_file(path, bytes)?; @@ -349,6 +368,10 @@ impl Libraries { .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { Some(p) => { + // Outside the request's read roots: the error names no path. + if !readable(&p) { + return Err(bad(cmd, "the library's file is outside the read roots")); + } let ext = extension(&p); let base = file_name(&p); // A sandbox copy of an import often lands as tmp….tmp.acb (#2996): store it under the @@ -446,6 +469,72 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } +/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without +/// roots in force. +fn readable(path: &str) -> bool { + crate::file_access::check_read(path).is_ok() +} + +/// The loaded library ids (`loaded/`) whose file the request being served may not read, each +/// with the id a reply shows instead: `loaded/`, as a library loaded without a path is +/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then +/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free +/// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. The +/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows +/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none. +pub fn concealed_library_ids(s: &Session, others: impl IntoIterator) -> Vec<(String, String)> { + if crate::file_access::current().is_none() { + return vec![]; + } + let session: BTreeSet = + s.swatch_libraries.ids().chain(s.brush_libraries.ids()).chain(s.symbol_libraries.ids()).chain(s.style_libraries.ids()).collect(); + let others: BTreeSet = others.into_iter().filter(|id| !session.contains(id)).collect(); + let mut hidden = Vec::new(); + let mut taken = BTreeSet::new(); + for ids in [session, others] { + for id in ids { + let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path)); + match name { + Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), + _ => { + taken.insert(id); + } + } + } + } + hidden + .into_iter() + .map(|(id, name)| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + (id, shown) + }) + .collect() +} + +/// The id a reply shows for library `id`, given the [`concealed_library_ids`] in force. +pub(crate) fn shown_id(concealed: &[(String, String)], id: &str) -> String { + concealed.iter().find(|(own, _)| own == id).map_or_else(|| id.to_string(), |(_, shown)| shown.clone()) +} + +/// The id a reply shows for library `id` ([`concealed_library_ids`]). +pub(crate) fn shown_library_id(s: &Session, id: &str) -> String { + shown_id(&concealed_library_ids(s, []), id) +} + +/// The library id behind `key` when `key` is the id a reply shows for a concealed loaded library. +pub(crate) fn concealed_library_id(s: &Session, key: &str) -> Option { + if !key.starts_with("loaded/") { + return None; + } + concealed_library_ids(s, []).into_iter().find(|(_, shown)| shown == key).map(|(own, _)| own) +} + /// `name` without its extension. pub(crate) fn stem(name: &str) -> &str { name.rsplit_once('.').map_or(name, |(s, _)| s) @@ -479,6 +568,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc Result<(LibraryInfo, Arc< fn list(s: &mut Session, _: &Value) -> Result { s.swatch_libraries.rescan(); + let concealed = concealed_library_ids(s, []); let libs: Vec = libraries(s) .into_iter() .map(|l| { let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); - json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.swatch_libraries.path(&l.id)}) + let (id, path) = (shown_id(&concealed, &l.id), s.swatch_libraries.shown_path(&l.id)); + json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path}) }) .collect(); - Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.user_dir() })) + Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.shown_user_dir() })) } /// The document's swatches `names` (swatches or colour groups; empty: all) as a library named @@ -619,7 +713,7 @@ fn load(s: &mut Session, p: &Value) -> Result { document_library(&loaded.doc, &[], stem(file).to_string(), C) } })?; - Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) + Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()})) } fn get(s: &mut Session, p: &Value) -> Result { @@ -629,7 +723,7 @@ fn get(s: &mut Session, p: &Value) -> Result { let groups: Vec = lib.groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::>()})).collect(); Ok( - json!({"id": info.id, "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::>(), "groups": groups}), + json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::>(), "groups": groups}), ) } @@ -652,7 +746,7 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result { "user" => (info, lib, false), _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), }; - let path = s.swatch_libraries.path(&info.id); + let path = s.swatch_libraries.shown_path(&info.id); Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) } @@ -792,7 +886,7 @@ fn add(s: &mut Session, p: &Value) -> Result { })? }; let names = |list: &[(String, String)]| list.iter().map(|(_, n)| n.clone()).collect::>(); - let mut out = json!({"library": info.id, "added": names(&added), "existing": names(&existing)}); + let mut out = json!({"library": shown_library_id(s, &info.id), "added": names(&added), "existing": names(&existing)}); if let (Some(cmd), Some((_, first))) = (apply, picked.first()) { let name = added.iter().chain(&existing).find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone()); s.execute(cmd, &json!({"swatch": name, "focus": focus}))?; diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index b8cae195c0..98b46a5d1a 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -19,7 +19,7 @@ use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_nat use super::clipboard::Clipboard; use super::clipboard::resources::SwatchChoices; use super::swatch::str_list; -use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; +use super::swatchlib::{FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, shown_id, shown_library_id, stem}; use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, number_or, str_param}; /// The extension of saved symbol libraries: native documents. @@ -386,6 +386,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc Result<(LibraryInfo, Arc< fn list(s: &mut Session, _: &Value) -> Result { s.symbol_libraries.rescan(); + let concealed = concealed_library_ids(s, []); let libs: Vec = libraries(s) .into_iter() .map(|l| { let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); - json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.symbol_libraries.path(&l.id)}) + let (id, path) = (shown_id(&concealed, &l.id), s.symbol_libraries.shown_path(&l.id)); + json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path}) }) .collect(); - Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.user_dir() })) + Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.shown_user_dir() })) } fn get(s: &mut Session, p: &Value) -> Result { @@ -421,7 +426,7 @@ fn get(s: &mut Session, p: &Value) -> Result { json!({"name": sym.name, "size": [w, h], "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id()}) }) .collect(); - Ok(json!({"id": info.id, "name": info.name, "category": info.category, "symbols": symbols})) + Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "symbols": symbols})) } fn add(s: &mut Session, p: &Value) -> Result { @@ -456,7 +461,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None }; let parent = if place { st.target_parent()? } else { None }; - let mut out = json!({ "library": info.id }); + let mut out = json!({ "library": shown_library_id(s, &info.id) }); if imported.added > 0 || place { let placed = s.edit("Add to Symbols", |d, sel| { let imported = clip.import_into(d, &choices, false)?; @@ -512,7 +517,7 @@ fn load(s: &mut Session, p: &Value) -> Result { } SymbolLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e)) })?; - Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) + Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()})) } #[cfg(test)] diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 21ed6d8023..00f1dd8adb 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -1637,7 +1637,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { }; super::create::new_type_alignment(s, p, C, &mut t)?; if !on_path { - t.area.fit = super::create::new_area_fit(s, p, C)?; + super::create::new_area_fit(s, p, C, &mut t)?; } t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 89ec47b276..d30e69df68 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -143,7 +143,21 @@ pub fn specs() -> Vec { |s, p| edit_style(s, p, Kind::Char), true, )); - v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph", "")); + v.extend(style_cmds!( + Kind::Para, + "paraStyle", + "Paragraph Styles", + "Paragraph", + "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt" + )); + v.push(spec( + "paraStyle.edit", + "Edit Paragraph Style", + &[], + "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt", + |s, p| edit_style(s, p, Kind::Para), + true, + )); v.push(spec("textStyle.loadAll", "Load All Styles…", &["Window", "Type"], LOAD_PARAMS, |s, p| load(s, p, &[Kind::Char, Kind::Para]), true)); v } @@ -554,7 +568,11 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma } char_attrs(&st) } - Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?), + Kind::Para => { + let st = with_attrs(&ParaStyle::default(), m, &c)?; + para_ranges(&st, m, kept, &c)?; + para_attrs(&st) + } }; Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect())) } @@ -562,6 +580,82 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma } } +/// The range of a paragraph style's indents, space before and space after, and Hyphenation Zone (pt), +/// as `text.setFormat` keeps them. +const PARA_DISTANCE: f64 = 1296.0; + +/// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the +/// ranges `text.setFormat` keeps them to: the indents and the space before and after, each +/// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as +/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked. +fn para_ranges(st: &ParaStyle, attrs: &Map, kept: Option<&Map>, c: &str) -> Result<()> { + let within = |key: &str, value: f64, lo: f64, hi: f64| { + if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) } + }; + let changed = |key: &str| attrs.contains_key(key) && kept.and_then(|k| k.get(key)) != attrs.get(key); + let nested_changed = |key: &str, field: &str| { + changed(key) && attrs.get(key).and_then(|v| v.get(field)) != kept.and_then(|k| k.get(key)).and_then(|v| v.get(field)) + }; + for (key, value) in [ + ("left_indent", st.left_indent), + ("right_indent", st.right_indent), + ("first_line_indent", st.first_line_indent), + ("space_before", st.space_before), + ("space_after", st.space_after), + ] { + if changed(key) { + within(key, value, -PARA_DISTANCE, PARA_DISTANCE)?; + } + } + let h = &st.hyphenation; + for (field, value, lo, hi) in [ + ("words_longer_than", f64::from(h.words_longer_than), 1.0, 25.0), + ("after_first", f64::from(h.after_first), 1.0, 15.0), + ("before_last", f64::from(h.before_last), 1.0, 15.0), + ("hyphen_limit", f64::from(h.hyphen_limit), 0.0, 25.0), + ("zone", h.zone, 0.0, PARA_DISTANCE), + ] { + if nested_changed("hyphenation", field) { + within(&format!("hyphenation.{field}"), value, lo, hi)?; + } + } + // Read as given: a paragraph's attributes keep justification options in range as they load. + if let Some(given) = attrs.get("justification").filter(|_| changed("justification")) { + let j: vectorcraft_doc::JustificationOptions = serde_json::from_value(given.clone()).map_err(|e| bad(c, e.to_string()))?; + for (field, values, (lo, hi)) in [ + ("word_spacing", j.word_spacing, vectorcraft_doc::JustificationOptions::WORD_SPACING), + ("letter_spacing", j.letter_spacing, vectorcraft_doc::JustificationOptions::LETTER_SPACING), + ("glyph_scaling", j.glyph_scaling, vectorcraft_doc::JustificationOptions::GLYPH_SCALING), + ] { + if !nested_changed("justification", field) { + continue; + } + let name = format!("justification.{field}"); + for value in values { + within(&name, value, lo, hi)?; + } + if !(values[0] <= values[1] && values[1] <= values[2]) { + return Err(bad(c, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum"))); + } + } + if nested_changed("justification", "auto_leading") { + let (lo, hi) = vectorcraft_doc::JustificationOptions::AUTO_LEADING; + within("justification.auto_leading", j.auto_leading, lo, hi)?; + } + } + if changed("tabs") { + let kept_tabs: Vec = kept + .and_then(|k| k.get("tabs")) + .and_then(Value::as_array) + .map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect()) + .unwrap_or_default(); + for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) { + within("tabs.position", stop.position, 0.0, 100_000.0)?; + } + } + Ok(()) +} + /// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a /// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes. fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index ab0c7d342e..87bca29048 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1585,3 +1585,147 @@ fn para_style_range_capture_samples_start_paragraph() { } } } + +fn paras(s: &Session, id: u64) -> Vec { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.paragraph_styles(), + _ => panic!("not text"), + } +} + +/// `paraStyle.edit` renames a paragraph style and replaces its attributes in one undo step: every +/// paragraph using it takes the new values, a paragraph's local override stays, and the saved file +/// keeps the sparse definition. +#[test] +fn paragraph_style_options_edit_applies_to_users_and_is_one_saved_undo_step() { + use vectorcraft_doc::Composer; + let (mut s, id) = session_with_text("One\nTwo\nThree"); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"left_indent": 6.0, "composer": "everyLine", "tabs": [{"position": 36.0}]}})) + .unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Body", "id": id, "start": 0, "end": 7})).unwrap(); + s.execute("text.setFormat", &json!({"id": id, "start": 4, "end": 4, "leftIndent": 20.0})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let attrs = json!({"left_indent": 12.0, "composer": "singleLine", "tabs": [{"position": 36.0}], + "hyphenation": {"words_longer_than": 8}, "justification": {"word_spacing": [70.0, 90.0, 120.0]}}); + s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Text", "attrs": attrs})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let p = paras(&s, id); + assert_eq!( + p.iter().map(|p| (p.style_name.as_deref(), p.left_indent, p.composer)).collect::>(), + [(Some("Text"), 12.0, Composer::SingleLine), (Some("Text"), 20.0, Composer::SingleLine), (None, 0.0, Composer::EveryLine)] + ); + assert_eq!(p[0].hyphenation.words_longer_than, 8); + assert_eq!(p[1].justification.word_spacing, [70.0, 90.0, 120.0]); + let def = s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Text").unwrap().attrs.clone(); + let mut keys: Vec<&str> = def.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, ["composer", "hyphenation", "justification", "left_indent", "tabs"]); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(saved.para_styles.iter().find(|d| d.name == "Text").unwrap().attrs, def); + let changed = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, changed); + // The Normal style, an empty or a taken name and an unknown style are errors. + s.execute("paraStyle.new", &json!({"name": "Other"})).unwrap(); + for (params, why) in [ + (json!({"name": "Text", "to": " ", "attrs": {}}), "give the style a name"), + (json!({"name": "Text", "to": "Other", "attrs": {}}), "already exists"), + (json!({"name": "Missing", "to": "Missing", "attrs": {}}), "no style `Missing`"), + (json!({"name": NORMAL_PARA, "to": NORMAL_PARA, "attrs": {}}), "can't be edited"), + ] { + let error = s.execute("paraStyle.edit", ¶ms).unwrap_err().to_string(); + assert!(error.contains(why), "{params}: {error}"); + } +} + +/// A changed paragraph attribute outside the range `text.setFormat` keeps it to is an error for +/// `paraStyle.edit`, `paraStyle.setAttrs` and `paraStyle.new`, with no change to the document; the +/// ends of each range are accepted, and an out-of-range value the style already has can be kept. +#[test] +fn paragraph_style_attributes_are_checked_before_a_definition_changes() { + let (mut s, _) = session_with_text("Text"); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"left_indent": 6.0}})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for (attrs, key) in [ + (json!({"left_indent": -1296.1}), "left_indent"), + (json!({"right_indent": 1296.1}), "right_indent"), + (json!({"first_line_indent": 1e300}), "first_line_indent"), + (json!({"space_before": 1296.1}), "space_before"), + (json!({"space_after": -1296.1}), "space_after"), + (json!({"hyphenation": {"words_longer_than": 0}}), "hyphenation.words_longer_than"), + (json!({"hyphenation": {"words_longer_than": 4000000000_u64}}), "hyphenation.words_longer_than"), + (json!({"hyphenation": {"after_first": 16}}), "hyphenation.after_first"), + (json!({"hyphenation": {"before_last": 0}}), "hyphenation.before_last"), + (json!({"hyphenation": {"hyphen_limit": 26}}), "hyphenation.hyphen_limit"), + (json!({"hyphenation": {"zone": -1.0}}), "hyphenation.zone"), + (json!({"hyphenation": {"zone": 1296.1}}), "hyphenation.zone"), + (json!({"justification": {"word_spacing": [80.0, 100.0, 1000.1]}}), "justification.word_spacing"), + (json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}}), "justification.word_spacing"), + (json!({"justification": {"letter_spacing": [-100.1, 0.0, 0.0]}}), "justification.letter_spacing"), + (json!({"justification": {"letter_spacing": [0.0, 10.0, 5.0]}}), "justification.letter_spacing"), + (json!({"justification": {"glyph_scaling": [49.0, 100.0, 100.0]}}), "justification.glyph_scaling"), + (json!({"justification": {"auto_leading": 500.1}}), "justification.auto_leading"), + (json!({"tabs": [{"position": 100000.1}]}), "tabs.position"), + (json!({"tabs": [{"position": -1.0}]}), "tabs.position"), + ] { + for cmd in ["paraStyle.edit", "paraStyle.setAttrs", "paraStyle.new"] { + let params = match cmd { + "paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": attrs}), + "paraStyle.new" => json!({"name": "Other", "attrs": attrs}), + _ => json!({"name": "Body", "attrs": attrs}), + }; + let error = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(error.contains(&format!("`{key}`")), "{cmd} {attrs}: {error}"); + } + } + // Malformed values are errors too. + for attrs in [json!({"hyphenation": {"words_longer_than": -1}}), json!({"justification": {"word_spacing": [1.0, 2.0]}}), json!({"tabs": 3})] { + assert!(s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": attrs})).is_err(), "{attrs}"); + } + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let ends = json!({ + "left_indent": -1296.0, "right_indent": 1296.0, "first_line_indent": 0.0, "space_before": 1296.0, "space_after": -1296.0, + "hyphenation": {"words_longer_than": 25, "after_first": 1, "before_last": 15, "hyphen_limit": 0, "zone": 1296.0}, + "justification": {"word_spacing": [0.0, 0.0, 1000.0], "letter_spacing": [-100.0, 0.0, 500.0], "glyph_scaling": [50.0, 100.0, 200.0], "auto_leading": 500.0}, + "tabs": [{"position": 0.0}, {"position": 100000.0}] + }); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": ends})).unwrap(); + // A style that already holds a value out of range (one read from a file) can be renamed with it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("left_indent".into(), json!(2000.0)); + s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Renamed", "attrs": {"left_indent": 2000.0}})).unwrap(); + assert!(s.doc().unwrap().doc.para_styles.iter().any(|d| d.name == "Renamed")); + // Such a Hyphenation, Justification or tab value is kept while another option of its group changes, + // and a changed option is still checked. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let held = json!({ + "hyphenation": {"words_longer_than": 40, "after_first": 2}, + "justification": {"letter_spacing": [0.0, 0.0, 600.0], "auto_leading": 120.0}, + "tabs": [{"position": 200000.0}] + }); + let attrs = &mut doc.para_styles.iter_mut().find(|d| d.name == "Renamed").unwrap().attrs; + attrs.extend(held.as_object().unwrap().clone()); + let edited = json!({ + "hyphenation": {"words_longer_than": 40, "after_first": 3}, + "justification": {"letter_spacing": [0.0, 0.0, 600.0], "auto_leading": 150.0}, + "tabs": [{"position": 200000.0}, {"position": 72.0}] + }); + s.execute("paraStyle.setAttrs", &json!({"name": "Renamed", "attrs": edited})).unwrap(); + let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Renamed").unwrap().attrs.clone(); + assert_eq!(def(&s)["hyphenation"]["after_first"], json!(3)); + assert_eq!(def(&s)["justification"]["auto_leading"], json!(150.0)); + for (attrs, key) in [ + (json!({"hyphenation": {"words_longer_than": 40, "after_first": 16}}), "hyphenation.after_first"), + (json!({"justification": {"auto_leading": 501.0}}), "justification.auto_leading"), + (json!({"tabs": [{"position": 200000.0}, {"position": 72.0}, {"position": 100001.0}]}), "tabs.position"), + ] { + let error = s.execute("paraStyle.setAttrs", &json!({"name": "Renamed", "attrs": attrs})).unwrap_err().to_string(); + assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}"); + } +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6d29550fa0..33dad39d93 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Area Type Options…", ["Type"], None, - "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", + "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText|block, fitMinPercent?: 10..100, wordGap?: % 0..1000, lineGap?: pt -1296..1296, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit; fit block is Dynamic Text's Block, as type.dynamicText sets it (in place of Auto Size and Shrink Text to Fit; threaded frames are refused; another fit turns it off, keeping the sizes it wrote), and wordGap and lineGap set its gaps (kept while Block is off; out of range is an error) (none given: query) → the first object's options, plus wordGap and lineGap, overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", has_selection, area_options ), @@ -304,11 +304,13 @@ fn ruby(s: &mut Session, p: &Value) -> Result { /// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing /// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type -/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo -/// step as the edit. +/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text, and Dynamic Text's Block +/// (`dynamic_text` on area type, [`block::block_fit`]) sizes each line to the frame: part of the +/// same undo step as the edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { // Edits that changed the paragraph count keep one paragraph style per paragraph. t.normalize_paras(); + block::block_fit(t); auto_height(t); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); @@ -320,6 +322,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { /// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again. pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { t.normalize_paras(); + block::block_fit(t); auto_height(t); let stamp = vectorcraft_text::LayoutStamp::now(); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); @@ -1026,7 +1029,7 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje } /// Area type's text, if `n` is area type. -fn area_text(n: Option<&Node>) -> Option<&TextObject> { +pub(crate) fn area_text(n: Option<&Node>) -> Option<&TextObject> { match n.map(|n| &n.kind) { Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t), _ => None, @@ -1059,10 +1062,15 @@ fn size_area(t: &mut TextObject, w: Option, h: Option) -> bool { reshape_area_with(t, |t| t.transform_area(a)) } -/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`, -/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent` -/// while the fit isn't Shrink Text). -pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result> { +/// A fit `p` asks for: the Area Type Options fit, and whether Dynamic Text's Block +/// ([`TextObject::dynamic_text`]) is on. Block's fit is [`AreaFit::None`]: it replaces Auto Size +/// and Shrink Text to Fit. +pub(crate) type FitChoice = (AreaFit, bool); + +/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText|block or `{"shrinkText": {"minPercent"}}`, +/// `fitMinPercent`), starting from `cur` (`block`: Block is on). None when `p` sets neither (or only +/// `fitMinPercent` while the fit isn't Shrink Text). +pub(crate) fn fit_param(p: &Value, cur: AreaFit, block: bool, c: &str) -> Result> { check_numbers(p, &["fitMinPercent"], c)?; let min = p.get("fitMinPercent").and_then(Value::as_f64); let cur_min = match cur { @@ -1071,21 +1079,48 @@ pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result match (cur, min) { - (AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m }, + (AreaFit::ShrinkText { .. }, Some(m)) if !block => AreaFit::ShrinkText { min_percent: m }, _ => return Ok(None), }, + Some(Value::String(id)) if is_block_id(id) => return Ok(Some((AreaFit::None, true))), Some(Value::String(id)) => { - AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))? + AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText|block, got {id}")))? } Some(v) => match serde_json::from_value::(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? { AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) }, f => f, }, }; - Ok(Some(match fit { - AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) }, - f => f, - })) + Ok(Some(( + match fit { + AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) }, + f => f, + }, + false, + ))) +} + +/// Whether a `fit` id names Dynamic Text's Block (case, spaces, dashes and underscores ignored). +fn is_block_id(id: &str) -> bool { + matches!(id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str(), "block" | "dynamictext") +} + +/// The `fit` id area type reports: `block` while Dynamic Text's Block is on, else its fit's. +pub(crate) fn fit_id(t: &TextObject) -> &'static str { + if t.dynamic_text { "block" } else { t.area.fit.id() } +} + +/// Dynamic Text's Block gaps `p` gives: `wordGap` (% of a word space, 0..1000) and `lineGap` (pt, +/// -1296..1296). A value that is not a number, or is outside its range, is an error. +pub(crate) fn block_gaps(p: &Value, c: &str) -> Result<(Option, Option)> { + let gap = |key: &str, (lo, hi): (f64, f64)| -> Result> { + match number_given(p, key, c)? { + Some(v) if !(v.is_finite() && (lo..=hi).contains(&v)) => Err(bad(c, format!("`{key}` must be from {lo} to {hi}, not {v}"))), + v => Ok(v), + } + }; + use vectorcraft_doc::AreaOptions as O; + Ok((gap("wordGap", O::WORD_GAP)?, gap("lineGap", O::LINE_GAP)?)) } /// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as @@ -1095,7 +1130,7 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); if let Some(o) = v.as_object_mut() { - o.insert("fit".into(), json!(t.area.fit.id())); + o.insert("fit".into(), json!(fit_id(t))); o.insert("flow".into(), json!(t.area.flow)); // The gutter between rows, the gutter's when the rows have none of their own. o.insert("rowGutter".into(), json!(t.area.row_gutter.unwrap_or(t.area.gutter))); @@ -1104,6 +1139,9 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { _ => AreaFit::DEFAULT_MIN_PERCENT, }; o.insert("fitMinPercent".into(), json!(min)); + let (word_gap, line_gap) = t.area.block_gaps(); + o.insert("wordGap".into(), json!(word_gap)); + o.insert("lineGap".into(), json!(line_gap)); if let Some((w, h, _)) = area_size(t) { o.insert("width".into(), json!(w)); o.insert("height".into(), json!(h)); @@ -1132,8 +1170,9 @@ fn area_options(s: &mut Session, p: &Value) -> Result { // Checked once up front (its errors don't depend on the frame); each frame resolves `fit` // against its own setting below. let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default(); - fit_param(p, cur.fit, C)?; - let fit_given = p.get("fit").is_some_and(|v| !v.is_null()) || p.get("fitMinPercent").is_some_and(|v| !v.is_null()); + fit_param(p, cur.fit, false, C)?; + let (word_gap, line_gap) = block_gaps(p, C)?; + let fit_given = ["fit", "fitMinPercent", "wordGap", "lineGap"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())); let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0)); let (w, h) = (size("width"), size("height")); let given: Vec<(&String, &Value)> = p.as_object().into_iter().flatten().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())).collect(); @@ -1142,7 +1181,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result { } // Each area type takes the options given over its own (#3759): setting one option on several // frames leaves their other options as they were. - let merged = |own: &vectorcraft_doc::AreaOptions| -> Result { + let merged = |own: &vectorcraft_doc::AreaOptions, block: bool| -> Result<(vectorcraft_doc::AreaOptions, bool)> { let mut v = serde_json::to_value(own).map_err(|e| EngineError::Other(e.to_string()))?; if let Some(o) = v.as_object_mut() { for (k, val) in &given { @@ -1162,17 +1201,29 @@ fn area_options(s: &mut Session, p: &Value) -> Result { opts.inset = finite(opts.inset).clamp(0.0, 10_000.0); opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0); // Each frame's fit from its own (`fitMinPercent` alone changes only Shrink Text frames). - opts.fit = fit_param(p, own.fit, C)?.unwrap_or(own.fit); - Ok(opts) + let (fit, block) = fit_param(p, own.fit, block, C)?.unwrap_or((own.fit, block)); + opts.fit = fit; + opts.word_gap = word_gap.unwrap_or(own.word_gap); + opts.line_gap = line_gap.unwrap_or(own.line_gap); + Ok((opts, block)) }; let d = &s.doc()?.doc; - let planned: Vec<(NodeId, vectorcraft_doc::AreaOptions)> = ids + let planned: Vec<(NodeId, (vectorcraft_doc::AreaOptions, bool))> = ids .iter() - .filter_map(|id| area_text(d.node(*id)).map(|t| (*id, &t.area))) - .map(|(id, own)| merged(own).map(|o| (id, o))) + .filter_map(|id| area_text(d.node(*id)).map(|t| (*id, t))) + .map(|(id, t)| merged(&t.area, t.dynamic_text).map(|o| (id, o))) .collect::>()?; - let apply = |t: &mut TextObject, opts: &vectorcraft_doc::AreaOptions| { + // Dynamic Text's Block is refused for threaded frames, as `type.dynamicText` refuses it. + let frames: Vec = + planned.iter().filter(|(id, (_, block))| *block && !super::threads::story_of(d, *id).is_empty()).map(|(id, _)| id.0.to_string()).collect(); + if !frames.is_empty() { + let frames = frames.join(", "); + return Err(bad(C, format!("area type in a thread cannot use Dynamic Text: {frames}"))); + } + let apply = |t: &mut TextObject, (opts, block): &(vectorcraft_doc::AreaOptions, bool)| { t.area = opts.clone(); + // Block off keeps the sizes it wrote into the characters. + t.dynamic_text = *block; // Auto Size sets the height (Illustrator's dialog turns the Height field off). let h = if opts.fit == AreaFit::AutoHeight { None } else { h }; // A resized frame has laid its text out again; otherwise the new options do here. @@ -1313,6 +1364,9 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod area_tests; +mod block; +#[cfg(test)] +mod block_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs new file mode 100644 index 0000000000..06453f4983 --- /dev/null +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -0,0 +1,304 @@ +//! Dynamic Text's Block for area type ([`TextObject::dynamic_text`] on area type): each line as +//! typed is set to the frame's width by changing its font size, after every edit +//! ([`super::refresh_bounds`]). + +use vectorcraft_doc::{TextKind, TextObject, TextRun}; + +/// The font size range a line's first character is fitted within, in document points (the +/// Character panel's). +const SIZES: (f64, f64) = (0.1, 1296.0); + +/// Most passes a refit runs to correct lines that still wrap after the first fit; each pass measures +/// the lines and runs at most one more area layout. +pub(crate) const BLOCK_PASSES: usize = 4; + +/// One line as typed: `start..content` is its text, `content..end` the `\n` or U+2028 that ends it +/// (empty for the last line). `para` is the index of its paragraph, and `para_start` whether it +/// starts that paragraph. +#[derive(Clone, Copy, Debug)] +struct TypedLine { + start: usize, + content: usize, + end: usize, + para: usize, + para_start: bool, +} + +/// The lines as typed: the text split after each `\n` and U+2028. +fn typed_lines(text: &str) -> Vec { + let mut out = vec![]; + let (mut start, mut para, mut para_start) = (0, 0, true); + for (i, c) in text.char_indices() { + if c == '\n' || c == '\u{2028}' { + let end = i + c.len_utf8(); + out.push(TypedLine { start, content: i, end, para, para_start }); + start = end; + para_start = c == '\n'; + if c == '\n' { + para += 1; + } + } + } + out.push(TypedLine { start, content: text.len(), end: text.len(), para, para_start }); + out +} + +/// The stored size of the character at `byte` (the run that holds it, not the one before it). +pub(crate) fn size_at(runs: &[TextRun], byte: usize) -> f64 { + let mut off = 0; + for r in runs { + let end = off + r.text.len(); + if (off..end).contains(&byte) { + return r.style.size; + } + off = end; + } + vectorcraft_text::edit::style_at(runs, byte).size +} + +/// The stored size of each line's first character, in one pass over the runs. +fn first_sizes(runs: &[TextRun], lines: &[TypedLine]) -> Vec { + let mut out = Vec::with_capacity(lines.len()); + let (mut ri, mut off) = (0, 0); + for l in lines { + while let Some(r) = runs.get(ri) { + if l.start < off + r.text.len() { + break; + } + off += r.text.len(); + ri += 1; + } + out.push(runs.get(ri).map_or_else(|| size_at(runs, l.start), |r| r.style.size)); + } + out +} + +/// Each line's content width (trailing spaces left out) by its first byte, with `t` laid out as +/// point type, where no line wraps, with the word spacing `t`'s layout uses. +fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collections::HashMap { + let mut probe = t.clone(); + for p in probe.para_styles_mut() { + p.justification = t.layout_justification(p); + } + probe.kind = TextKind::Point; + let laid = vectorcraft_text::layout(db, &probe); + let mut out = std::collections::HashMap::with_capacity(laid.lines.len()); + for m in &laid.lines { + out.entry(m.start).or_insert(m.x1 - m.x0); + } + out +} + +/// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing +/// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down +/// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its +/// size rounded down to tenths, and None when that is its size, so fitting again changes nothing. +/// Room taken entirely by indents gives the smallest size. +fn refit(size: f64, width: f64, avail: f64, slope: f64) -> Option { + if width <= avail + 1e-6 && width + 0.1 * slope > avail { + let rounded = tenths(size); + return ((rounded - size).abs() > 1e-9).then_some(rounded); + } + Some(tenths(size + (avail.max(0.0) - width) / slope)) +} + +/// A size in document points rounded down to a whole number of tenths and kept within [`SIZES`]. +fn tenths(points: f64) -> f64 { + ((points * 10.0 + 1e-6).floor() / 10.0).clamp(SIZES.0, SIZES.1) +} + +/// Fit area type with Dynamic Text (Block) on: with the word gap as every paragraph's Desired word +/// spacing ([`TextObject::layout_justification`]; the paragraphs keep their own values), each line +/// as typed gets the size (its first character's, in steps of 0.1 pt within 0.1 to +/// 1,296 pt, the line's other characters keeping their proportions to it) at which its width +/// equals the frame's width less the inset, the gutters and the paragraph's indents, and each +/// character's leading is its size plus the line gap. An empty line or one of only spaces keeps +/// its size. Nothing changes when the frame has no width along the lines or the type's transform +/// has no size. A line that still wraps at its first fit is made smaller, in at most +/// [`BLOCK_PASSES`] more passes. True when the text changed. +pub(crate) fn block_fit(t: &mut TextObject) -> bool { + if !t.dynamic_text { + return false; + } + let TextKind::Area { frame } = &t.kind else { return false }; + let (_, line_gap) = t.area.block_gaps(); + let Some(b) = frame.bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return false }; + let Some(points) = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12) else { return false }; + let before = t.runs.clone(); + + // The room along a line in one cell of the frame, before indents. + let a = &t.area; + let (along, cells, gutter) = if t.vertical { (b.height(), a.rows, a.row_gutter.unwrap_or(a.gutter)) } else { (b.width(), a.columns, a.gutter) }; + let cells = cells.max(1) as f64; + let cell = (along - 2.0 * a.inset.max(0.0) - gutter.max(0.0) * (cells - 1.0)) / cells; + if !(cell.is_finite() && cell > 1e-9) { + return t.runs != before; + } + + let db = vectorcraft_text::FontDb::global(); + let text = t.plain_text(); + let lines = typed_lines(&text); + let widths = line_widths(db, t); + let sizes = first_sizes(&t.runs, &lines); + // Per line: (factor applied to its sizes, the room it fills, the last size measured and its + // width), or None to keep its size. + let mut fits: Vec> = lines + .iter() + .zip(&sizes) + .map(|(l, size)| { + if l.content == l.start { + return None; + } + let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; + let para = t.para_at(l.para); + let first = if l.para_start { para.first_line_indent } else { 0.0 }; + let avail = cell - para.left_indent - para.right_indent - first; + let size = size * points; + if !(size.is_finite() && size > 0.0) { + return None; + } + Some((refit(size, width, avail, width / size).map_or(1.0, |n| n / size), avail, size, width)) + }) + .collect(); + + let gap = line_gap / points; + let min_leading = SIZES.0 / points; + // Always from the sizes the refit started with, so each pass sets a line's size once: the runs + // are split where the lines end and each piece scaled by its line's factor, in one pass. + let apply = |t: &mut TextObject, from: &[TextRun], fits: &[Option<(f64, f64, f64, f64)>]| { + let mut out = Vec::with_capacity(from.len() + lines.len()); + let (mut off, mut li) = (0, 0); + for r in from { + let end = off + r.text.len(); + let mut at = off; + loop { + while lines.get(li).is_some_and(|l| l.end <= at && li + 1 < lines.len()) { + li += 1; + } + let stop = lines.get(li).map_or(end, |l| l.end.clamp(at, end)); + let stop = if stop == at { end } else { stop }; + let f = fits.get(li).copied().flatten().map_or(1.0, |(f, ..)| f); + let mut piece = TextRun { text: r.text.get(at - off..stop - off).unwrap_or_default().to_string(), ..r.clone() }; + if at > off { + piece.style.glyph_alternate = None; + } + let st = &mut piece.style; + st.size *= f; + st.baseline_shift *= f; + st.tcy_up_down *= f; + st.tcy_left_right *= f; + st.leading = Some((st.size + gap).max(min_leading)); + if let Some(art) = &mut piece.inline { + art.baseline_shift *= f; + } + out.push(piece); + at = stop; + if at >= end { + break; + } + } + off = end; + } + vectorcraft_text::edit::normalize(&mut out); + t.runs = out; + }; + let start = t.runs.clone(); + apply(t, &start, &fits); + + // Each pass measures the lines again: a line whose width did not follow its size (a tab to a + // fixed stop) is fitted again from its measured width, and when every line measures right, a + // line the frame's shape or a list marker still wraps is made smaller, by what the line the + // layout gave it holds. + // Per line: the smallest size measured too wide, which a later pass never goes back to. + let mut too_wide = vec![f64::INFINITY; lines.len()]; + for _ in 0..BLOCK_PASSES { + let now_widths = line_widths(db, t); + let mut again = false; + for (((l, fit), size), wide) in lines.iter().zip(fits.iter_mut()).zip(&sizes).zip(too_wide.iter_mut()) { + let Some((f, avail, last_size, last_width)) = fit else { continue }; + let size = size * points; + let now = size * *f; + let Some(width) = now_widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9) else { continue }; + if width > *avail + 1e-6 { + *wide = wide.min(now); + } + // The slope between the last two sizes measured, where they differ: a line whose width + // has a part that stays as the size changes is fitted in one step. + let slope = Some((width - *last_width) / (now - *last_size)) + .filter(|s| (now - *last_size).abs() > 1e-9 && s.is_finite() && *s > 1e-9) + .unwrap_or(width / now); + (*last_size, *last_width) = (now, width); + let next = refit(now, width, *avail, slope).map(|n| if n >= *wide - 1e-9 { tenths(*wide - 0.1) } else { n }); + if let Some(next) = next.filter(|n| (n - now).abs() > 1e-9) { + *f = next / size; + again = true; + } + } + if !again { + let laid = vectorcraft_text::layout(db, t); + // Per typed line: how many laid-out lines it took, and the room of its first. + let mut parts: Vec<(usize, f64)> = vec![(0, 0.0); lines.len()]; + for m in &laid.lines { + let i = lines.partition_point(|l| l.start <= m.start).saturating_sub(1); + if let Some(p) = parts.get_mut(i) { + if p.0 == 0 { + p.1 = m.avail.1 - m.avail.0; + } + p.0 += 1; + } + } + for (((l, fit), (count, room)), size) in lines.iter().zip(fits.iter_mut()).zip(&parts).zip(&sizes) { + let Some((f, ..)) = fit else { continue }; + if *count < 2 { + continue; + } + let size = size * points; + let now = size * *f; + if now <= SIZES.0 + 1e-9 { + continue; + } + let width = now_widths.get(&l.start).copied().unwrap_or(f64::INFINITY); + let smaller = if room.is_finite() && *room > 0.0 && *room < width { tenths(now * room / width) } else { now }; + *f = smaller.min(tenths(now - 0.1)) / size; + again = true; + } + } + if !again { + break; + } + apply(t, &start, &fits); + } + t.runs != before +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_lines_split_after_returns_and_forced_breaks() { + let l = typed_lines("ab\ncd\u{2028}e\n"); + let spans: Vec<_> = l.iter().map(|l| (l.start, l.content, l.end, l.para, l.para_start)).collect(); + assert_eq!(spans, vec![(0, 2, 3, 0, true), (3, 5, 8, 1, true), (8, 9, 10, 1, false), (10, 10, 10, 2, true)]); + assert_eq!(typed_lines("").len(), 1); + } + + #[test] + fn a_fitting_line_keeps_its_size_in_tenths() { + // 100 pt wide at 12 pt, 100.5 pt of room: a tenth larger would not fit. + assert_eq!(refit(12.0, 100.0, 100.5, 100.0 / 12.0), None); + assert_eq!(refit(12.34, 100.0, 100.5, 100.0 / 12.34), Some(12.3), "a size between tenths is rounded down"); + // Room for twice the width: twice the size. + assert_eq!(refit(12.0, 100.0, 200.0, 100.0 / 12.0), Some(24.0)); + // A part of the width that stays (a slope below width / size) needs more size. + assert_eq!(refit(12.0, 100.0, 150.0, 5.0), Some(22.0)); + } + + #[test] + fn sizes_round_down_to_tenths_within_the_range() { + assert_eq!(tenths(23.99), 23.9); + assert_eq!(tenths(24.0 - 1e-9), 24.0); + assert_eq!(tenths(0.01), 0.1); + assert_eq!(tenths(5000.0), 1296.0); + } +} diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs new file mode 100644 index 0000000000..2dda356146 --- /dev/null +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -0,0 +1,425 @@ +//! Dynamic Text's Block for area type: `type.dynamicText`, Area Type Options › Block and the refit +//! after edits. + +use serde_json::{Value, json}; +use vectorcraft_doc::{AreaFit, NodeId, NodeKind, TextKind, TextObject}; + +use crate::Session; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn area(s: &mut Session, w: f64, h: f64, text: &str) -> u64 { + let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + id +} + +fn text(s: &Session, id: u64) -> TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The size of the character at `byte`. +fn size_at(t: &TextObject, byte: usize) -> f64 { + super::block::size_at(&t.runs, byte) +} + +/// Each laid-out line's (start byte, content width, room): the room is the frame's span less the +/// indents. +fn lines(t: &TextObject) -> Vec<(usize, f64, f64)> { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.lines.iter().map(|l| (l.start, l.x1 - l.x0, l.avail.1 - l.avail.0)).collect() +} + +/// Every typed line is one laid-out line whose width is within one 0.1 pt size step of its room. +fn assert_block(t: &TextObject) { + let typed: Vec = std::iter::once(0).chain(t.plain_text().match_indices(['\n', '\u{2028}']).map(|(i, c)| i + c.len())).collect(); + let laid = lines(t); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), typed, "one laid-out line per typed line: {laid:?}"); + for &(start, width, room) in &laid { + let size = size_at(t, start); + if width <= 1e-9 || size >= 1296.0 - 1e-9 || size <= 0.1 + 1e-9 { + continue; + } + assert!(width <= room + 0.01, "line at {start} is wider than its room: {width} > {room}"); + assert!(width >= room * (size - 0.1) / size - 0.01, "line at {start} at {size} pt is short of its room: {width} < {room}"); + } +} + +#[test] +fn block_sets_each_typed_line_to_the_frame_width_by_its_size() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line\nmid one"); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r, json!({"on": true, "ids": [id], "fitScale": 1.0, "wordGap": 100.0, "lineGap": 0.0})); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(t.area.fit, AreaFit::None); + assert_block(&t); + let (big, long, mid) = (size_at(&t, 0), size_at(&t, 4), size_at(&t, 31)); + assert!(big > mid && mid > long, "a shorter line is set larger: {big} {mid} {long}"); + for size in [big, long, mid] { + assert!(((size * 10.0).round() - size * 10.0).abs() < 1e-6, "{size} is a whole number of tenths"); + } + // Each character's leading is its size (line gap 0). + assert!(t.runs.iter().all(|r| r.style.leading == Some(r.style.size)), "{:?}", t.runs); + // Inspect reports it. + let doc = s.execute("document.inspect", &json!({})).unwrap(); + let node = doc["layers"][0]["children"].as_array().unwrap().iter().find(|k| k["id"] == id).unwrap().clone(); + assert_eq!((node["dynamicText"].as_bool(), node["fit"].as_str()), (Some(true), Some("block"))); +} + +#[test] +fn block_keeps_the_proportions_inside_a_line_and_fits_again_after_edits() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "one two three\nfour"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 24})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_block(&t); + let ratio = size_at(&t, 0) / size_at(&t, 4); + assert!((ratio - 2.0).abs() < 0.02, "the first word stays twice the size of the rest: {ratio}"); + let four = size_at(&t, 14); + // Typing into the first line makes it smaller, in the edit's own undo step; the second line stays. + let first = size_at(&t, 4); + let n = undo_len(&s); + s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": " and more words"})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, id); + assert_block(&t); + assert!(size_at(&t, 4) < first, "{} < {first}", size_at(&t, 4)); + assert_eq!(size_at(&t, t.plain_text().find("four").unwrap()), four); + // A narrower frame sets every line smaller. + s.execute("text.areaOptions", &json!({"width": 150})).unwrap(); + let narrow = text(&s, id); + assert_block(&narrow); + let f = size_at(&narrow, narrow.plain_text().find("four").unwrap()) / four; + assert!((f - 0.5).abs() < 0.02, "half the width, half the size: {f}"); + // Undo brings the wider frame's sizes back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id).runs, t.runs); +} + +#[test] +fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() { + let mut s = session(); + // The tab goes to a stop that stays where it is as the size changes. + let id = area(&mut s, 300.0, 400.0, "Fit me\nand\tme too"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let fitted = text(&s, id).runs; + let laid = lines(&text(&s, id)); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), vec![0, 7], "{laid:?}"); + assert!(laid.iter().all(|l| l.1 <= l.2 + 0.01 && l.1 > l.2 * 0.95), "each line fills its room: {laid:?}"); + let n = undo_len(&s); + assert_eq!(s.execute("type.dynamicText", &json!({"on": true})).unwrap()["on"], true); + assert_eq!(undo_len(&s), n, "already on: no undo step"); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["on"], false); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, id); + assert!(!t.dynamic_text); + assert_eq!(t.runs, fitted, "off keeps the sizes it was drawn at"); + // Edits no longer fit the lines. + s.execute("text.areaOptions", &json!({"width": 100})).unwrap(); + assert_eq!(text(&s, id).runs, fitted); + assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["on"], false); + assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); +} + +#[test] +fn bad_params_and_other_type_are_refused() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me"); + for bad in [ + json!({"on": "yes"}), + json!({"on": 1}), + json!({"wordGap": "wide"}), + json!({"wordGap": -1}), + json!({"wordGap": 1001}), + json!({"lineGap": 1297}), + json!({"lineGap": -1297}), + json!({"lineGap": true}), + ] { + assert!(s.execute("type.dynamicText", &bad).is_err(), "{bad}"); + } + assert!(!text(&s, id).dynamic_text); + let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [point]})).unwrap(); + let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string(); + assert!(e.contains("select type on a path or area type"), "{e}"); + // With point type selected too, only the area type changes. + s.execute("select.set", &json!({"ids": [id, point]})).unwrap(); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([id])); + assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text); + // A gap with only type on a path selected is an error, and nothing changes. + let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap(); + let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [along]})).unwrap(); + let n = undo_len(&s); + for gap in [json!({"wordGap": 300}), json!({"on": false, "lineGap": 2})] { + let e = s.execute("type.dynamicText", &gap).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{e}"); + } + assert_eq!(undo_len(&s), n); + assert!(!text(&s, along).dynamic_text); +} + +#[test] +fn gaps_set_word_spacing_and_leading() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "two words\nthree more words"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let plain = text(&s, id); + let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 6})).unwrap(); + assert_eq!((r["on"].as_bool(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some(true), Some(300.0), Some(6.0))); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!((t.area.word_gap, t.area.line_gap), (300.0, 6.0)); + // The layout uses the gap as the Desired word spacing; the paragraph keeps its own. + assert_eq!(t.para_at(0).justification.word_spacing, [80.0, 100.0, 133.0]); + assert_eq!(t.layout_justification(t.para_at(0)).word_spacing, [80.0, 300.0, 300.0]); + assert!(t.runs.iter().all(|r| (r.style.leading.unwrap() - r.style.size - 6.0).abs() < 1e-9), "{:?}", t.runs); + assert_block(&t); + assert!(size_at(&t, 0) < size_at(&plain, 0), "wider word spaces leave the letters less room"); + // (own, used by the layout) word spacing of the first paragraph. + let spacing = |s: &Session| { + let t = text(s, id); + (t.para_at(0).justification.word_spacing, t.layout_justification(t.para_at(0)).word_spacing) + }; + // Minimum and Maximum move out only as far as the current gap needs, and the paragraph's own + // values apply again when Block is off. + s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 1000.0, 1000.0])); + s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0])); + s.execute("type.dynamicText", &json!({"wordGap": 0})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [0.0, 0.0, 133.0])); + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0])); + // A paragraph's own values, outside the default range and equal to Desired too, stay through + // gap changes and refits after edits. + s.execute("text.setFormat", &json!({"wordSpacing": [20, 20, 150]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true, "wordGap": 20})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0])); + s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 1000.0, 1000.0])); + s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap(); + s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 0, "insert": "a "})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 100.0, 150.0])); + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0])); + // A gap alone on a frame without Block turns it on. + let other = area(&mut s, 200.0, 200.0, "x y"); + let r = s.execute("type.dynamicText", &json!({"lineGap": -2})).unwrap(); + assert_eq!(r["on"], true); + let t = text(&s, other); + assert!(t.dynamic_text); + assert_eq!((t.area.word_gap, t.area.line_gap), (100.0, -2.0)); +} + +#[test] +fn area_type_options_offer_block_with_its_gaps() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too"); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["wordGap"].as_f64(), q["lineGap"].as_f64()), (Some(100.0), Some(0.0))); + // Gaps alone are kept for Block, without turning it on or changing the text. + let (n, runs) = (undo_len(&s), text(&s, id).runs); + s.execute("text.areaOptions", &json!({"wordGap": 200})).unwrap(); + let t = text(&s, id); + assert_eq!((undo_len(&s), t.dynamic_text, t.area.word_gap), (n + 1, false, 200.0)); + assert_eq!(t.runs, runs); + let r = s.execute("text.areaOptions", &json!({"fit": "block", "lineGap": 4})).unwrap(); + assert_eq!((r["fit"].as_str(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some("block"), Some(200.0), Some(4.0))); + assert!(text(&s, id).dynamic_text); + assert_block(&text(&s, id)); + // The reply passed back (as the dialog does) changes nothing; naming Block again keeps the gaps. + let n = undo_len(&s); + assert_eq!(s.execute("text.areaOptions", &r).unwrap(), r); + s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap(); + assert_eq!(undo_len(&s), n); + s.execute("text.areaOptions", &json!({"lineGap": 2})).unwrap(); + let t = text(&s, id); + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 200.0, 2.0)); + assert!(s.execute("text.areaOptions", &json!({"lineGap": 5000})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"wordGap": 2000})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"wordGap": "wide"})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"fit": "squash"})).unwrap_err().to_string().contains("shrinkText|block")); + // Shrink Text to Fit replaces Block, keeping the sizes. + let runs = text(&s, id).runs; + s.execute("text.areaOptions", &json!({"fit": "shrinkText"})).unwrap(); + let t = text(&s, id); + assert!(!t.dynamic_text); + assert_eq!(t.runs, runs); + // Block replaces Shrink Text to Fit and Auto Size. + s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap(); + assert_eq!(text(&s, id).area.fit, AreaFit::None); + // New area type can start with it. + let made = s + .execute( + "text.create", + &json!({"x": 10, "y": 300, "size": 12, "area": {"width": 200, "height": 200, "fit": "block", "wordGap": 150}, "text": "made"}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let t = text(&s, made); + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 150.0, 0.0)); + assert_block(&t); +} + +#[test] +fn block_round_trips_through_the_native_format() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too"); + s.execute("type.dynamicText", &json!({"wordGap": 120, "lineGap": 3})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let bytes = vectorcraft_format::save(&doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 120.0, 3.0)); + assert_eq!(t.runs, text(&s, id).runs, "the fitted sizes are saved in the characters"); + // Saved as `dynamicText` with the gaps beside the area options; missing gaps read as their + // defaults, which are left out when saved. + let saved = serde_json::to_value(&**t).unwrap(); + assert_eq!((&saved["dynamicText"], &saved["area"]["wordGap"], &saved["area"]["lineGap"]), (&json!(true), &json!(120.0), &json!(3.0))); + let plain = serde_json::from_value::(json!({})).unwrap(); + assert_eq!((plain.word_gap, plain.line_gap), (100.0, 0.0)); + let plain: Value = serde_json::to_value(&plain).unwrap(); + assert!(plain.get("wordGap").is_none() && plain.get("lineGap").is_none(), "{plain}"); + // Turned off, it opens off with the sizes it had. + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(!t.dynamic_text); + assert_eq!(t.runs, text(&s, id).runs); +} + +#[test] +fn edge_cases_fit_without_failing() { + let mut s = session(); + // Empty text, an empty line and a line of spaces keep their sizes. + let empty = area(&mut s, 300.0, 400.0, ""); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, empty), 0), 12.0); + let gaps = area(&mut s, 300.0, 400.0, "top\n\n \nbottom line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, gaps); + assert_eq!((size_at(&t, 4), size_at(&t, 5)), (12.0, 12.0)); + assert_block(&t); + // One word. + let word = area(&mut s, 300.0, 400.0, "Word"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_block(&text(&s, word)); + // A wide frame stops at 1,296 pt; a very long line in a narrow frame stops at 0.1 pt and wraps. + let wide = area(&mut s, 100_000.0, 400.0, "i"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, wide), 0), 1296.0); + let narrow = area(&mut s, 1.0, 400.0, &"long words ".repeat(40)); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, narrow); + assert_eq!(size_at(&t, 0), 0.1); + assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "at 0.1 pt the line still does not fit"); + // A frame inset so far that it has no width keeps the sizes. + let inset = area(&mut s, 50.0, 400.0, "Inset"); + s.execute("text.areaOptions", &json!({"inset": 30})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, inset), 0), 12.0); +} + +#[test] +fn block_fits_columns_lists_vertical_and_right_to_left_type() { + let mut s = session(); + // Two columns: each line is set to a column's width. + let cols = area(&mut s, 400.0, 600.0, "left column\nsecond"); + s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 20})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, cols); + assert_block(&t); + assert!(lines(&t).iter().all(|l| (l.2 - 190.0).abs() < 1e-6), "{:?}", lines(&t)); + // A bulleted list: each item stays one line, the marker and its tab taking their room before + // the text (the room the layout reports takes in the marker's too). + let list = area(&mut s, 300.0, 400.0, "first item\nsecond item here"); + s.execute("text.setFormat", &json!({"list": "bullet"})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, list); + let laid = lines(&t); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), vec![0, 11], "{laid:?}"); + assert!(laid.iter().all(|l| l.1 <= l.2), "{laid:?}"); + assert!(size_at(&t, 0) > size_at(&t, 11), "the shorter item is larger"); + // Right-to-left text. + let rtl = area(&mut s, 300.0, 400.0, "שלום עולם\nטקסט ארוך יותר בשורה"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_block(&text(&s, rtl)); + // Vertical type: each column of text is set to the frame's height. + let v = s + .execute( + "text.create", + &json!({"x": 40, "y": 40, "size": 12, "vertical": true, "area": {"width": 300, "height": 200}, "text": "縦書き\nもっと長い縦書きの行"}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [v]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, v); + assert!(t.vertical); + assert_block(&t); + assert!(lines(&t).iter().all(|l| (l.2 - 200.0).abs() < 1e-6), "{:?}", lines(&t)); +} + +#[test] +fn threaded_frames_are_refused_and_threading_turns_block_off() { + let mut s = session(); + let a = area(&mut s, 200.0, 300.0, "Fit me\nand me too"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let fitted = text(&s, a).runs; + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 40, "width": 200, "height": 300})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let n = undo_len(&s); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, a); + assert!(!t.dynamic_text, "threading turns Block off"); + assert_eq!(t.runs, fitted, "the text keeps its sizes"); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let e = s.execute("type.dynamicText", &json!({"on": true})).unwrap_err().to_string(); + assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}"); + let runs = text(&s, a).runs; + let e = s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap_err().to_string(); + assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}"); + assert_eq!(text(&s, a).runs, runs, "Area Type Options refuses it too, changing nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s, a).dynamic_text, "undo brings it back with the thread gone"); + assert!(matches!(text(&s, a).kind, TextKind::Area { .. })); +} + +#[test] +fn long_text_fits_in_a_bounded_number_of_layouts() { + let mut s = session(); + let story: String = (0..300).map(|i| format!("line number {i} {}\n", "x".repeat(i % 17))).collect(); + let id = area(&mut s, 300.0, 100_000.0, &story); + let before = vectorcraft_text::test_fonts::layouts_on_this_thread(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before; + assert_block(&text(&s, id)); + // The command fits the object to compare and to apply (each a measure, at most two layouts a + // pass and the caches), plus the reply's own. + let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4; + assert!(used <= bound, "{used} layouts"); +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index deaac8acc4..faaaf2145d 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -1,13 +1,13 @@ //! Type menu long tail (Change Case, Smart Punctuation, Convert To Area/Point Type, Type on a Path -//! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text -//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). +//! Options, Dynamic Text, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) +//! and the text commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). use serde_json::{Value, json}; -use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; -use super::typecmd::refresh_bounds; +use super::typecmd::{area_text, block_gaps, refresh_bounds}; use super::{ CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, switch_or, targets, text_param, @@ -65,10 +65,19 @@ pub fn specs() -> Vec { "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool} turn Dynamic Text on or off for the selected type on a path (on omitted: the opposite of the first object's setting). On, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. Off, the text keeps the sizes it was drawn at. Point type and area type in the selection are left as they are; a selection holding type on a path in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When every object already has the setting, nothing changes and no undo step is recorded → {on, ids, fitScale (the first object's factor, 1 when off)}", - has_free_path_type, + "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", + has_dynamic_type, dynamic_text ), + cmd!( + "type.dynamicTextPreset", + "Dynamic Text Presets", + ["Type", "Dynamic Text Presets"], + None, + "{preset: \"circle\"|\"arch\"|\"bow\", position?: \"above\"|\"center\"|\"below\", flip?: bool, ids?|id?} put the selected point type and type on a path on a new path of the preset's shape with Dynamic Text on, so the text is sized to fill it (see type.dynamicText). Circle: a circle as wide as the text, the text running clockwise round all of it from its bottom; Arch: an arc of a circle bulging upward over 120°, as wide as the text; Bow: the same arc bulging downward. The width is, for point type, the length on the page of the longer side of the text's bounds, and for type on a path the longer side of its path's bounds on the page (so the same preset again leaves it as it is); the new path is centered where they were, and the shape is upright on the page when the object is rotated or scaled. position (omitted: above) sets Type on a Path Options › Align to Path: above stands the type on the path (descender), center centers it on the path (center), below hangs it from the path (ascender); flip true then turns the text to the other side of its path, running the other way. Type on a Path Options › effect and spacing stay. Area type in the selection is left as it is; a selection holding type on a path in a thread is refused. When nothing changes, no undo step is recorded → {ids, fitScale (the first object's factor)}", + has_preset_type, + dynamic_text_preset + ), cmd!( "type.fillPlaceholder", "Fill with Placeholder Text", @@ -520,26 +529,40 @@ fn path_options(s: &mut Session, p: &Value) -> Result { options(s) } -/// Whether type on a path `id` is a frame of a thread. +/// Whether type `id` is a frame of a thread. fn threaded(d: &Document, id: NodeId) -> bool { !super::threads::story_of(d, id).is_empty() } -/// `type.dynamicText` is enabled for a selection holding type on a path, none of it in a thread. -fn has_free_path_type(s: &Session) -> std::result::Result<(), String> { +/// Type on a path or area type: the type Dynamic Text works on. +fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> { + path_text(n).or_else(|| area_text(n)) +} + +/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it +/// in a thread. +fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; let d = &st.doc; let selected: Vec = st.selection.objects.to_vec(); - let paths: Vec = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect(); - if paths.is_empty() { - return Err("select type on a path".into()); + let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect(); + if types.is_empty() { + return Err("select type on a path or area type".into()); } + threaded_refusal(d, &types).map_or(Ok(()), Err) +} + +/// Why Dynamic Text is refused for `ids`: the frames among them that are in a thread, type on a +/// path first; None when none is. +fn threaded_refusal(d: &Document, ids: &[NodeId]) -> Option { + let (paths, areas): (Vec, Vec) = ids.iter().copied().partition(|id| path_text(d.node(*id)).is_some()); let frames = threaded_ids(d, &paths); if !frames.is_empty() { - return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + return Some(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); } - Ok(()) + let frames = threaded_ids(d, &areas); + (!frames.is_empty()).then(|| format!("area type in a thread cannot use Dynamic Text: {frames}")) } /// The ids among `ids` of frames of threads, as a list for a message; empty when there are none. @@ -547,32 +570,60 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::>().join(", ") } -/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting) on the -/// selected type on a path, in one undo step, and none when every object already has it. Turning -/// it off keeps the sizes the text was drawn at ([`bake_dynamic_text`]). +/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on +/// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in +/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn +/// at ([`bake_dynamic_text`]). fn dynamic_text(s: &mut Session, p: &Value) -> Result { const C: &str = "type.dynamicText"; - let (ids, on, changing) = { + let (word_gap, line_gap) = block_gaps(p, C)?; + let (ids, on, planned) = { let d = &s.doc()?.doc; - let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect(); - let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?; - let frames = threaded_ids(d, &ids); - if !frames.is_empty() { - return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); + let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect(); + let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?; + if let Some(why) = threaded_refusal(d, &ids) { + return Err(bad(C, why)); } - let on = switch_or(p, "on", !path_text(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; - let changing: Vec = ids.iter().copied().filter(|id| path_text(d.node(*id)).is_some_and(|t| t.dynamic_text != on)).collect(); - (ids, on, changing) + let gap_given = word_gap.is_some() || line_gap.is_some(); + if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) { + return Err(bad(C, "wordGap and lineGap need area type in the selection")); + } + let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; + // Each object as the command leaves it, and only those it changes. + let mut planned: Vec<(NodeId, TextObject)> = vec![]; + for id in &ids { + let Some(t) = dynamic_type(d.node(*id)) else { continue }; + let mut after = t.clone(); + if matches!(t.kind, TextKind::Area { .. }) { + after.area.word_gap = word_gap.unwrap_or(t.area.word_gap); + after.area.line_gap = line_gap.unwrap_or(t.area.line_gap); + } + match (on, t.dynamic_text) { + (true, true) => refresh_bounds(&mut after), + (true, false) => { + after.dynamic_text = true; + // Block replaces Auto Size and Shrink Text to Fit. + if matches!(t.kind, TextKind::Area { .. }) { + after.area.fit = AreaFit::None; + } + refresh_bounds(&mut after); + } + (false, true) => { + bake_dynamic_text(&mut after); + } + (false, false) => {} + } + if after != *t { + planned.push((*id, after)); + } + } + (ids, on, planned) }; - if !changing.is_empty() { + if !planned.is_empty() { s.edit("Dynamic Text", |d, _| { - for id in &changing { - let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - if on { - t.dynamic_text = true; - refresh_bounds(t); - } else { - bake_dynamic_text(t); + for (id, after) in &planned { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + **t = after.clone(); } } Ok(()) @@ -581,7 +632,13 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let fit = ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale); - Ok(json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) + let mut reply = json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit}); + if let Some(t) = ids.iter().find_map(|id| area_text(d.node(*id))) { + let (w, l) = t.area.block_gaps(); + reply["wordGap"] = json!(w); + reply["lineGap"] = json!(l); + } + Ok(reply) } /// Turn Dynamic Text off on `t`, keeping the sizes its layout draws it at: every run's size, @@ -600,6 +657,196 @@ pub(crate) fn bake_dynamic_text(t: &mut TextObject) -> bool { true } +/// The shapes of `type.dynamicTextPreset`. +const PRESETS: [&str; 3] = ["circle", "arch", "bow"]; + +/// Point type and type on a path among `ids` and their descendants: the type +/// `type.dynamicTextPreset` puts on a path. +fn preset_ids(d: &Document, ids: &[NodeId]) -> Vec { + text_ids(d, ids) + .into_iter() + .filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point | TextKind::OnPath { .. }))) + .collect() +} + +/// `type.dynamicTextPreset` is enabled for a selection holding point type or type on a path, and +/// no type on a path in a thread. +fn has_preset_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let ids = preset_ids(d, &st.selection.objects); + if ids.is_empty() { + return Err("select point type or type on a path".into()); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + } + Ok(()) +} + +/// The path of Dynamic Text preset `preset` (one of [`PRESETS`]) `w` wide, centered on `c`, with +/// the start bracket the text starts at. Circle: a circle `w` across, run clockwise from its +/// bottom; Arch and Bow: a 120° arc of a circle whose chord is `w`, bulging up or down, run from +/// left to right. +fn preset_path(preset: &str, c: vectorcraft_geom::Point, w: f64) -> (vectorcraft_geom::PathData, f64) { + let h = w / 2.0; + if preset == "circle" { + // `shapes::ellipse` runs clockwise on screen from its left: its bottom is 3/4 of the way. + return (shapes::ellipse(Rect::new(c.x - h, c.y - h, c.x + h, c.y + h)), 0.75); + } + // A chord of `w` under 120°: radius w/√3, and the arc rises a half radius above its chord. + let r = w / 3f64.sqrt(); + let arch = preset == "arch"; + let cy = if arch { c.y - r / 4.0 + r } else { c.y + r / 4.0 - r }; + let circle = Rect::new(c.x - r, cy - r, c.x + r, cy + r); + // `shapes::ellipse_arc` runs counterclockwise on screen: right to left over the top. + let mut path = if arch { + shapes::ellipse_arc(circle, 30.0, 150.0, shapes::EllipseMode::Arc) + } else { + shapes::ellipse_arc(circle, 210.0, 330.0, shapes::EllipseMode::Arc) + }; + if arch { + path.reverse(); + } + (path, 0.0) +} + +/// `t` (point type or type on a path) on the path of `preset` with Dynamic Text on, Align to Path +/// `align`, and flipped when `flip`. The shape is made upright on the page and mapped back through +/// the object's transform, so a rotated or scaled object still shows a circle, an arch or a bow. +/// Point type: the path is as wide as the longer side of the text's layout bounds is long on the +/// page, centered on them; with no layout area (empty text), four ems of its first character +/// wide, centered on its estimated bounds. Type on a path: as wide as the longer side of its +/// path's bounds on the page, centered on them, so the same preset again gives the same path. +/// When the object's transform can't be undone, or undoing it gives a path that isn't finite, the +/// shape is made in the text's own space. +fn with_preset(t: &TextObject, preset: &str, align: vectorcraft_doc::PathAlign, flip: bool) -> TextObject { + let invertible = t.xf.is_finite() && t.xf.determinant() != 0.0 && t.xf.inverse().is_finite(); + // The transform the shape was made upright through, with the shape. + let ((path, start, w), xf) = invertible + .then(|| preset_shape(t, preset, t.xf).map(|s| (s, t.xf))) + .flatten() + .or_else(|| preset_shape(t, preset, Affine::IDENTITY).map(|s| (s, Affine::IDENTITY))) + .unwrap_or_else(|| { + let (path, start) = preset_path(preset, vectorcraft_geom::Point::ZERO, 48.0); + ((path, start, 48.0), Affine::IDENTITY) + }); + let mut out = t.clone(); + out.kind = TextKind::OnPath { path, start, end: None }; + out.path_align = align; + out.dynamic_text = true; + if flip { + out.flip_on_path(); + } + // The same preset again measures the path it made, which can differ in the last bits: a path + // within a billionth of its width of the old one on the page keeps the old one, so nothing + // changes. + if let (TextKind::OnPath { path: old, .. }, TextKind::OnPath { path: new, .. }) = (&t.kind, &mut out.kind) + && same_path(&old.transformed(xf), &new.transformed(xf), w * 1e-9) + { + *new = old.clone(); + } + refresh_bounds(&mut out); + out +} + +/// The path of `preset` for `t` made upright through transform `xf` (see [`with_preset`]), in the +/// text's space, with its start bracket and its width on the page; None when the path isn't +/// finite. +fn preset_shape(t: &TextObject, preset: &str, xf: Affine) -> Option<(vectorcraft_geom::PathData, f64, f64)> { + // How long `(x, y)` in the text's space is on the page. + let on_page = |x: f64, y: f64| (xf * vectorcraft_geom::Point::new(x, y) - xf * vectorcraft_geom::Point::ZERO).hypot(); + // The center on the page, and the width measured (0 when nothing can be measured). + let (c, longer) = match &t.kind { + TextKind::OnPath { path, .. } => path + .transformed(xf) + .bounds() + .filter(|b| b.is_finite()) + .map_or((xf * vectorcraft_geom::Point::ZERO, 0.0), |b| (b.center(), b.width().max(b.height()))), + _ => { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + match Some(lay).filter(|b| b.area() > 0.0 && b.is_finite()) { + Some(b) => (xf * b.center(), on_page(b.width(), 0.0).max(on_page(0.0, b.height()))), + None => (xf * Some(t.estimate_bounds()).filter(|b| b.is_finite()).map_or(vectorcraft_geom::Point::ZERO, |b| b.center()), 0.0), + } + } + }; + // Without a measured width: four ems of the first character, else 48 pt. + let size = t.first_style().size; + let w = if longer.is_finite() && longer > 1e-6 { + longer + } else if size.is_finite() && size > 0.0 && on_page(size * 4.0, 0.0) > 1e-6 { + on_page(size * 4.0, 0.0) + } else { + 48.0 + }; + let (path, start) = preset_path(preset, c, w); + let path = path.transformed(xf.inverse()); + let finite = path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()); + (finite && w.is_finite()).then_some((path, start, w)) +} + +/// Whether paths `a` and `b` have the same subpaths, closed alike, with every anchor and handle +/// within `eps` of each other. +fn same_path(a: &vectorcraft_geom::PathData, b: &vectorcraft_geom::PathData, eps: f64) -> bool { + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= eps; + a.subpaths.len() == b.subpaths.len() + && a.subpaths.iter().zip(&b.subpaths).all(|(s, t)| { + s.closed == t.closed + && s.anchors.len() == t.anchors.len() + && s.anchors.iter().zip(&t.anchors).all(|(x, y)| x.kind == y.kind && near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) + }) +} + +/// Type › Dynamic Text Presets: put the selected point type and type on a path on a new path of +/// the preset's shape with Dynamic Text on, in one undo step, and none when nothing changes. +fn dynamic_text_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.dynamicTextPreset"; + let key = "preset"; + let preset = choice(p, C, key, &PRESETS)?.ok_or_else(|| bad(C, format!("`{key}` must be one of {}", PRESETS.join(", "))))?; + let align = match choice(p, C, "position", &["above", "center", "below"])? { + Some("center") => vectorcraft_doc::PathAlign::Center, + Some("below") => vectorcraft_doc::PathAlign::Ascender, + _ => vectorcraft_doc::PathAlign::Descender, + }; + let flip = switch_or(p, "flip", false, C)?; + let (ids, changed) = { + let d = &s.doc()?.doc; + let ids = preset_ids(d, &targets(s, p)?); + if ids.is_empty() { + return Err(bad(C, "select point type or type on a path")); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); + } + let changed: Vec<(NodeId, TextObject)> = ids + .iter() + .filter_map(|id| match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some((*id, with_preset(t, preset, align, flip))).filter(|(_, new)| new != &**t), + _ => None, + }) + .collect(); + (ids, changed) + }; + if !changed.is_empty() { + s.edit("Dynamic Text", |d, _| { + for (id, new) in &changed { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + **t = new.clone(); + } + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let fit = + ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) +} + // ---------- placeholder / insert ---------- /// VectorCraft's own placeholder copy (not the classical Latin text). diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 8d0d2b8eee..95b44776f0 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -25,10 +25,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo /// `(command, param)` pairs the journal records only so a replay reproduces the original run /// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the /// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the -/// Artboard tool had selected for its commands; the artboards and art Cut and Copy took with that +/// Artboard tool had selected for its commands and for Paste in Place, in Front and in Back; the artboards and art Cut and Copy took with that /// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out /// ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 26] = [ +const REPLAY_ONLY: [(&str, &str); 29] = [ ("file.new", "created"), ("edit.newDocumentFromState", "created"), ("document.save", "modified"), @@ -36,6 +36,9 @@ const REPLAY_ONLY: [(&str, &str); 26] = [ ("edit.pasteInPlace", "artboard"), ("edit.pasteInFront", "artboard"), ("edit.pasteInBack", "artboard"), + ("edit.pasteInPlace", "artboards"), + ("edit.pasteInFront", "artboards"), + ("edit.pasteInBack", "artboards"), ("object.align", "artboard"), ("object.distribute", "artboard"), ("object.distributeSpacing", "artboard"), diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 27ebc9c40d..73c8ba8efe 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -463,7 +463,10 @@ impl ScriptScope { let real = match std::fs::canonicalize(path) { Ok(real) => real, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + if std::fs::symlink_metadata(path).is_ok() || !self.missing_inside(path) { + return Err(ScriptRead::Refused); + } + return Err(ScriptRead::Missing); } Err(e) => return Err(ScriptRead::Failed(e.to_string())), }; @@ -473,6 +476,18 @@ impl ScriptScope { Ok(real) } + /// Is the missing `path` inside a granted folder: does the nearest folder above it that exists + /// lie inside one? On a file system where case matters, `MAIN/x` beside the granted `main` is + /// outside, whether `MAIN` exists or not. A link above it that leads to nothing is outside too, + /// so a script cannot tell whether the folder a link leads to exists. + fn missing_inside(&self, path: &Path) -> bool { + path.ancestors() + .skip(1) + .find(|a| std::fs::symlink_metadata(a).is_ok()) + .and_then(|a| std::fs::canonicalize(a).ok()) + .is_some_and(|real| self.roots.iter().any(|r| inside(&real, r))) + } + /// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it. pub(crate) fn stat(&self, path: &Path) -> Result { let real = self.locate(path)?; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 347f6734e7..9bad62a6c8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -99,14 +99,15 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> NodeKind::Text(t) => { v["text"] = json!(t.plain_text()); // Area type and type on a path: does the text fit (the red overflow "+")? Area type - // also reports its fit and Shrink Text's factor (1 unshrunk), and type on a path its - // Dynamic Text setting and factor (1 when off). + // also reports its fit (`block` for Dynamic Text's Block) and Shrink Text's factor (1 + // unshrunk), and type on a path its factor (1 when off); both report Dynamic Text. if !matches!(t.kind, vectorcraft_doc::TextKind::Point) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); v["overflow"] = json!(lay.overflow); if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) { - v["fit"] = json!(t.area.fit.id()); + v["fit"] = json!(crate::cmd::typecmd::fit_id(t)); v["fitScale"] = json!(lay.fit_scale); + v["dynamicText"] = json!(t.dynamic_text); } else { v["dynamicText"] = json!(t.dynamic_text); v["fitScale"] = json!(lay.fit_scale); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7d71e8bfea..745a6fb62c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -254,6 +254,8 @@ pub struct Session { pub recovery: cmd::recovery::Recovery, /// The running script's job, while there is one: the mutation lease ([`script`]). pub(crate) script_key: Option, + /// The named script engines this session keeps ([`script`]). + pub(crate) script_engines: script::EnginesKey, /// Stops the running script at its next instruction; the same flag for every run. pub(crate) script_stop: vectorcraft_js::Interrupt, /// The report of the run that ended last. @@ -276,6 +278,9 @@ pub struct Session { pub(crate) in_tool_actions: bool, /// While [`Session::preview`] runs its command: its edits are the interaction's own. previewing: bool, + /// The images Remove Background decoded during the interaction's preview steps, emptied when + /// it ends ([`cmd::DecodedImages`]). + pub(crate) decoded_images: cmd::DecodedImages, /// Envelope Options with no envelope selected: the options and fidelity new envelopes get /// (`None`: the reference app's defaults, fidelity 50); not saved. pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, @@ -343,6 +348,15 @@ pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> { FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +/// Whether the tests that run every command with `{}` run command `id`. `plugin.clearCache` is +/// left out: without an id it empties the cache of every plug-in installed in the process, which +/// includes the caches that the plug-in storage tests check while they run. +/// `tests/plugin_clear_cache.rs` runs it with `{}` in a test binary of its own. +#[cfg(test)] +pub(crate) fn runs_with_empty_params(id: &str) -> bool { + id != "plugin.clearCache" +} + #[cfg(test)] use tests::{ adjust as tests_adjust, clip as tests_clip, colormgmt as tests_colormgmt, expand as tests_expand, large_edits as tests_large_edits, diff --git a/crates/engine/src/script/dialogs.rs b/crates/engine/src/script/dialogs.rs index 1faf624f16..a674396f4b 100644 --- a/crates/engine/src/script/dialogs.rs +++ b/crates/engine/src/script/dialogs.rs @@ -290,12 +290,12 @@ impl Dialogs { } } -/// Adds `alert`, `confirm` and `prompt` to the realm. -pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { +/// Adds `alert`, `confirm` and `prompt` to the realm, for the run `link` holds. +pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> { use Arg::{Bool, Text, TextOrUndefined}; let call = |kind: DialogKind| -> HostFunction { - let h = host.clone(); - Rc::new(move |args| open(&h, kind, &args)) + let l = link.clone(); + Rc::new(move |args| open(&*super::current(&l)?, kind, &args)) }; realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?; realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?; diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs index 3e6b005844..fcf6dbf542 100644 --- a/crates/engine/src/script/dollar.rs +++ b/crates/engine/src/script/dollar.rs @@ -4,14 +4,15 @@ //! the locale the global `localize` reads //! (). //! -//! Every value here belongs to one run: a run starts with the locale of the interface, the -//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero, -//! and what a script changes ends with its run. `$.setenv` never changes the process's environment. +//! Every value here belongs to one run, in a named engine too ([`RunState`]): a run starts with the +//! locale of the interface, the environment values of [`ENV_NAMES`] read from the process, +//! localization off and a timer at zero, and what a script changes ends with its run. `$.setenv` +//! never changes the process's environment. //! //! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and -//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is -//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0 -//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the +//! `$.version` give VectorCraft's version, `$.engineName` is the name `#targetengine` gave, or +//! `VectorCraft` without one, and `$.buildDate` is `null`; `$.flags` takes only 512, which turns +//! localization on; `$.level` and `$.memCache` are 0 and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the //! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no //! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own //! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most @@ -25,7 +26,7 @@ use std::time::Duration; use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem}; -use super::{Host, function}; +use super::{Link, current, function}; /// The longest `$.sleep`, in milliseconds. pub const MAX_SLEEP_MS: f64 = 1_000.0; @@ -36,12 +37,34 @@ pub const MAX_ENV_BYTES: usize = 64; /// The value of `$.flags` that turns localization on, the only one a script may set. pub const LOCALIZE_FLAG: f64 = 512.0; -/// What `$` knows of a run. +/// What `$` knows of its realm. pub(crate) struct RunInfo { - /// The locale the run starts with, such as `de_DE`. - pub locale: String, /// The realm reads scripts as strict mode code. pub strict: bool, + /// The name `#targetengine` gave the realm's engine. + pub engine: Option, +} + +/// The values of `$` that belong to one run, which the run's host holds. +pub(crate) struct RunState { + /// The locale the run starts with, such as `de_DE`. + locale: String, + env: RefCell)>>, + /// The time since the run started at which `$.hiresTimer` was last read. + timer: Cell, +} + +impl RunState { + pub(crate) fn new(locale: &str) -> RunState { + RunState { locale: locale.to_string(), env: RefCell::new(process_env()), timer: Cell::new(Duration::ZERO) } + } +} + +/// Sets `localization` as a run starts: `locale`, this system's platform and localization off. +pub(super) fn start_run(localization: &Localization, locale: &str) { + localization.set_enabled(false); + localization.set_locale(locale); + localization.set_platform(platform()); } /// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language, @@ -148,14 +171,10 @@ fn text(s: impl Into) -> Data { Data::String(s.into()) } -/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. -pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { +/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. They +/// act for the run that `link` holds when they are called. +pub(super) fn members(link: &Link, info: RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let version = env!("CARGO_PKG_VERSION"); - let host_locale = info.locale.clone(); - localization.set_locale(&info.locale); - localization.set_platform(platform()); - let env = Rc::new(RefCell::new(process_env())); - let timer = Rc::new(Cell::new(Duration::ZERO)); let getter = |f: Rc Data>| HostItem::Getter(function(move |_| Ok(f()))); let property = |get: Rc Data>, set: Rc Result<(), HostError>>| HostItem::Property { get: function(move |_| Ok(get())), @@ -167,13 +186,13 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc host_locale.clone(), - Data::String(t) if t.is_empty() => host_locale.clone(), + Data::Null | Data::Undefined => current(&run)?.dollar.locale.clone(), + Data::String(t) if t.is_empty() => current(&run)?.dollar.locale.clone(), Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?, _ => return Err(HostError::Type(BAD_LOCALE.to_string())), }; @@ -224,31 +243,34 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc, info: RunInfo, localization: &Rc *n, Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))), @@ -282,7 +305,7 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc, - next: u32, temps: u32, bytes: usize, } @@ -94,7 +98,7 @@ impl Disk { pub(crate) fn new(scope: &ScriptScope) -> Self { let home = home_folder(); let start = scope.folder().map_or_else(|| home.clone(), |p| p.to_string_lossy().into_owned()); - Self { scope: scope.clone(), home, start, open: HashMap::new(), next: 1, temps: 0, bytes: 0 } + Self { scope: scope.clone(), home, start, open: HashMap::new(), temps: 0, bytes: 0 } } /// The path `text` names from the folder `base`, a platform path; `text`'s URI escapes are @@ -161,8 +165,10 @@ impl Disk { } self.bytes += bytes.len(); let file = Open::new(bytes, encoding).ok_or_else(|| HostError::Error(UNDECODABLE.to_string()))?; - let id = self.next; - self.next = self.next.wrapping_add(1).max(1); + let mut id = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + while id == 0 || self.open.contains_key(&id) { + id = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + } self.open.insert(id, file); Ok(id) } @@ -183,11 +189,15 @@ fn home_folder() -> String { } /// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none. +/// On the web every one is `null`, and no platform folder is looked up. fn locations(home: &str) -> Data { + if cfg!(target_arch = "wasm32") { + return Data::Object(PLACES.iter().map(|k| (k.to_string(), Data::Null)).collect()); + } let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned(); let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned())); - let temp = std::env::temp_dir().to_string_lossy().into_owned(); + let temp = temp_folder().to_string_lossy().into_owned(); let (user_data, app_data, common, system, trash) = if WINDOWS { (env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None) } else if cfg!(target_os = "macos") { @@ -203,7 +213,6 @@ fn locations(home: &str) -> Data { (Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash"))) }; let s = |v: Option| v.map_or(Data::Null, Data::String); - let web = cfg!(target_arch = "wasm32"); let places = vec![ ("appData", app_data), ("appPackage", exe.clone()), @@ -216,7 +225,15 @@ fn locations(home: &str) -> Data { ("trash", trash), ("userData", user_data), ]; - Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect()) + Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), s(v))).collect()) +} + +/// The names [`locations`] gives. +const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"]; + +/// The folder for temporary files: the platform's, or on the web the root, where nothing is read. +fn temp_folder() -> std::path::PathBuf { + if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/") } else { std::env::temp_dir() } } /// The start of an absolute path. @@ -343,12 +360,12 @@ impl Pathname { } } -/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its +/// `s` with every character but letters, digits and `/-_.!~*'()` written as `%xx` escapes of its /// UTF-8 bytes (RFC 2396). fn encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { - if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) { + if b.is_ascii_alphanumeric() || b"/-_.!~*'()".contains(&b) { out.push(b as char); } else { out.push_str(&format!("%{b:02X}")); @@ -638,10 +655,11 @@ fn millis(t: Option) -> Data { t.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(Data::Null, |d| Data::Number(d.as_millis() as f64)) } -/// One host function of `__vcFiles`, counted and charged as a host call. -fn call(host: &Rc, f: fn(&Host, &[Data]) -> Result) -> HostItem { - let h = host.clone(); +/// One host function of `__vcFiles`, counted and charged as a host call of the run `link` holds. +fn call(link: &super::Link, f: fn(&Host, &[Data]) -> Result) -> HostItem { + let l = link.clone(); HostItem::Function(Rc::new(move |a| { + let h = super::current(&l)?; bump(&h.counts.file_calls); let r = f(&h, &a)?; h.charge_call(super::data_size(&r).saturating_add(a.iter().map(super::data_size).sum()))?; @@ -835,14 +853,39 @@ fn temp_name(host: &Host, _: &[Data]) -> Result { let mut disk = host.disk.borrow_mut(); disk.temps += 1; let name = format!("vc{:05}.tmp", disk.temps); - Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned())) + Ok(Data::String(temp_folder().join(name).to_string_lossy().into_owned())) } -/// Adds `File` and `Folder` to the realm. -pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { - let (start, home) = { - let disk = host.disk.borrow(); - (disk.pathname(&disk.start, &disk.start, false).fs(), disk.home.clone()) +fn locations_of(host: &Host, _: &[Data]) -> Result { + Ok(locations(&host.disk.borrow().home)) +} + +fn encode_text(_: &Host, a: &[Data]) -> Result { + Ok(Data::String(encode(&arg_text(a, 0)))) +} + +fn decode_text(_: &Host, a: &[Data]) -> Result { + Ok(Data::String(decode(&arg_text(a, 0)))) +} + +fn encoding_available(_: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(Encoding::parse(&arg_text(a, 0)).is_some())) +} + +/// The folder `Folder.current` starts at in the run `host` serves: the script's own folder. +fn start_folder(host: &Host, _: &[Data]) -> Result { + let disk = host.disk.borrow(); + Ok(Data::String(disk.pathname(&disk.start, &disk.start, false).fs())) +} + +/// Adds `File` and `Folder` to the realm, their host functions acting for the run `link` holds. +pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> { + let unchecked = |f: fn(&Host, &[Data]) -> Result| { + let l = link.clone(); + HostItem::Function(Rc::new(move |a| { + let h = super::current(&l)?; + f(&h, &a) + })) }; let value = |s: &str| HostItem::Value(Data::String(s.to_string())); let text = HostItem::Object(vec![ @@ -851,7 +894,6 @@ pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError ("noPicker".into(), value(NO_PICKER)), ("badMode".into(), value(BAD_MODE)), ]); - let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0))))); let system = if WINDOWS { "Windows" } else if cfg!(target_os = "macos") { @@ -863,28 +905,28 @@ pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError ("text".into(), text), ("system".into(), value(system)), ("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })), - ("current".into(), HostItem::Function(Rc::new(move |_| Ok(Data::String(start.clone()))))), - ("locations".into(), HostItem::Function(Rc::new(move |_| Ok(locations(&home))))), - ("encode".into(), pure(|s| Data::String(encode(s)))), - ("decode".into(), pure(|s| Data::String(decode(s)))), - ("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))), - ("parse".into(), call(host, parse)), - ("relative".into(), call(host, relative)), - ("tempName".into(), call(host, temp_name)), - ("stat".into(), call(host, stat)), - ("open".into(), call(host, open)), - ("close".into(), call(host, close)), - ("read".into(), call(host, read)), - ("readln".into(), call(host, readln)), - ("readch".into(), call(host, readch)), - ("seek".into(), call(host, seek)), - ("tell".into(), call(host, tell)), - ("eof".into(), call(host, eof)), - ("encoding".into(), call(host, encoding)), - ("setEncoding".into(), call(host, set_encoding)), - ("list".into(), call(host, list)), - ("resolve".into(), call(host, resolve)), - ("execute".into(), call(host, execute)), + ("current".into(), unchecked(start_folder)), + ("locations".into(), call(link, locations_of)), + ("encode".into(), call(link, encode_text)), + ("decode".into(), call(link, decode_text)), + ("encodingAvailable".into(), call(link, encoding_available)), + ("parse".into(), call(link, parse)), + ("relative".into(), call(link, relative)), + ("tempName".into(), call(link, temp_name)), + ("stat".into(), call(link, stat)), + ("open".into(), call(link, open)), + ("close".into(), call(link, close)), + ("read".into(), call(link, read)), + ("readln".into(), call(link, readln)), + ("readch".into(), call(link, readch)), + ("seek".into(), call(link, seek)), + ("tell".into(), call(link, tell)), + ("eof".into(), call(link, eof)), + ("encoding".into(), call(link, encoding)), + ("setEncoding".into(), call(link, set_encoding)), + ("list".into(), call(link, list)), + ("resolve".into(), call(link, resolve)), + ("execute".into(), call(link, execute)), ]; realm.define_file_classes(items) } @@ -935,7 +977,7 @@ mod tests { #[test] fn uri_escapes_are_utf8_both_ways() { assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201"); - assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb"); + assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()/b"); assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x"); assert_eq!(decode("100%"), "100%"); assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8"); diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index 6cf0c60bef..ce2e1d6783 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -16,8 +16,13 @@ //! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list. //! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a //! `JSINCLUDE` folder outside the scope, which is passed over. -//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as -//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment. +//! - `#target` takes any value and changes nothing. `#engine` is refused as unsupported, and any +//! other `#name` as unknown; `//@name` with another name stays a comment. +//! - `#targetengine name` names the engine the script runs in ([`Expanded::engine`]). Only the +//! script itself may have one, at most once, and only before its first code or `#include` line: +//! blanks, comments and the other preprocessor lines may come before it. The name is an unquoted +//! name of ASCII letters, digits and `_` that does not start with a digit, or a quoted one of 1 to +//! [`MAX_ENGINE_NAME_BYTES`] bytes without control characters, compared as written. //! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks //! for the whole run; without one they are off. A write to a property that has only a getter is //! checked as a read-only one, the objects that take no new properties are those that are not @@ -50,6 +55,8 @@ pub const MAX_TOTAL_BYTES: usize = 32 << 20; pub const MAX_PROBES: usize = 1024; /// The longest value a preprocessor line may have, in bytes. const MAX_VALUE_BYTES: usize = 4096; +/// The longest name `#targetengine` may give, in bytes of UTF-8. +pub const MAX_ENGINE_NAME_BYTES: usize = 64; /// Where the lines of an expanded source came from: file 0 is the file expanded, the others the /// files it included. @@ -107,12 +114,78 @@ pub(crate) struct Failure { } /// A source with its preprocessor lines read: the text to run, where its lines came from, the -/// name its `#script` line gave, and what its last `#strict` line set (`None` without one). +/// name its `#script` line gave, what its last `#strict` line set (`None` without one), and the +/// engine its `#targetengine` line named, with that line's line and column. pub(crate) struct Expanded { pub source: String, pub map: SourceMap, pub name: Option, pub strict: Option, + pub engine: Option<(String, u32, u32)>, +} + +/// What the preprocessor lines of a file have named so far: its `#script` name and its engine, and +/// whether code or an `#include` line has come before. +#[derive(Default)] +struct Named { + name: Option, + engine: Option<(String, u32, u32)>, + code_seen: bool, +} + +/// Are `units` only blanks and comments? +fn only_blanks_and_comments(units: &[u16]) -> bool { + let mut i = 0; + while let Some(&u) = units.get(i) { + match u { + 0x2F if units.get(i + 1) == Some(&0x2F) => { + while units.get(i).is_some_and(|&c| !matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029)) { + i += 1; + } + } + 0x2F if units.get(i + 1) == Some(&0x2A) => { + let Some(end) = units.get(i + 2..).and_then(|rest| rest.windows(2).position(|w| w == [0x2A, 0x2F])) else { return false }; + i += end + 4; + } + 0xFEFF => i += 1, + u if char::from_u32(u32::from(u)).is_some_and(char::is_whitespace) => i += 1, + _ => return false, + } + } + true +} + +/// The engine name of a `#targetengine` line's value `value` (as written, quotes included), or what +/// is wrong with it. +fn engine_name(value: &str) -> Result { + let name = match value.chars().next() { + None => return Err("needs a name"), + Some(q @ ('"' | '\'')) => { + let inner = value.get(1..).and_then(|v| v.strip_suffix(q)).ok_or("needs a closing quote after the name")?; + if inner.contains(q) { + return Err("takes one name"); + } + if inner.chars().any(char::is_control) { + return Err("needs a name without control characters"); + } + inner + } + _ => { + let mut chars = value.chars(); + let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_'); + if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err("needs a name of letters, digits and _, or a quoted name"); + } + value + } + }; + if name.is_empty() { + return Err("needs a name"); + } + if name.len() > MAX_ENGINE_NAME_BYTES { + return Err("needs a name of at most 64 bytes"); + } + Ok(name.to_string()) } /// A file being run: its name for people and the folder its relative paths start from (none for a @@ -347,8 +420,8 @@ impl Files { /// its `#script` line names the run. fn expand(&mut self, shown: Arc, dir: Option, text: &str, script: bool) -> Result { let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None }; - let mut name = None; - self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?; + let mut named = Named::default(); + self.file(&mut out, shown, dir.as_deref(), text, script, &mut named)?; let source = String::from_utf16(&out.units).map_err(|_| Failure { kind: FailKind::Syntax, message: "the script is not valid text".into(), @@ -357,20 +430,12 @@ impl Files { line: 0, column: 0, })?; - Ok(Expanded { source, map: out.map, name, strict: out.strict }) + Ok(Expanded { source, map: out.map, name: named.name, strict: out.strict, engine: named.engine }) } /// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place /// of their lines. - fn file( - &mut self, - out: &mut Out, - shown: Arc, - dir: Option<&Path>, - text: &str, - script: bool, - name: &mut Option, - ) -> Result<(), Failure> { + fn file(&mut self, out: &mut Out, shown: Arc, dir: Option<&Path>, text: &str, script: bool, named: &mut Named) -> Result<(), Failure> { let index = out.map.files.len(); out.map.files.push(shown.clone()); let units: Vec = text.encode_utf16().collect(); @@ -390,7 +455,11 @@ impl Files { units.get(..start).and_then(|u| u.iter().rposition(|&c| matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029))).map_or(0, |i| i + 1); let column = u32::try_from(start - line_start + 1).unwrap_or(u32::MAX); let at = |kind, message: String| fail(kind, message, d.line, column); - out.emit(units.get(cursor..start).unwrap_or_default()); + let before = units.get(cursor..start).unwrap_or_default(); + if !named.code_seen && !only_blanks_and_comments(before) { + named.code_seen = true; + } + out.emit(before); cursor = end; let label = format!("{}{}", d.form, d.name); if d.value.len() > MAX_VALUE_BYTES { @@ -402,11 +471,12 @@ impl Files { if value.is_empty() { return Err(at(FailKind::Syntax, format!("{label} needs a file name"))); } + named.code_seen = true; let (real, bytes) = self.open(value, dir, true).map_err(|(kind, message)| at(kind, message))?; let included = self.admit(&real, bytes, value).map_err(|(kind, message)| at(kind, message))?; self.chain.push(real.clone()); self.levels += 1; - let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, name); + let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, named); self.chain.pop(); self.levels -= 1; r?; @@ -421,7 +491,7 @@ impl Files { return Err(at(FailKind::Syntax, format!("{label} needs a name"))); } if script && index == 0 { - *name = Some(value.to_string()); + named.name = Some(value.to_string()); } } Some(DirectiveName::Target) => { @@ -434,7 +504,19 @@ impl Files { "on" => out.strict = Some(true), _ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))), }, - Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))), + Some(DirectiveName::TargetEngine) => { + if !script || index > 0 { + return Err(at(FailKind::Syntax, format!("{label} may only be in the script itself, not in a file it runs"))); + } + if named.engine.is_some() { + return Err(at(FailKind::Syntax, format!("the script has more than one {label} line"))); + } + if named.code_seen { + return Err(at(FailKind::Syntax, format!("{label} must come before the script's code and #include lines"))); + } + let engine = engine_name(&d.value).map_err(|why| at(FailKind::Syntax, format!("{label} {why}")))?; + named.engine = Some((engine, d.line, column)); + } None if d.name.eq_ignore_ascii_case("engine") => { return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))); } diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 8d223f53c1..a8f0c18336 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -38,6 +38,13 @@ //! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. //! //! `File` and `Folder` read files inside the script's own folder ([`files`]). +//! +//! A script with `#targetengine name` runs in that named engine, which the session keeps with the +//! globals its runs leave, at most [`MAX_ENGINES`] of them with [`ENGINE_HEAP_BYTES`] of heap each +//! (VectorCraft's own limits). A script without the line, or with `main`, gets a new realm for each +//! run. An engine keeps no document and no run: its host functions reach the run through a link +//! that holds the run's host only while the run goes on. An error, a limit or a stop puts the +//! documents back and keeps the engine's globals; an internal error drops the engine. pub mod console; pub mod dialogs; @@ -196,6 +203,150 @@ fn with_job(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f)) } +/// The most named engines (`#targetengine`) a session keeps. +pub const MAX_ENGINES: usize = 8; +/// The heap a named engine may use, with what it keeps between runs. +pub const ENGINE_HEAP_BYTES: usize = 64 << 20; +/// The engine of a script without `#targetengine`, which `#targetengine main` also names: a new +/// one for each run, never kept. +pub const MAIN_ENGINE: &str = "main"; + +/// The run a script's host functions act for. A named engine keeps its functions between runs, so +/// they find the run through this link, which holds the run's [`Host`] only while the run goes on. +type Link = Rc>>>; + +/// The host of the run `link` holds, or an error when no run holds it. +fn current(link: &Link) -> std::result::Result, HostError> { + link.borrow().clone().ok_or_else(|| HostError::Error(NO_RUN.to_string())) +} + +/// A named engine: its interpreter, with the globals its runs left (`None` while a run has it), +/// the link its host functions use, and its heap after its last run. +struct Engine { + name: String, + realm: Option, + link: Link, + heap: usize, +} + +/// The named engines of one thread's sessions, by their key, each in the order they were made. +struct ThreadEngines { + thread: std::thread::ThreadId, + by_key: HashMap>, +} + +impl ThreadEngines { + fn new() -> Self { + let thread = std::thread::current().id(); + released().insert(thread, Vec::new()); + Self { thread, by_key: HashMap::new() } + } +} + +impl Drop for ThreadEngines { + fn drop(&mut self) { + released().remove(&self.thread); + } +} + +thread_local! { + /// The named engines of this thread's sessions. They are held here, and not in their session, + /// because they hold interpreters, which stay on this thread. + static ENGINES: RefCell = RefCell::new(ThreadEngines::new()); +} + +static NEXT_ENGINES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// For each thread that holds engines, the keys of the ones sessions let go of on another thread. +/// The thread drops them the next time it uses its engines; a thread that has ended has no entry, +/// since its engines ended with it. +static RELEASED_ENGINES: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +fn released() -> std::sync::MutexGuard<'static, HashMap>> { + RELEASED_ENGINES.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Lets the engines with key `id` go, for `thread`, which holds them, to drop. +fn release_engines(thread: std::thread::ThreadId, id: u64) { + if let Some(ids) = released().get_mut(&thread) { + ids.push(id); + } +} + +/// A session's hold on its named engines: their key, and the thread that holds them once a run +/// made one. Dropping it drops them. The engines stay on that thread, so a session used on another +/// thread lets them go and starts there with none. +#[derive(Debug)] +pub struct EnginesKey(std::sync::Mutex<(u64, Option)>); + +impl EnginesKey { + pub(crate) fn new() -> Self { + Self(std::sync::Mutex::new((NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), None))) + } + + /// The key of the session's engines on this thread. + fn here(&self) -> u64 { + let mut key = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let thread = std::thread::current().id(); + match key.1 { + Some(t) if t != thread => { + release_engines(t, key.0); + *key = (NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), Some(thread)); + } + Some(_) => {} + None => key.1 = Some(thread), + } + key.0 + } +} + +impl Drop for EnginesKey { + fn drop(&mut self) { + let (id, thread) = *self.0.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(t) = thread.filter(|t| *t != std::thread::current().id()) { + release_engines(t, id); + return; + } + let gone = ENGINES.try_with(|engines| engines.try_borrow_mut().ok().and_then(|mut e| e.by_key.remove(&id))); + drop(gone); + } +} + +fn with_engines(key: &EnginesKey, f: impl FnOnce(&mut Vec) -> T) -> T { + let id = key.here(); + ENGINES.with(|engines| { + let gone: Vec> = { + let mut here = engines.borrow_mut(); + let ids = released().get_mut(&here.thread).map(std::mem::take).unwrap_or_default(); + ids.iter().filter_map(|id| here.by_key.remove(id)).collect() + }; + drop(gone); + f(engines.borrow_mut().by_key.entry(id).or_default()) + }) +} + +/// Tests: the named engines this thread holds, for every session. +#[cfg(test)] +pub(crate) fn engines_on_this_thread() -> usize { + ENGINES.with(|engines| engines.borrow().by_key.values().map(Vec::len).sum()) +} + +/// Tests: whether engines can still be let go for `thread`, which holds an entry while it lives. +#[cfg(test)] +pub(crate) fn releases_kept_for(thread: std::thread::ThreadId) -> bool { + released().contains_key(&thread) +} + +/// The heap of a named engine. +fn engine_heap() -> usize { + #[cfg(test)] + if let Some(n) = TEST_ENGINE_HEAP.with(Cell::get) { + return n; + } + ENGINE_HEAP_BYTES +} + /// The state a script changes that a rollback puts back. struct World { docs: Vec, @@ -522,6 +673,10 @@ struct Host { can_wait: Rc>, /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). eval_strict: Cell>, + /// The values of `$` that belong to the run. + dollar: dollar::RunState, + /// The run's `args`, which `vc.args` gives, and their size as `vc.args` charges it. + args: RefCell<(Data, u64)>, } /// A run's host calls by kind, as its report gives them in `hostCallCounts`. @@ -704,6 +859,10 @@ impl Host { struct Job { realm: Realm, host: Rc, + /// The link the realm's host functions find `host` through. + link: Link, + /// The named engine the realm belongs to, which gets it back when the run ends. + engine: Option, /// Between slices the working copy; during the last slice's commit the state before the run. world: World, /// The documents open when the run began, by [`DocState::uid`]. @@ -1016,10 +1175,11 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) { /// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file /// being run and `$.includePath` the `#includepath` list. Its other members are /// [`dollar::members`]. -fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { +fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let writer = |end: bool| { - let h = host.clone(); + let l = link.clone(); HostItem::TextFunction(function(move |a| { + let h = current(&l)?; let text: String = a.iter().map(|d| if let Data::String(s) = d { s.as_str() } else { "" }).collect(); h.charge_call(text.len() as u64 + 2)?; bump(&h.counts.console_writes); @@ -1028,21 +1188,24 @@ fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc String| { - let h = host.clone(); + let l = link.clone(); HostItem::Getter(function(move |_| { + let h = current(&l)?; let text = read(&h.files.borrow()); h.charge_call(text.len() as u64 + 2)?; Ok(Data::String(text)) })) }; let eval_file = { - let h = host.clone(); - let end = host.clone(); - let strict = host.clone(); + let (l, end, strict) = (link.clone(), link.clone(), link.clone()); HostItem::Evaluator { - source: function(move |a| eval_file(&h, a)), - end: Rc::new(move || end.files.borrow_mut().end_eval()), - strict_writes: Rc::new(move || strict.eval_strict.get()), + source: function(move |a| eval_file(&*current(&l)?, a)), + end: Rc::new(move || { + if let Ok(h) = current(&end) { + h.files.borrow_mut().end_eval(); + } + }), + strict_writes: Rc::new(move || current(&strict).ok().and_then(|h| h.eval_strict.get())), } }; let mut items = vec![ @@ -1052,7 +1215,7 @@ fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> std::result::Result + 'sta Rc::new(f) } -fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { +/// The `vc` object, made once for each realm: its members act for the run `link` holds when they +/// are called. +fn vc_object(link: &Link) -> Vec<(String, HostItem)> { + let args = { + let l = link.clone(); + function(move |_| { + let h = current(&l)?; + let (args, size) = &*h.args.borrow(); + h.charge_call(*size)?; + Ok(args.clone()) + }) + }; let run = { - let h = host.clone(); - function(move |a| vc_run(&h, a)) + let l = link.clone(); + function(move |a| vc_run(¤t(&l)?, a)) }; let document = { - let h = host.clone(); + let l = link.clone(); function(move |_| { + let h = current(&l)?; h.charge_call(64)?; bump(&h.counts.document_reads); vc_document(&h) }) }; let commands = { - let h = host.clone(); + let l = link.clone(); let list = Rc::new(vc_commands()); let size = data_size(&list); function(move |_| { + let h = current(&l)?; h.charge_call(size)?; bump(&h.counts.command_list_reads); Ok((*list).clone()) }) }; let log = { - let h = host.clone(); + let l = link.clone(); function(move |a| { + let h = current(&l)?; h.charge_call(data_size(a.first().unwrap_or(&Data::Undefined)))?; bump(&h.counts.vc_log); vc_log(&h, a) @@ -1136,7 +1313,7 @@ fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { ("document".into(), HostItem::Getter(document)), ("commands".into(), HostItem::Getter(commands)), ("log".into(), HostItem::Function(log)), - ("args".into(), HostItem::Value(args)), + ("args".into(), HostItem::FrozenGetter(args)), ( "api".into(), HostItem::Object(vec![("version".into(), HostItem::Value(Data::String(API_VERSION.into()))), ("has".into(), HostItem::Function(has))]), @@ -1199,13 +1376,16 @@ fn located(host: &Host, tag: u32, mark: Option, e: &RunError) -> (RunError, if e.line == 0 { return (e.clone(), None); } + // An error in code from another source, such as a function an earlier run of a named engine + // defined, has no place in this run's files, so it is given without one. + let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None); match tag { 0 => map(&host.script_map.borrow(), true), n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) { Some(m) => map(m, false), - None => (e.clone(), None), + None => unplaced(), }, - _ => (e.clone(), None), + _ => unplaced(), } } @@ -1225,6 +1405,95 @@ impl Session { self.script_key.is_some() } + /// The named engine of the running script. + fn running_engine(&self) -> Option { + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.engine.clone())).flatten() + } + + /// The named engines (`#targetengine`) this session keeps, in the order they were made: each + /// one's name, its heap after its last run in bytes, and whether a run has it now. + pub fn script_engines(&self) -> Vec<(String, usize, bool)> { + with_engines(&self.script_engines, |engines| engines.iter().map(|e| (e.name.clone(), e.heap, e.realm.is_none())).collect()) + } + + /// Drops the named engine `name` and the globals its runs left; a later run that names it gets a + /// new one. + pub fn drop_script_engine(&mut self, name: &str) -> Result<()> { + if name == MAIN_ENGINE { + return Err(EngineError::Other(DROP_MAIN.to_string())); + } + if self.running_engine().as_deref() == Some(name) { + return Err(EngineError::Busy); + } + let gone = with_engines(&self.script_engines, |engines| { + let at = engines.iter().position(|e| e.name == name)?; + Some(engines.remove(at)) + }); + match gone { + Some(engine) => { + drop(engine); + Ok(()) + } + None => Err(EngineError::Other(format!("there is no script engine named {name:?}"))), + } + } + + /// The realm of the named engine `name` for a run, with the link its host functions use: the + /// realm its last run left, or `None` for an engine made now. → the kind and text of the + /// failure when the engine cannot be had. + fn take_engine(&mut self, name: &str) -> std::result::Result<(Option, Link), (include::FailKind, String)> { + if cfg!(target_arch = "wasm32") { + return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string())); + } + with_engines(&self.script_engines, |engines| { + if let Some(e) = engines.iter_mut().find(|e| e.name == name) { + let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?; + return Ok((Some(realm), e.link.clone())); + } + if engines.len() >= MAX_ENGINES { + return Err(( + include::FailKind::Limit, + format!("a session keeps at most {MAX_ENGINES} script engines; drop one with script.engine.drop"), + )); + } + let link: Link = Rc::new(RefCell::new(None)); + engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 }); + Ok((None, link)) + }) + } + + /// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer + /// holds the run, and the engine keeps `realm`, or is dropped without one. + fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option) { + let host = link.borrow_mut().take(); + drop(host); + let Some(name) = engine else { return }; + let mut realm = realm; + let heap = realm.as_mut().map(Realm::collect_garbage); + let gone = with_engines(&self.script_engines, |engines| { + let at = engines.iter().position(|e| e.name == name)?; + match (realm.take(), engines.get_mut(at)) { + (Some(r), Some(e)) => { + e.realm = Some(r); + e.heap = heap.unwrap_or(0); + None + } + _ => Some(engines.remove(at)), + } + }); + drop(gone); + drop(realm); + } + + /// The end of `job`: its engine gets its realm back, with a run that was stopped where it was + /// ended, unless the run ended with an internal error, which drops the engine. + fn end_job(&self, job: Job, internal: bool) { + let Job { mut realm, link, engine, host, .. } = job; + drop(host); + realm.end(); + self.return_engine(engine.as_deref(), &link, (!internal).then_some(realm)); + } + /// The flag that stops the script running in this session from any thread, at its next /// instruction. pub fn script_interrupt(&self) -> Interrupt { @@ -1337,6 +1606,13 @@ impl Session { let line = console::ConsoleLine { run: key.run, name: key.name.clone(), entry: console::Entry::End(ending) }; self.script_console.push(line, false); drop(key); + // The realm of the job's named engine went with the job, so the engine is dropped. + let lost: Vec = with_engines(&self.script_engines, |engines| { + let (lost, kept) = std::mem::take(engines).into_iter().partition(|e| e.realm.is_none()); + *engines = kept; + lost + }); + drop(lost); raise_ids(self); self.script_stop.clear(); self.reset_tool(); @@ -1374,10 +1650,40 @@ impl Session { Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(), _ => request.name, }; - let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() }; + // The engine named by `#targetengine`, other than `main`: its realm, kept from its last run, + // or none for an engine this run makes. + let named = match &expanded { + Ok(include::Expanded { engine: Some((engine, line, column)), .. }) if engine != MAIN_ENGINE => Some((engine.clone(), *line, *column)), + _ => None, + }; + let (expanded, kept) = match named { + None => (expanded, None), + Some((engine, line, column)) => match self.take_engine(&engine) { + Ok(kept) => (expanded, Some((engine, kept))), + Err((kind, message)) => { + let file = Arc::from(files.file_name()); + (Err(include::Failure { kind, message, index: 0, file, line, column }), None) + } + }, + }; + let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES }; + let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() }; + let (engine, realm, link) = match kept { + None => (None, None, Rc::new(RefCell::new(None))), + Some((engine, (realm, link))) => (Some(engine), realm, link), + }; + // A realm made here gets the script extensions, `$` and the dialogs; a kept one has them + // from its first run, and they act for the run that calls them. + let fresh = realm.is_none(); let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() }; let strict = options.strict; - let mut realm = Realm::with_options(options, limits); + let mut realm = match realm { + Some(mut realm) => { + realm.set_limits(limits); + realm + } + None => Realm::with_options(options, limits), + }; realm.share_interrupt(&self.script_stop); realm.set_data_limits(DataLimits::default()); self.script_runs += 1; @@ -1414,29 +1720,47 @@ impl Session { wake_at: Cell::new(None), can_wait: realm.wait_flag(), eval_strict: Cell::new(None), + dollar: dollar::RunState::new(&request.locale), + args: RefCell::new((Data::Undefined, 0)), }); - let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) - .map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?; + *link.borrow_mut() = Some(host.clone()); + let args = match to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) { + Ok(args) => args, + Err(place) => { + self.return_engine(engine.as_deref(), &link, (!fresh).then_some(realm)); + return Err(EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") }); + } + }; + let size = data_size(&args); + *host.args.borrow_mut() = (args, size); let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start }; self.script_console.push(line, false); - let info = dollar::RunInfo { locale: request.locale, strict }; + let engine_name = match &expanded { + Ok(include::Expanded { engine: Some((name, ..)), .. }) => Some(name.clone()), + _ => None, + }; + let info = dollar::RunInfo { strict, engine: engine_name }; let localization = realm.localization(); + dollar::start_run(&localization, &request.locale); let started = match expanded { Err(f) => Err(preprocessor_error(f)), Ok(expanded) => { *host.script_map.borrow_mut() = expanded.map; realm.set_strict_writes(expanded.strict == Some(true)); - realm - .add_script_extensions() - .and_then(|()| realm.define_host_object("vc", vc_object(&host, args))) - .and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization))) - .and_then(|()| dialogs::install(&mut realm, &host)) - .and_then(|()| files::install(&mut realm, &host)) + (if fresh { realm.add_script_extensions() } else { Ok(()) }) + .and_then(|()| if fresh { realm.define_host_object("vc", vc_object(&link)) } else { Ok(()) }) + .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) + .and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) }) + .and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() }) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) } }; if let Err((e, file)) = started { + // An engine this run made is not kept: only a run that starts makes one. An internal + // error drops a kept one. + let keep = !fresh && e.kind != RunErrorKind::Internal; + self.return_engine(engine.as_deref(), &link, keep.then_some(realm)); let mut report = failure_report(&name, &e, file.as_deref(), &host, 0, 0); close_run(self, &host, &mut report, ending(&e.kind)); let message = failure_text(&report); @@ -1445,7 +1769,7 @@ impl Session { } let scope = crate::file_access::ScopeHandle::capture(); let (run, line_name) = (host.run, host.name.clone()); - let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope }; + let job = Job { realm, host, link, engine, world: World::working(self), start_uids: open, name: name.clone(), scope }; let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); self.script_key = Some(JobKey { id, run, name: line_name }); @@ -1510,7 +1834,7 @@ impl Session { let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e); let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); - drop(job); + self.end_job(job, e.kind == RunErrorKind::Internal); raise_ids(self); self.script_stop.clear(); self.reset_tool(); @@ -1592,6 +1916,7 @@ impl Session { "hostCalls": host.calls.get(), }); close_run(self, host, &mut report, console::Ending::Finished); + self.end_job(job, false); self.script_report = Some(report.clone()); Ok(report) } @@ -1630,6 +1955,10 @@ const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the scr const EVAL_NO_PATH: &str = "$.evalFile requires a file path"; const EVAL_TIMEOUT: &str = "$.evalFile: the timeout must be a finite number of milliseconds, 0 or more"; const EVAL_ARGUMENTS: &str = "$.evalFile: takes a file path and a timeout"; +const NO_RUN: &str = "this function's script run has ended"; +const DROP_MAIN: &str = "the main script engine is new for each run and is never kept, so it cannot be dropped"; +const NO_ENGINES_ON_WEB: &str = "named script engines (#targetengine) are not available on the web"; +const ENGINE_BUSY: &str = "the script engine is in use by another run"; /// The report of a run that failed; `file` is the included file the error is in. fn failure_report(name: &str, e: &RunError, file: Option<&str>, host: &Host, fuel_left: u64, fuel_used: u64) -> Value { @@ -1795,6 +2124,8 @@ thread_local! { pub(crate) static PANIC_IN_SLICE: Cell = const { Cell::new(false) }; /// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`]. pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; + /// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. + pub(crate) static TEST_ENGINE_HEAP: Cell> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 3f45e8aeab..2d850fd44d 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -114,6 +114,7 @@ impl Session { recovery: Default::default(), in_tool_actions: false, previewing: false, + decoded_images: Default::default(), envelope_defaults: None, liquify_stroke: None, plane_widget_press: false, @@ -133,6 +134,7 @@ impl Session { global_edit_options: Default::default(), keyboard_text: false, script_key: None, + script_engines: crate::script::EnginesKey::new(), script_stop: vectorcraft_js::Interrupt::new(), script_report: None, script_console: Default::default(), diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 1762c96765..c7d22ab6c7 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -335,6 +335,7 @@ impl Session { pub fn commit_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); + self.decoded_images.empty(); if let Some(paint) = self.active().and_then(|st| st.interaction.as_ref()).and_then(|it| it.preview_paint.clone()) { self.paint = paint; } @@ -414,6 +415,7 @@ impl Session { pub fn cancel_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); + self.decoded_images.empty(); self.pending_paint = None; if let Some(paint) = self .active() diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 24d04230a1..1d66e3a63a 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; +use vectorcraft_plugins::permissions::GrantToken; use vectorcraft_plugins::safe_mode; use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; @@ -239,6 +240,15 @@ fn lock_disabled(_: &Path) -> Option { thread_local! { /// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume. pub(crate) static PUT_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: [`Session::put_back_plugin`] writes the old module back on this thread and then + /// reports a failure, as when the last flush of a copy fails. + pub(crate) static PUT_BACK_FAILS_AFTER_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: moving a plug-in's settings and cache back from `.removed` ([`move_back`]) + /// fails on this thread, as when another program holds a file in the folder. + pub(crate) static MOVE_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: writing the `.delete` file beside a folder about to be deleted ([`delete_aside`]) + /// fails on this thread, as on a full volume. + pub(crate) static MARKER_WRITE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted. @@ -430,9 +440,11 @@ impl Session { match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { asks_nothing.remove(&m.id); - vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } None => { log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) @@ -442,7 +454,9 @@ impl Session { Ok(plugin) => { asks_nothing.insert(plugin.id().to_string()); vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), } @@ -519,6 +533,27 @@ impl Session { } } + /// [`Session::plugin_approver`] for a folder load: a module the store's grants approve gets + /// that grant recorded for the process too, since the registry installs only a module that a + /// recorded grant covers ([`vectorcraft_plugins::registry::install`]). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn plugin_folder_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { + let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir)); + move |plugin| { + let m = plugin.manifest(); + match &grants { + Some(grants) if m.asks_permissions() => match grants.get(&m.id).filter(|g| grant_covers(g, plugin)) { + Some(g) => { + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); + true + } + None => false, + }, + _ => vectorcraft_plugins::registry::approved(plugin), + } + } + } + /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those the process /// doesn't count for that folder ([`Session::ignore_stored_grant`]). The folder's key is worked /// out on each call, so a path whose link now leads elsewhere is matched with where it leads. @@ -552,16 +587,21 @@ impl Session { /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. → whether the - /// store now holds `previous`. + /// write reported success; a write that reports a failure may still have put `previous` back. pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool { #[cfg(test)] if PUT_BACK_FAILS.get() { return false; } - match previous { + let back = match previous { Some(bytes) => self.store_plugin(id, bytes), None => self.unstore_plugin(id), + }; + #[cfg(test)] + if PUT_BACK_FAILS_AFTER_WRITE.get() { + return false; } + back } /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it @@ -677,26 +717,34 @@ impl Session { } /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without - /// one, nowhere (its storage calls then fail as unavailable). + /// one, nowhere (its storage calls then fail as unavailable). Settings and cache that a removal + /// kept are moved back first ([`plugin_storage`]). pub(crate) fn attach_plugin_storage(&self, id: &str) { vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); } - /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A - /// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls - /// can't reach it and removing the plug-in leaves it. + /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`, or one + /// that a removal kept ([`aside_paths`], [`is_kept`])? A folder behind a `storage` folder + /// that is a link is not counted: the plug-in's storage calls can't reach it and removing the + /// plug-in leaves it. pub(crate) fn has_plugin_storage(&self, id: &str) -> bool { - self.plugin_dir - .as_deref() - .is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok()) + self.plugin_dir.as_deref().is_some_and(|dir| { + let path = plugin_data_dir(dir, id); + !is_link(&dir.join(PLUGIN_DATA_DIR)) && (std::fs::symlink_metadata(&path).is_ok() || aside_paths(&path).iter().any(|a| is_kept(a))) + }) } /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. - /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it - /// as it was, when the rename does; what the deletion then leaves stays under that name, with a - /// log record. A later call for the id tries to delete it again. `forget` runs once the folder is - /// out of the way, before anything is deleted: when it returns `false`, the folder is renamed - /// back and this fails. + /// Settings and cache that an earlier removal kept ([`is_kept`]) are first moved back when the + /// folder is missing ([`restore_removed`]); what earlier deletions left is deleted. The folder is + /// then renamed out of the way ([`aside_paths`]: `.removed`, or `.removing` while + /// `.removed` holds kept settings), and that fails, leaving it as it was, when the rename + /// does. `forget` runs once the folder is out of the way, before anything is deleted: when it + /// returns `false`, the folder is renamed back and this fails, and the kept settings stay. When + /// that rename fails too, the error also names both paths and the reason, and the next attach or + /// removal for the id moves the folder back. Once `forget` returns `true`, the folder and the + /// kept settings are deleted ([`delete_aside`]); what that deletion leaves stays, with a log + /// record, and a later call for the id tries to delete it again. pub(crate) fn remove_plugin_storage(&self, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> { let not_forgotten = || StorageError::Io(GRANT_NOT_REMOVED.to_string()); vectorcraft_plugins::registry::set_storage(id, None); @@ -723,28 +771,45 @@ impl Session { close(); return Ok(()); } - let mut aside = path.clone().into_os_string(); - aside.push(".removed"); - let aside = PathBuf::from(aside); - let _ = std::fs::remove_dir_all(&aside); - match std::fs::rename(&path, &aside) { + let asides = aside_paths(&path); + if std::fs::symlink_metadata(&path).is_err() + && let Err(e) = restore_removed(&path) + { + log::warn!("can't move the settings and cache of {} back: {e}", path.display()); + } + for aside in asides.iter().filter(|a| !is_kept(a)) { + delete_aside(aside); + } + let target = if std::fs::symlink_metadata(&path).is_err() { + asides.iter().rev().find(|a| is_kept(a)).unwrap_or(&asides[0]) + } else if !is_kept(&asides[0]) { + &asides[0] + } else if std::fs::symlink_metadata(&asides[1]).is_err() { + &asides[1] + } else { + return Err(StorageError::Io(format!("{}: {} and {} are in the way", path.display(), asides[0].display(), asides[1].display()))); + }; + match std::fs::rename(&path, target) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))), Ok(()) => {} } if !forget() { - if std::fs::symlink_metadata(&aside).is_ok() - && let Err(e) = std::fs::rename(&aside, &path) + if std::fs::symlink_metadata(target).is_ok() + && let Err(e) = move_back(target, &path) { - log::warn!("can't move {} back: {e}", aside.display()); + log::warn!("can't move {} back: {e}", target.display()); + return Err(StorageError::Io(format!( + "{GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {} to {}: {e}", + target.display(), + path.display() + ))); } return Err(not_forgotten()); } close(); - if std::fs::symlink_metadata(&aside).is_ok() - && let Err(e) = std::fs::remove_dir_all(&aside) - { - log::warn!("can't delete {}: {e}", aside.display()); + for aside in &asides { + delete_aside(aside); } Ok(()) } @@ -775,10 +840,107 @@ pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id))) } -/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. +/// `path` with `suffix` added to its last part. +fn suffixed(path: &Path, suffix: &str) -> PathBuf { + let mut named = path.as_os_str().to_os_string(); + named.push(suffix); + PathBuf::from(named) +} + +/// Where [`Session::remove_plugin_storage`] moves the plug-in folder `path` out of the way: +/// `.removed`, and `.removing` while `.removed` holds settings and cache an +/// earlier removal kept ([`is_kept`]). The second one, when it is there, is the newer. +fn aside_paths(path: &Path) -> [PathBuf; 2] { + [suffixed(path, ".removed"), suffixed(path, ".removing")] +} + +/// `