diff --git a/.github/workflows/affinity-corpus.yml b/.github/workflows/affinity-corpus.yml index 948b87be9..b3e0178f5 100644 --- a/.github/workflows/affinity-corpus.yml +++ b/.github/workflows/affinity-corpus.yml @@ -24,9 +24,13 @@ jobs: CARGO_PROFILE_DEV_DEBUG: "0" CARGO_PROFILE_TEST_DEBUG: "0" steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0 with: toolchain: "1.95" - name: Fetch pinned and checksum-verified corpus diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index da85536c4..0a91cc050 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -29,6 +29,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a57e0e6b..eee66ab3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,10 @@ jobs: permissions: contents: read steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Git dependencies come from storytold run: | diff --git a/Cargo.lock b/Cargo.lock index 88a9abca0..d018aa027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -975,12 +975,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7 [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1070,7 +1070,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1080,6 +1080,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.6", "semver", "serde", "serde_json", @@ -1096,7 +1097,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "flate2", "serde", @@ -6312,7 +6313,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.21.0" +version = "0.24.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6358,7 +6359,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.21.0" +version = "0.24.0" dependencies = [ "crc32fast", "flate2", @@ -6369,14 +6370,14 @@ dependencies = [ [[package]] name = "vectorcraft-apischema" -version = "0.21.0" +version = "0.24.0" dependencies = [ "serde_json", ] [[package]] name = "vectorcraft-brush" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6388,7 +6389,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6404,7 +6405,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.21.0" +version = "0.24.0" dependencies = [ "log", "objc2-core-foundation", @@ -6427,7 +6428,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6439,7 +6440,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.21.0" +version = "0.24.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6458,7 +6459,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6476,7 +6477,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.21.0" +version = "0.24.0" dependencies = [ "cfb", "chrono", @@ -6529,7 +6530,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-pdf", "encoding_rs", @@ -6557,7 +6558,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-vectorcraft-format", "flate2", @@ -6573,7 +6574,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6585,16 +6586,17 @@ dependencies = [ [[package]] name = "vectorcraft-js" -version = "0.21.0" +version = "0.24.0" dependencies = [ "chrono", + "getrandom 0.4.3", "proptest", "unicode-general-category", ] [[package]] name = "vectorcraft-mcp" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "log", @@ -6613,7 +6615,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6627,7 +6629,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6639,7 +6641,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.21.0" +version = "0.24.0" dependencies = [ "aes", "chrono", @@ -6652,6 +6654,8 @@ dependencies = [ "kurbo 0.13.1", "log", "md-5", + "objc2-core-foundation", + "objc2-core-graphics", "roxmltree 0.21.2", "serde", "serde_json", @@ -6671,7 +6675,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-vectorcraft-format", "image", @@ -6685,11 +6689,12 @@ dependencies = [ "vectorcraft-geom", "wasmi", "wat", + "web-time", ] [[package]] name = "vectorcraft-render" -version = "0.21.0" +version = "0.24.0" dependencies = [ "flate2", "gif 0.14.2", @@ -6719,14 +6724,15 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.21.0" +version = "0.24.0" dependencies = [ "proptest", + "unicode-general-category", ] [[package]] name = "vectorcraft-svg" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-docpath", "flate2", @@ -6753,7 +6759,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.21.0" +version = "0.24.0" dependencies = [ "fax", "image", @@ -6771,7 +6777,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.21.0" +version = "0.24.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6792,7 +6798,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.21.0" +version = "0.24.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6800,7 +6806,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6817,7 +6823,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6829,7 +6835,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.21.0" +version = "0.24.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6867,7 +6873,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-web-api", "eframe", @@ -7999,7 +8005,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.21.0" +version = "0.24.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 2c0bf38ec..606c34f8b 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.21.0" +version = "0.24.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -14,16 +14,16 @@ repository = "https://github.com/storytold/vectorcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] } craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # 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 = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index e83a01efe..327036c37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,21 +1,8 @@ # VectorCraft Roadmap -- Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. -- Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. - -- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. -- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. - -- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. -- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. - -- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available. - -- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned. - **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -35,7 +22,6 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| -| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | @@ -225,7 +211,13 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | | 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | +| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. | +| 2026-11-03 | minor | Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. | +| 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. | +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | @@ -262,10 +254,14 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. | | 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. | +| 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. | +| 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. | | 2026-10-10 | minor | Method changed, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away | | 2026-10-10 | minor | Added the essentials-user readiness score (~75%) | | 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md | | 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha | | 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` | +| 2026-10-10 | minor | UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available. | +| 2026-10-10 | minor | UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned. | | 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list | | 2026-10-02 | major | First 22-area weighted parity estimate | diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 734f8a056..57ab10359 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -236,11 +236,13 @@ fn main() -> ExitCode { } /// A headless session that remembers what the user keeps between runs: the PDF presets saved, the -/// ICC profiles loaded, the plug-ins installed (#2168, #2174, #2541) and the custom dictionary's words (#4936), in the folders -/// and files next to the preferences that the desktop app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply -/// as they do there. +/// ICC profiles loaded, the plug-ins installed (#2168, #2174, #2541), the custom dictionary's words (#4936) and the Color Settings (#6335), in the folders +/// and files next to the preferences that the desktop app uses too, and lists the scripts of the Scripts folder there +/// (`script.listInstalled`). `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there. fn with_stores(mut h: Headless) -> Headless { + prefs_dir::attach_scripts_dir(&mut h.session); prefs_dir::attach_profile_store(&mut h.session); + prefs_dir::attach_color_settings(&mut h.session); prefs_dir::attach_plugin_store(&mut h.session); prefs_dir::attach_pdf_preset_store(&mut h.session); prefs_dir::attach_custom_dictionary(&mut h.session); @@ -484,7 +486,12 @@ fn info(args: &[String]) -> Result<(), String> { let base = h.call("engine.execute", json!({"command": "file.info", "params": {}}))?; let doc = h.session.doc().map_err(|e| e.to_string())?.doc.clone(); let mut kinds: std::collections::BTreeMap<&'static str, usize> = Default::default(); - doc.walk(|n| *kinds.entry(n.kind_label()).or_default() += 1); + // Ruler guides are not counted among the objects. + doc.walk(|n| { + if !n.is_ruler_guide() { + *kinds.entry(n.kind_label()).or_default() += 1; + } + }); let fonts = h.call("engine.execute", json!({"command": "text.fonts", "params": {}})).unwrap_or(Value::Null); let artboards: Vec = doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect(); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 7e4f245d3..bb94bad1c 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -456,11 +456,15 @@ fn main() -> std::process::ExitCode { let mut session = Session::new(); // The profiles loaded before come back before the preferences name any of them. prefs_dir::attach_profile_store(&mut session); + // Then Edit › Color Settings, which may name one of those profiles. + prefs_dir::attach_color_settings(&mut session); // Before any plug-in loads: this launch's marker in the Launch folder next to the // preferences (none for runs without preferences), and Safe Mode. let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch"))); let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag); prefs_dir::attach_plugin_store(&mut session); + // File › Scripts lists the scripts of the Scripts folder next to the preferences. + prefs_dir::attach_scripts_dir(&mut session); let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); app.set_startup_update_check(true); load_prefs(&mut app, saved); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 8e6402703..96e09f5e7 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -41,6 +41,14 @@ pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) { } } +/// Make Edit › Color Settings kept in `Color Settings.json` the active ones (#6335), and keep them +/// there when they change. After [`attach_profile_store`]: they may name a loaded profile. +pub fn attach_color_settings(session: &mut vectorcraft_engine::Session) { + if let Some(path) = store_dir("Color Settings.json") { + session.attach_color_settings(path); + } +} + /// Install the plug-ins Install Plug-in kept in the `Plug-ins` folder (#2541), and keep the ones /// installed from now on there. pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) { @@ -49,6 +57,14 @@ pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) { } } +/// Have File › Scripts and `script.listInstalled` list the scripts of the `Scripts` folder. The +/// folder is only read: the user makes it. +pub fn attach_scripts_dir(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("Scripts") { + session.attach_scripts_dir(dir); + } +} + /// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from /// now on there. After the preferences were restored: the folder is the one place the presets live. pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) { diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 45ed99299..3faf94423 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -583,7 +583,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no entity and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } // Symbols that hold symbols are written in full for each instance: depth alone doesn't @@ -676,6 +677,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 3ac145fd2..2e19ad44a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -8,7 +8,6 @@ mod layers; mod profile; mod stroke_style; -pub(crate) use layers::HEAD_REACH; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 880f3490d..923275999 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -139,10 +139,6 @@ impl StrokeGradientMode { } } -/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are -/// cut square a little behind its 4 × 4 box). -pub(crate) const HEAD_REACH: f64 = 4.6; - fn ten() -> f64 { 10.0 } @@ -188,7 +184,8 @@ impl StrokeLayer { } } /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least - /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide. + /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide (see + /// [`Arrowhead::reach`] and [`Arrowhead::half_width`]). pub fn arrow_weight(&self, end: bool) -> f64 { let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; (self.width * pct / 100.0).max(0.25) @@ -197,7 +194,7 @@ impl StrokeLayer { /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. pub fn head_reach(&self, end: bool) -> f64 { let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0) + head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0) } /// How far the arrowheads can reach from the path's end points (0 without heads). pub fn arrow_reach(&self) -> f64 { diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index acd4d8189..46a0c30c3 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -49,6 +49,10 @@ impl Dash { } } +/// How far the arrowheads drawn in the 4 × 4 box reach from their tip, in units of their weight +/// (the open arrow's arms are cut square a little behind the box). +const HEAD_REACH: f64 = 4.6; + /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Arrowhead { @@ -94,13 +98,18 @@ pub enum Arrowhead { Kite, TriangleBar, Oval, + /// Two arms of the head's weight (the line's weight at 100 % scale, for lines of 0.25 pt or + /// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as + /// the other heads. + LineArrow, } impl Arrowhead { /// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks). - pub const ALL: [Arrowhead; 40] = [ + pub const ALL: [Arrowhead; 41] = [ Arrowhead::Arrow, Arrowhead::ArrowOpen, + Arrowhead::LineArrow, Arrowhead::Barbed, Arrowhead::Concave, Arrowhead::DoubleArrow, @@ -141,6 +150,22 @@ impl Arrowhead { Arrowhead::Fork, ]; + /// How far any part of the head can be from its tip, in units of its weight. + pub fn reach(self) -> f64 { + match self { + // Its farthest corners are 4 + ½√2 back and 4 − ½√2 out. + Arrowhead::LineArrow => 5.75, + _ => HEAD_REACH, + } + } + /// How far the head spreads either side of its line, in units of its weight. + pub fn half_width(self) -> f64 { + match self { + Arrowhead::LineArrow => 4.0, + _ => 2.0, + } + } + /// The name the arrowhead menus show. pub fn label(self) -> &'static str { match self { @@ -184,6 +209,7 @@ impl Arrowhead { Arrowhead::Kite => "Kite", Arrowhead::TriangleBar => "Triangle to Bar", Arrowhead::Oval => "Oval", + Arrowhead::LineArrow => "Line Arrow", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 6e9768bf5..aa2ba0100 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -292,7 +292,7 @@ fn profile_presets_round_trip_their_ids() { #[test] fn arrowhead_names_are_stable_and_every_one_is_listed_once() { // Saved files store these names: they may only ever be appended to. - const SAVED: [&str; 40] = [ + const SAVED: [&str; 41] = [ "Triangle", "TriangleOpen", "Circle", @@ -333,6 +333,7 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() { "Kite", "TriangleBar", "Oval", + "LineArrow", ]; for name in SAVED { let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); @@ -366,9 +367,15 @@ fn outset_covers_arrowheads() { st.arrow_scale = (100.0, 200.0); assert_eq!(st.arrow_weight(false), 4.0); assert_eq!(st.arrow_weight(true), 8.0); - assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0)); + let reach = Arrowhead::Triangle.reach(); + assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, reach * 8.0 + 2.0)); // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. - assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0); + assert_eq!(a.outset(), reach * 8.0 + 2.0); + // The line arrow's arms spread twice as wide, so it reaches further from its tip. + a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::LineArrow); + assert!(Arrowhead::LineArrow.reach() > reach); + assert_eq!(a.outset(), Arrowhead::LineArrow.reach() * 8.0 + 2.0); + a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle); a.stroke_mut().unwrap().width = 0.01; assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); } diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index d7bfcafca..fd06ea909 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -1,4 +1,4 @@ -use super::{Arc, Deserialize, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip}; +use super::{Arc, Deserialize, LayerColor, Node, NodeId, Rect, Serialize, SymbolBoxes, skip}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { @@ -39,6 +39,18 @@ pub struct Artboard { /// it behind the art ([`super::Document::with_artboard_fills`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub color: Option, + /// Guide label (Artboard Options): the color the canvas draws the artboard's own ruler guides + /// in (a guide whose [`super::Guide::artboard`] is this artboard's id), a [`LayerColor`] preset + /// or a custom RGB. None (No label) draws them in the guide color preference, as canvas guides + /// are. Only the canvas uses it: the paper, exports and print don't. Written only when set; a + /// file's preset outside [`super::LAYER_COLORS`] reads as no label. + #[serde(default, deserialize_with = "label_within_palette", skip_serializing_if = "Option::is_none")] + pub guide_label: Option, +} + +/// A file's guide label: a preset index outside [`super::LAYER_COLORS`] reads as no label. +fn label_within_palette<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Option::::deserialize(d)?.filter(|c| !matches!(c, LayerColor::Preset(i) if usize::from(*i) >= super::LAYER_COLORS.len()))) } /// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times @@ -192,12 +204,7 @@ impl LockedArtboards<'_> { /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it /// is new art? pub fn overlaps(&self, n: &Node) -> bool { - if self.rects.is_empty() - || !n.visible - || n.is_layer() - || matches!(n.kind, NodeKind::Path { guide: true, .. }) - || self.pattern_art.contains(&n.id) - { + if self.rects.is_empty() || !n.visible || n.is_layer() || n.is_guide() || self.pattern_art.contains(&n.id) { return false; } if let Some(held) = self.held.borrow().get(&n.id) { diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index 114235696..e4f267a8b 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -123,6 +123,24 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut }); } +/// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many +/// to `dropped`. Only layers holding one are copied. +fn drop_guides(layer: &mut Arc, out: &impl Fn(&Node) -> bool, dropped: &mut usize) { + fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out))) + } + if !holds(layer, out) { + return; + } + let Some(children) = Arc::make_mut(layer).children_mut() else { return }; + let before = children.len(); + children.retain(|c| !out(c)); + *dropped += before - children.len(); + for c in children.iter_mut().filter(|c| c.is_layer()) { + drop_guides(c, out, dropped); + } +} + impl Document { /// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects /// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied @@ -141,8 +159,9 @@ impl Document { for c in children.iter() { layers_in(c, &mut layers); } - gone.objects += children.len(); - children.clear(); + // The layer's own ruler guides stay; only its art goes. + gone.objects += children.iter().filter(|c| !c.is_ruler_guide()).count(); + children.retain(|c| c.is_ruler_guide()); *clip = false; } } else { @@ -161,9 +180,18 @@ impl Document { self.retire_artboard_id(*id); } gone.artboards = removed.len(); - let guides = self.guides.len(); - self.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.guides.len(); + // The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]). + 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(); + let out = |n: &Node| match n.kind { + NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), + _ => false, + }; + for layer in &mut self.layers { + drop_guides(layer, &out, &mut gone.guides); + } if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) { self.ruler_origin = Point::ZERO; gone.ruler_origin = true; @@ -232,12 +260,18 @@ mod tests { let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }; d.artboards.push(far); let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) }; - d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; + d.saved_guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; d.ruler_origin = Point::new(1e17, 0.0); + // The same guides as nodes, as a document has them once read. + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 4); let gone = d.drop_beyond_canvas(); assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true }); assert_eq!(d.artboards.len(), 1); - assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(d.saved_guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(nodes.drop_beyond_canvas(), gone); + let left: Vec<(bool, f64, Option)> = nodes.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(left, [(false, 50.0, None)]); assert_eq!(d.ruler_origin, Point::ZERO); assert_eq!( gone.note().unwrap(), @@ -310,7 +344,8 @@ mod tests { let probe = Document::new(1.0, 1.0); let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]); - d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; + d.saved_guides = + vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, @@ -320,7 +355,7 @@ mod tests { d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); 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!(d.guides.len(), 1, "the guide of a layer that stays, stays"); + 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)])]); assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 5abba9c63..63b7e0ce7 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -132,7 +132,7 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { out.push(a); continue; } - let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + let clip = n.clips(); let (visible, locked) = (n.visible, n.locked); let mut held = n.children_mut().map(std::mem::take).unwrap_or_default(); if clip && !held.is_empty() { @@ -148,6 +148,13 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { // What it held takes its place, and is looked at in its turn. left.extend(held.into_iter().rev()); } + // The ruler guides of a sublayer merged into its parent layer go above the parent's art, with + // the parent's own. + if out.iter().any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = out.into_iter().partition(|c| c.is_ruler_guide()); + out = art; + out.extend(guides); + } *nodes = out; true } diff --git a/crates/doc/src/dimension.rs b/crates/doc/src/dimension.rs index 5ca27311e..89df89974 100644 --- a/crates/doc/src/dimension.rs +++ b/crates/doc/src/dimension.rs @@ -1,7 +1,7 @@ //! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters. use crate::{Document, NodeId, Unit}; use serde::{Deserialize, Serialize}; -use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; /// What a dimension measures. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -25,6 +25,17 @@ impl DimensionMode { } } +/// The direction a linear dimension measures in: along the line through its two points, or only +/// their horizontal or vertical distance. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Orientation { + #[default] + Aligned, + Horizontal, + Vertical, +} + /// The unit a length is labelled in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -295,6 +306,12 @@ pub enum DimensionSource { a: Option, b: Option, vertex: Option, + /// The first point when it is not an anchor, held against a path's anchors. + #[serde(default, rename = "freeA", skip_serializing_if = "Option::is_none")] + free_a: Option, + /// The second point when it is not an anchor. + #[serde(default, rename = "freeB", skip_serializing_if = "Option::is_none")] + free_b: Option, }, } @@ -304,7 +321,11 @@ impl DimensionSource { Self::Segment { edge } | Self::Arc { edge } => vec![edge.node], Self::Angle { first, second } => vec![first.node, second.node], Self::Circle { node } => vec![*node], - Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(), + Self::Points { a, b, vertex, free_a, free_b } => [a, b, vertex] + .into_iter() + .filter_map(|p| p.as_ref().map(|r| r.node)) + .chain([free_a, free_b].into_iter().filter_map(|p| p.as_ref().map(FreePoint::node))) + .collect(), } } /// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle @@ -321,6 +342,34 @@ impl DimensionSource { old.is_some() && old != counts(after) }) } + /// The map that took the measured art from `before` to `after`, when every path it measures + /// went through one [`art_transform`] that reflects it (a negative determinant). None when a + /// measured path is unchanged, or when two of them went through different maps. + pub fn reflection(&self, before: &Document, after: &Document) -> Option { + let mut first: Option = None; + for id in self.nodes() { + let (old, new) = (before.node(id)?, after.node(id)?); + if std::ptr::eq(old, new) || old.path_data() == new.path_data() { + return None; + } + let xf = fitted(before, after, id, |xf| xf.determinant() < 0.0)?; + match first { + None => first = Some(xf), + Some(f) => { + // The linear parts and the translations, each against its own size. + let (a, b) = (f.as_coeffs(), xf.as_coeffs()); + let differ = |range: std::ops::Range, tolerance: f64| { + let size = a[range.clone()].iter().map(|c| c.abs()).fold(1.0, f64::max); + a[range.clone()].iter().zip(&b[range]).any(|(p, q)| (p - q).abs() > tolerance * size) + }; + if differ(0..4, 1e-9) || differ(4..6, 1e-9) { + return None; + } + } + } + } + first + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -337,6 +386,235 @@ impl PointRef { } } +/// The point of an anchor that an [`AnchorWeight`] names: the anchor, or one of its handles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnchorPart { + #[default] + Point, + In, + Out, +} + +impl AnchorPart { + fn of(self, a: &Anchor) -> Point { + match self { + Self::Point => a.p, + Self::In => a.h_in, + Self::Out => a.h_out, + } + } +} + +/// One anchor's share of a [`FreePoint`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchorWeight { + pub subpath: usize, + pub anchor: usize, + /// The anchor itself, or its in or out handle. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub part: AnchorPart, + pub weight: f64, +} + +/// A measured point that is not an anchor (a point on a segment, or a point a Smart Guide gave), +/// linked to one path so that it moves with it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FreePoint { + /// A point on a segment at curve parameter `t`, 0 at the segment's first anchor and 1 at its + /// last. It stays on the segment when the segment's anchors or handles are edited. + OnSegment { edge: SegmentRef, t: f64 }, + /// A weighted sum of two or three anchors or handles of one path, the weights adding up to 1, + /// plus `across` times the vector from the first anchor to the second turned a quarter turn, + /// from (x, y) to (-y, x), so that a vector pointing right in the y-down document space + /// turns to point down (two anchors only). When the + /// path is moved, scaled, rotated, reflected or sheared, a point held by three moves with + /// it. A point held by two and `across`, beside a straight path, moves, turns and scales + /// uniformly with it, and a reflection or a non-uniform scale moves it as a half turn or a + /// uniform scale would. + Anchors { + node: NodeId, + anchors: Vec, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + across: f64, + }, +} + +/// A point of a path: (subpath, anchor, part, place). +type PathPoint = (usize, usize, AnchorPart, Point); + +/// Every anchor of `path` in order, each followed by its in and out handles when `handles` is set. +fn path_points(path: &PathData, handles: bool) -> impl Iterator + Clone + '_ { + let parts: &'static [AnchorPart] = if handles { &[AnchorPart::Point, AnchorPart::In, AnchorPart::Out] } else { &[AnchorPart::Point] }; + path.subpaths + .iter() + .enumerate() + .flat_map(move |(si, sp)| sp.anchors.iter().enumerate().flat_map(move |(ai, a)| parts.iter().map(move |&part| (si, ai, part, part.of(a))))) +} + +/// Three points that span `points`: the first, the one farthest from it, and the one farthest +/// from the line through those two. None when every point is in one place. +fn spanning(points: impl Iterator + Clone) -> Option<[PathPoint; 3]> { + let first = points.clone().next()?; + let o = first.3; + let second = points.clone().max_by(|x, y| x.3.distance(o).total_cmp(&y.3.distance(o)))?; + let u = second.3 - o; + let span = u.hypot(); + if !span.is_finite() || span <= 1e-6 { + return None; + } + let third = points.max_by(|x, y| u.cross(x.3 - o).abs().total_cmp(&u.cross(y.3 - o).abs()))?; + Some([first, second, third]) +} + +/// The map that takes the anchors and handles of path `node` in `before` to where they are in +/// `after`, when one affine map does that for every one of them: the path was moved, scaled, +/// rotated, reflected or sheared as a whole. None when its anchors changed in number, moved in +/// another way, or lie on one line with all their handles, as on a straight line: a reflection +/// across a line through such a path puts its points where a turn can put them too. +pub fn art_transform(before: &Document, after: &Document, node: NodeId) -> Option { + fitted(before, after, node, |_| true) +} + +/// [`art_transform`] when the map passes `wanted`. The map is found from three points, and +/// `wanted` is asked before every other point is checked against it. +fn fitted(before: &Document, after: &Document, node: NodeId, wanted: impl Fn(&Affine) -> bool) -> Option { + let (old, new) = (before.node(node)?.path_data()?, after.node(node)?.path_data()?); + if old.subpaths.len() != new.subpaths.len() || old.subpaths.iter().zip(&new.subpaths).any(|(o, n)| o.anchors.len() != n.anchors.len()) { + return None; + } + let [o, f, g] = spanning(path_points(old, true))?; + let place = |q: PathPoint| new.subpaths.get(q.0).and_then(|sp| sp.anchors.get(q.1)).map(|a| q.2.of(a)); + let (o2, f2, g2) = (place(o)?, place(f)?, place(g)?); + let (u, v) = (f.3 - o.3, g.3 - o.3); + let span = u.hypot(); + if u.cross(v).abs() <= 1e-9 * span * span { + return None; + } + let basis = |u: Vec2, v: Vec2| Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]); + let linear = basis(f2 - o2, g2 - o2) * basis(u, v).inverse(); + let xf = Affine::translate(o2.to_vec2()) * linear * Affine::translate(-o.3.to_vec2()); + if !xf.as_coeffs().iter().all(|c| c.is_finite()) || !wanted(&xf) { + return None; + } + let tolerance = 1e-6 * span.max(1.0); + path_points(old, true).zip(path_points(new, true)).all(|(o, n)| (xf * o.3).distance(n.3) <= tolerance).then_some(xf) +} + +impl FreePoint { + /// Largest weight: a point far outside the anchors that hold it is not held. + const MAX_WEIGHT: f64 = 1e4; + + /// The path it is linked to. + pub fn node(&self) -> NodeId { + match self { + Self::OnSegment { edge, .. } => edge.node, + Self::Anchors { node, .. } => *node, + } + } + + /// `p` held against path `node`: three anchors that span the path; when all its anchors lie + /// on one line or in one place, as on an arch, three of its anchors and handles; when its + /// handles lie on that line too, or so nearly that the weights would be too large, two anchors + /// and `p`'s distance across that line. None when none of these holds `p`. + pub fn on(doc: &Document, node: NodeId, p: Point) -> Option { + let path = doc.node(node)?.path_data()?; + let weight = |(subpath, anchor, part, _): PathPoint, weight| AnchorWeight { subpath, anchor, part, weight }; + for handles in [false, true] { + let Some([o, f, g]) = spanning(path_points(path, handles)) else { continue }; + let (u, v) = (f.3 - o.3, g.3 - o.3); + let span = u.hypot(); + let det = u.cross(v); + if det.abs() > 1e-9 * span * span { + let s = (p - o.3).cross(v) / det; + let t = u.cross(p - o.3) / det; + let anchors = vec![weight(o, 1.0 - s - t), weight(f, s), weight(g, t)]; + // A nearly flat set of points gives weights too large to keep: the next fit is tried. + if let Some(held) = (Self::Anchors { node, anchors, across: 0.0 }).holding(doc, p, span) { + return Some(held); + } + } + } + // Every anchor and handle on one line, or nearly: along it from the first anchor, and across it. + let [o, f, _] = spanning(path_points(path, false))?; + let u = f.3 - o.3; + let span = u.hypot(); + let (t, across) = ((p - o.3).dot(u) / (span * span), u.cross(p - o.3) / (span * span)); + Self::Anchors { node, anchors: vec![weight(o, 1.0 - t), weight(f, t)], across }.holding(doc, p, span) + } + + /// `p` on the segment `edge`, at the curve parameter of the segment's point nearest to it. + /// None when the segment is gone or `p` is not on it. + pub fn on_segment(doc: &Document, edge: &SegmentRef, p: Point) -> Option { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + let (a, b) = edge.endpoints(doc)?; + let t = sp.segment(edge.segment).nearest(p, 1e-12).t; + Self::OnSegment { edge: edge.clone(), t }.holding(doc, p, a.distance(b)) + } + + /// Itself when it is [`Self::valid`] and puts the point at `p`, within a tolerance that grows + /// with `span`, the size of the art that holds it. + fn holding(self, doc: &Document, p: Point, span: f64) -> Option { + let at = self.position(doc)?; + (at.distance(p) <= 1e-6 * span.max(1.0)).then_some(self) + } + + /// Whether its numbers can be used: a finite `t` from 0 to 1 on a segment; or two or three + /// weights that add up to 1, with `across` set only for two, each finite and at most + /// [`Self::MAX_WEIGHT`] across. + pub fn valid(&self) -> bool { + match self { + Self::OnSegment { t, .. } => (0.0..=1.0).contains(t), + Self::Anchors { anchors, across, .. } => { + (2..=3).contains(&anchors.len()) + && anchors.iter().all(|w| w.weight.is_finite() && w.weight.abs() <= Self::MAX_WEIGHT) + && (anchors.iter().map(|w| w.weight).sum::() - 1.0).abs() <= 1e-9 + && across.is_finite() + && across.abs() <= Self::MAX_WEIGHT + && (*across == 0.0 || anchors.len() == 2) + } + } + } + + /// Where the point is now. None when its segment or an anchor is gone or the point is not + /// [`Self::valid`]. + pub fn position(&self, doc: &Document) -> Option { + use vectorcraft_geom::kurbo::ParamCurve; + if !self.valid() { + return None; + } + let at = match self { + Self::OnSegment { edge, t } => { + let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + sp.segment(edge.segment).eval(*t) + } + Self::Anchors { node, anchors, across } => { + let path = doc.node(*node)?.path_data()?; + let place = |w: &AnchorWeight| path.subpaths.get(w.subpath).and_then(|sp| sp.anchors.get(w.anchor)).map(|a| w.part.of(a).to_vec2()); + let mut at = Vec2::ZERO; + for w in anchors { + at += place(w)? * w.weight; + } + if *across != 0.0 { + let u = place(anchors.get(1)?)? - place(anchors.first()?)?; + at += Vec2::new(-u.y, u.x) * *across; + } + at.to_point() + } + }; + (at.x.is_finite() && at.y.is_finite()).then_some(at) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Dimension { @@ -349,6 +627,10 @@ pub struct Dimension { pub length: f64, pub options: DimensionOptions, pub source: Option, + /// What a linear dimension measures: the distance between its points (aligned), or only its + /// horizontal or vertical part. Other modes ignore it. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub orientation: Orientation, /// How far a linked dimension was moved on its own since the last refresh: the refresh turns /// it into a new placement unless the measured art moved too. #[serde(skip)] @@ -359,16 +641,137 @@ impl Dimension { /// A point on the drawn dimension line (linear), arc (angular) or radius line. pub fn handle(&self) -> Point { match self.mode { - DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset, + DimensionMode::Linear => { + let (a, b) = self.line_ends(); + a.midpoint(b) + } DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0), DimensionMode::Radius | DimensionMode::Diameter => self.b, } } - /// The left normal of a → b. + /// The unit direction a linear dimension measures along: a → b when aligned, else the x axis + /// (horizontal) or the y axis (vertical), whichever way the points lie. + pub fn direction(&self) -> Vec2 { + match self.orientation { + Orientation::Horizontal if self.mode == DimensionMode::Linear => Vec2::new(1.0, 0.0), + Orientation::Vertical if self.mode == DimensionMode::Linear => Vec2::new(0.0, 1.0), + _ => { + let u = self.b - self.a; + let len = u.hypot(); + if len > 1e-9 { u / len } else { Vec2::new(1.0, 0.0) } + } + } + } + /// The left normal of [`Self::direction`]. pub fn normal(&self) -> Vec2 { - let u = self.b - self.a; - let len = u.hypot(); - if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) } + let u = self.direction(); + Vec2::new(-u.y, u.x) + } + /// The length a linear dimension shows: the distance between its points along + /// [`Self::direction`]. + pub fn measured(&self) -> f64 { + (self.b - self.a).dot(self.direction()).abs() + } + /// The two ends of a linear dimension's line: the measured points moved onto one line along + /// [`Self::direction`], `offset` from a along the normal. + pub fn line_ends(&self) -> (Point, Point) { + let (u, n) = (self.direction(), self.normal()); + let a = self.a + n * self.offset; + (a, a + u * (self.b - self.a).dot(u)) + } + /// A horizontal or vertical dimension whose points moved from `old`'s keeps its line as far + /// outside them as it was, on the same side: the gap is measured from the outermost point + /// on the line's side, so the line stays clear of art that turns. A line that lay between + /// the two points keeps its distance from the first point. + pub fn keep_gap(&mut self, old: &Dimension) { + if self.mode != DimensionMode::Linear + || self.orientation == Orientation::Aligned + || old.orientation != self.orientation + || (self.a, self.b) == (old.a, old.b) + { + return; + } + let n = self.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let line = across(old.a) + old.offset; + let (low, high) = (across(old.a).min(across(old.b)), across(old.a).max(across(old.b))); + let (new_low, new_high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b))); + let at = if line >= high { + new_high + (line - high) + } else if line <= low { + new_low + (line - low) + } else { + return; + }; + let offset = at - across(self.a); + if offset.is_finite() && offset.abs() <= 1e6 { + self.offset = offset; + } + } + /// A linear dimension whose art was reflected, its points now moved from `old`'s: its line goes + /// to the side of the points that `image` (where the reflection put `old`'s line) is on, as far + /// past the outermost point on that side as `old`'s line was. A line that lay between the two + /// points is left as it is. + pub fn mirror_line(&mut self, old: &Dimension, image: Point) { + if self.mode != DimensionMode::Linear || old.mode != DimensionMode::Linear { + return; + } + let gap = { + let n = old.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let line = across(old.a) + old.offset; + let (low, high) = (across(old.a).min(across(old.b)), across(old.a).max(across(old.b))); + if line >= high { + line - high + } else if line <= low { + low - line + } else { + return; + } + }; + let n = self.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let (low, high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b))); + let at = if 2.0 * across(image) >= low + high { high + gap } else { low - gap }; + let offset = at - across(self.a); + if offset.is_finite() && offset.abs() <= 1e6 { + self.offset = offset; + } + } + /// Whether a linear dimension's line lies strictly between its two points along the normal. + fn line_between_points(&self) -> bool { + let n = self.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let line = across(self.a) + self.offset; + across(self.a).min(across(self.b)) < line && line < across(self.a).max(across(self.b)) + } + /// Makes a linear dimension measure in `orientation`, with its line on the side of the points + /// it was on. The line goes through the middle of the old line when that point lies outside + /// the two points along the new normal; otherwise it lies as far past the outermost point on + /// that side as the old line lay from its points. + pub fn set_orientation(&mut self, orientation: Orientation) { + if self.mode != DimensionMode::Linear || self.orientation == orientation { + self.orientation = orientation; + return; + } + let (handle, gap) = (self.handle(), self.offset.abs()); + self.orientation = orientation; + let n = self.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let (low, high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b))); + let h = across(handle); + let at = if h >= high || h <= low { + h + } else if 2.0 * h >= low + high { + high + gap + } else { + low - gap + }; + let offset = at - across(self.a); + if offset.is_finite() && offset.abs() <= 1e6 { + self.offset = offset; + } + self.length = self.measured(); } fn bisector(&self) -> Vec2 { let start = (self.a - self.vertex).atan2(); @@ -397,28 +800,46 @@ impl Dimension { self.moved = Some((xf * at) - at + self.moved.unwrap_or_default()); return; } + // A horizontal or vertical dimension keeps measuring along its axis, between the points + // where they now are, and its line goes through where the transform puts it. When that + // puts the line between the points (a quarter turn), the line keeps its gap from them. + let before = (self.mode == DimensionMode::Linear && self.orientation != Orientation::Aligned).then(|| (self.clone(), xf * self.handle())); self.a = xf * self.a; self.b = xf * self.b; self.vertex = xf * self.vertex; + if let Some((old, p)) = before { + let offset = (p - self.a).dot(self.normal()); + if offset.is_finite() && offset.abs() <= 1e6 { + self.offset = offset; + } + if self.line_between_points() && !old.line_between_points() { + self.keep_gap(&old); + } + } if self.mode == DimensionMode::Linear { - self.length = self.a.distance(self.b); + self.length = self.measured(); } } pub fn resolve(&mut self, doc: &Document) -> Result<(), String> { match &self.source { - Some(DimensionSource::Points { a, b, vertex }) => { - for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] { - if let Some(r) = reference { - *point = r.position(doc).ok_or("Measured anchor no longer exists")?; + Some(DimensionSource::Points { a, b, vertex, free_a, free_b }) => { + for (reference, free, point) in [(a, free_a, &mut self.a), (b, free_b, &mut self.b), (vertex, &None, &mut self.vertex)] { + match (reference, free) { + // An end is an anchor or a free point, never both. + (Some(_), Some(_)) => return Err("Invalid measured point".into()), + (Some(r), None) => *point = r.position(doc).ok_or("Measured anchor no longer exists")?, + (None, Some(f)) if !f.valid() => return Err("Invalid measured point".into()), + (None, Some(f)) => *point = f.position(doc).ok_or("Measured anchor no longer exists")?, + (None, None) => {} } } - self.length = self.a.distance(self.b); + self.length = if self.mode == DimensionMode::Linear { self.measured() } else { self.a.distance(self.b) }; } Some(DimensionSource::Segment { edge }) => { let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?; self.a = a; self.b = b; - self.length = a.distance(b); + self.length = self.measured(); } Some(DimensionSource::Angle { first, second }) => { for edge in [first, second] { @@ -486,7 +907,7 @@ impl Dimension { DimensionMode::Angular => self.angle().abs().to_degrees(), DimensionMode::Radius => self.a.distance(self.b), DimensionMode::Diameter => self.a.distance(self.b) * 2.0, - DimensionMode::Linear => self.a.distance(self.b), + DimensionMode::Linear => self.measured(), }; self.options.label(value, self.mode == DimensionMode::Angular) } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2a2f8a8d7..708d0aee0 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -10,7 +10,7 @@ use super::{ impl Document { /// Is everything but the layers the same in `other`, as the next version of the document after - /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// an edit of its art? The id counter and the caches are left out, and art outside the layers /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it /// doesn't change: quick for every frame of an animation. pub fn same_outside_layers(&self, other: &Self) -> bool { @@ -38,7 +38,7 @@ impl Document { text_threads, symbols, graph_designs, - guides, + saved_guides, views, saved_selections, variables, @@ -75,7 +75,9 @@ impl Document { dimensions, retired_artboard_id: _, opened_artboard: _, + units_from_file: _, index: _, + guide_cache: _, } = self; *version == other.version && *title == other.title @@ -97,7 +99,7 @@ impl Document { && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) && graph_designs.len() == other.graph_designs.len() && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && *guides == other.guides + && *saved_guides == other.saved_guides && *views == other.views && *saved_selections == other.saved_selections && *variables == other.variables @@ -208,6 +210,7 @@ impl Document { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }], layers: vec![], swatches: swatches.into(), @@ -219,7 +222,7 @@ impl Document { text_threads: vec![], symbols: vec![], graph_designs: vec![], - guides: vec![], + saved_guides: vec![], views: vec![], saved_selections: Default::default(), variables: Default::default(), @@ -256,7 +259,9 @@ impl Document { dimensions: false, retired_artboard_id: Default::default(), opened_artboard: None, + units_from_file: false, index: Default::default(), + guide_cache: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -498,11 +503,13 @@ impl Document { } } - /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). + /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). In a layer, art goes + /// below the layer's ruler guides and a ruler guide among them ([`Node::insert_index`]). pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { let id = node.id; + self.check_ruler_guide_place(&node, parent)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &node); ch.insert(i, Arc::new(node)); Ok(id) } @@ -519,20 +526,33 @@ impl Document { Ok(ch.remove(idx)) } - /// Move a node to `parent` at `index` (index measured after removal). + /// Move a node to `parent` at `index` (index measured after removal), clamped as + /// [`Document::insert`] clamps it. pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { if let Some(p) = parent && self.ancestry(p).is_some_and(|a| a.contains(&id)) { return Err(DocError::Invalid("cannot move an object into itself".into())); } + if let Some(n) = self.node(id) { + self.check_ruler_guide_place(n, parent)?; + } let n = self.remove(id)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &n); ch.insert(i, n); Ok(()) } + /// An error when `n` is a ruler guide and `parent` is not a layer or sublayer: a ruler guide is + /// always a direct child of one ([`NodeKind::RulerGuide`]). + fn check_ruler_guide_place(&self, n: &Node, parent: Option) -> Result<(), DocError> { + if n.is_ruler_guide() && !parent.and_then(|p| self.node(p)).is_some_and(Node::is_layer) { + return Err(DocError::Invalid("a ruler guide goes only directly in a layer or sublayer".into())); + } + Ok(()) + } + /// Position of `id` within its parent: (parent, index, sibling count). pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { let parent = self.parent_of(id); @@ -736,7 +756,7 @@ impl Document { for c in n.children().into_iter().flatten() { if c.is_layer() { objects(c, out); - } else { + } else if !c.is_ruler_guide() { out.insert(c.id); } } @@ -754,7 +774,8 @@ impl Document { } /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else - /// the group or other object around it right inside a layer. None for a layer or sublayer. + /// the group or other object around it right inside a layer. None for a layer, a sublayer or a + /// ruler guide. pub fn layer_object(&self, id: NodeId) -> Option<&Node> { self.layer_object_at(&self.index_path(id)?) } @@ -765,6 +786,9 @@ impl Document { let mut nodes = &self.layers; for &i in path { let n = nodes.get(i)?; + if n.is_ruler_guide() { + return None; + } if !n.is_layer() { return Some(n.as_ref()); } diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index ebb2fc44e..0f1ec463c 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -56,9 +56,12 @@ impl Document { if self.saved_selections.is_empty() { return; } + // A saved selection names objects; ruler guides are not among them. let mut ids = std::collections::HashSet::new(); self.walk(|n| { - ids.insert(n.id); + if !n.is_ruler_guide() { + ids.insert(n.id); + } }); let mut kept: Vec = vec![]; for s in std::mem::take(&mut self.saved_selections) { @@ -101,12 +104,13 @@ impl Document { /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The /// layer is removed wherever it is, also as a sublayer of another layer; an id that names - /// anything other than a layer is left alone. + /// anything other than a layer is left alone. Ruler guides on the layer stay in the document + /// ([`Self::remove_edit_layer`]). pub fn drop_edit_modes(&mut self) { let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)]; for id in layers.into_iter().flatten() { if self.node(id).is_some_and(Node::is_layer) { - let _ = self.remove(id); + let _ = self.remove_edit_layer(id); } } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 510de3a4d..ca6b5ed88 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -1,6 +1,8 @@ use super::document_ext::free_id; use super::guides::Guide; +use super::limits::MAX_NEXT_ID; use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds}; +use std::collections::{BTreeMap, HashSet}; impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol @@ -151,7 +153,7 @@ impl Document { rest.iter().any(|m| has(m, s, inside, true)) } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)), - NodeKind::Path { guide: true, .. } => false, + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => false, _ => art_box_meets(c, s, rect, exact), } } @@ -171,6 +173,195 @@ impl Document { art } + /// Put the ruler guide nodes a file holds where ruler guides go: directly in a layer or + /// sublayer, above its art. A ruler guide below art in a layer moves up to the layer's other + /// guides, keeping their order. A ruler guide anywhere else (at the top level, in a group or + /// another object, in opacity-mask art, in a symbol, a graph design or a pattern) is removed. + /// Returns how many were removed. + pub fn tidy_ruler_guides(&mut self) -> usize { + let mut gone = self.layers.len(); + self.layers.retain(|l| !l.is_ruler_guide()); + gone -= self.layers.len(); + for l in &mut self.layers { + if layer_untidy(l) { + gone += tidy_layer(Arc::make_mut(l)); + } + } + let others = self + .symbols + .iter_mut() + .map(|s| &mut s.art) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in others { + if root.is_ruler_guide() { + *root = Arc::new(Node::group(root.id, vec![])); + gone += 1; + } else if any_guide(root) { + gone += drop_guides_below(Arc::make_mut(root)); + } + } + gone + } + + /// Make the saved ruler guides (read from a file: [`Document::guides_to_saved`]'s list) into + /// [`NodeKind::RulerGuide`] nodes, each at the top of its layer, in list order. A guide on a + /// layer or sublayer the document has goes on it; one on no layer, or on a layer that is gone, + /// goes on the top visible, unlocked layer ([`Self::default_layer`]), or on a new "Layer 1" + /// when there is none. A guide keeps its saved id when no other node, slice or guide has it, + /// and its name, lock and visibility. The saved list is emptied, so running this again does + /// nothing. Returns how many guides became nodes. + pub fn guides_into_nodes(&mut self) -> usize { + let saved = std::mem::take(&mut self.saved_guides); + if saved.is_empty() { + return 0; + } + let mut used = HashSet::new(); + self.walk(|n| { + used.insert(n.id); + }); + used.extend(self.slices.iter().map(|s| s.id)); + used.extend(self.assets.iter().map(|a| NodeId(a.id))); + let kept: Vec> = saved.iter().map(|g| g.id.filter(|id| id.0 > 0 && id.0 < MAX_NEXT_ID && used.insert(*id))).collect(); + if let Some(max) = kept.iter().flatten().map(|id| id.0).max() { + self.raise_next_id(max.saturating_add(1)); + } + let mut home = None; + // The guides of each layer, in list order. + let mut by_layer: BTreeMap>> = BTreeMap::new(); + let mut order = vec![]; + for (g, id) in saved.iter().zip(kept) { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => match home { + Some(h) => h, + None => { + let h = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + home = Some(h); + h + } + }, + }; + let id = id.unwrap_or_else(|| self.alloc_id()); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if !by_layer.contains_key(&layer) { + order.push(layer); + } + by_layer.entry(layer).or_default().push(Arc::new(n)); + } + for layer in order { + let guides = by_layer.remove(&layer).unwrap_or_default(); + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.extend(guides); + } + } + saved.len() + } + + /// Take the ruler guide nodes out of the layers and put them in the saved list, by ascending + /// id, each naming its layer, after any entries not yet made into nodes: the form a file keeps + /// them in, readable by versions from before ruler guides were nodes. For saving a copy of + /// the document; [`Self::guides_into_nodes`] reverses it. + pub fn guides_to_saved(&mut self) { + let guides = self.ruler_guides(); + if guides.is_empty() { + return; + } + for l in &mut self.layers { + if l.holds_ruler_guides() { + Arc::make_mut(l).strip_ruler_guides(); + } + } + self.saved_guides.extend(guides); + } + + /// Every ruler guide, by ascending id, with the layer or sublayer it is on, its id, name and + /// its own lock and visibility. A guide's place in this list is its index in the guide + /// commands. + pub fn ruler_guides(&self) -> Vec { + if let Some(listed) = self.guide_cache.get(&self.layers) { + return listed.as_ref().clone(); + } + fn visit(layer: &Node, out: &mut Vec) { + for c in layer.children().into_iter().flatten() { + if let Some(g) = guide_of(c, layer.id) { + out.push(g); + } else if c.is_layer() { + visit(c, out); + } + } + } + let mut out = vec![]; + for l in &self.layers { + visit(l, &mut out); + } + out.sort_by_key(|g| g.id); + self.guide_cache.set(&self.layers, &out); + out + } + + /// Ruler guide `id`, as [`Self::ruler_guides`] lists it. None when `id` is not a ruler guide + /// on a layer. + pub fn ruler_guide(&self, id: NodeId) -> Option { + let layer = self.parent_of(id)?; + guide_of(self.node(id)?, layer) + } + + /// Change ruler guide `id` with `f` (its line, its artboard; the layer, id, name, lock and + /// visibility fields are not read back). Returns false when `id` is not a ruler guide. + pub fn edit_ruler_guide(&mut self, id: NodeId, f: impl FnOnce(&mut Guide)) -> bool { + let Some(mut g) = self.ruler_guide(id) else { return false }; + f(&mut g); + let Some(n) = self.node_mut(id) else { return false }; + n.kind = NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }; + true + } + + /// Add ruler guide `g` as a node with a new id at the top of its layer: `g.layer` when the + /// document has that layer or sublayer, else the top visible, unlocked layer + /// ([`Self::default_layer`]), or a new "Layer 1" when there is none. Its name, lock and + /// visibility come from `g`. Returns the new id. + pub fn add_ruler_guide(&mut self, g: &Guide) -> NodeId { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => self.default_layer().unwrap_or_else(|| self.add_layer(None)), + }; + let id = self.alloc_id(); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.push(Arc::new(n)); + } + id + } + + /// Remove node `id`, the temporary layer of opacity-mask, symbol or pattern editing, keeping the + /// ruler guides on it and on its sublayers: they go to the top of the top visible, unlocked + /// layer left ([`Self::default_layer`]), or of a new "Layer 1" when no layer is left, where a + /// saved guide whose layer is gone opens. Returns the removed node, without the guides. + pub fn remove_edit_layer(&mut self, id: NodeId) -> Result, crate::DocError> { + let mut layer = self.remove(id)?; + if !layer.holds_ruler_guides() { + return Ok(layer); + } + let mut guides = vec![]; + layer.walk(&mut |c| { + if c.is_ruler_guide() { + guides.push(Arc::new(c.clone())); + } + }); + Arc::make_mut(&mut layer).strip_ruler_guides(); + let home = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + self.children_mut(Some(home))?.extend(guides); + Ok(layer) + } + /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose /// artboard is gone. @@ -179,79 +370,39 @@ impl Document { Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// The ruler guide node `g` names, if this document has it. + fn guide_node(&self, g: &Guide) -> Option { + g.id.filter(|id| self.node(*id).is_some_and(Node::is_ruler_guide)) + } + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. fn guide_layer(&self, g: &Guide) -> Option { g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) } - /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is - /// visible? A guide whose layer is gone shows as one on no layer. + /// Is ruler guide `g` shown: its node, and the layers it is on, visible? A guide that is not a + /// node of this document shows unless hidden itself or on a hidden layer. pub fn guide_shown(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_visible(l)) - } - - /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? - pub fn guide_editable(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_editable(l)) - } - - /// Put the ruler guides on no layer this document has (from files saved before guides had - /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. - pub fn rehome_guides(&mut self, layer: NodeId) -> usize { - let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); - let mut n = 0; - for (g, lost) in self.guides.iter_mut().zip(lost) { - if lost { - g.layer = Some(layer); - n += 1; - } + match self.guide_node(g) { + Some(id) => self.is_visible(id), + None => !g.hidden && self.guide_layer(g).is_none_or(|l| self.is_visible(l)), } - n } - /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that - /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. - pub fn adopt_guides(&mut self) -> usize { - self.default_layer().map_or(0, |home| self.rehome_guides(home)) + /// Can ruler guide `g` be picked, moved and deleted: shown, and neither it nor its layers + /// locked? + pub fn guide_editable(&self, g: &Guide) -> bool { + match self.guide_node(g) { + Some(id) => self.is_editable(id), + None => !g.hidden && !g.locked && self.guide_layer(g).is_none_or(|l| self.is_editable(l)), + } } - /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in - /// step. Guides on no layer stay. Returns how many went. - pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { - let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); - self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) - } - - /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made - /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies - /// the objects on it. Returns how many. - pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { - let layers = |n: &Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if c.is_layer() { - out.push(c.id); - } - }); - out - }; - let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); - let copies: Vec = self - .guides - .iter() - .filter_map(|g| { - let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; - Some(Guide { layer: Some(to), ..g.clone() }) - }) - .collect(); - let n = copies.len(); - self.guides.extend(copies); - n - } - - /// The ruler guides on layer `layer` (not its sublayers'), by index. - pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { - self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + /// The ruler guides directly on layer `layer` (not its sublayers'), by ascending id. + pub fn guides_on(&self, layer: NodeId) -> Vec { + let mut out: Vec = self.children(Some(layer)).into_iter().flatten().filter_map(|c| guide_of(c, layer)).collect(); + out.sort_by_key(|g| g.id); + out } /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? @@ -260,45 +411,86 @@ impl Document { self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) } - /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their - /// place among the rest (the selected art stays selected). Returns how many went. - pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { - let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); - // Where each kept guide ends up. - let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); - for k in &kept { - to.push(k.then_some(n)); - n += usize::from(*k); + /// Keep the ruler guides `keep` accepts, deleting the others; `sel` forgets the deleted ones + /// (the selected art stays selected). Returns how many went. + pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(&Guide) -> bool) -> usize { + let gone: HashSet = self.ruler_guides().into_iter().filter(|g| !keep(g)).filter_map(|g| g.id).collect(); + if gone.is_empty() { + return 0; + } + fn drop(layer: &mut Arc, gone: &HashSet) { + fn holds(n: &Node, gone: &HashSet) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| gone.contains(&c.id) || holds(c, gone))) + } + if !holds(layer, gone) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + ch.retain(|c| !(c.is_ruler_guide() && gone.contains(&c.id))); + for c in ch.iter_mut().filter(|c| c.is_layer()) { + drop(c, gone); + } + } + for l in &mut self.layers { + drop(l, &gone); + } + sel.guides.retain(|id| !gone.contains(id)); + if sel.key_guide.is_some_and(|k| gone.contains(&k)) { + sel.key_guide = None; + } + gone.len() + } + + /// Change the line of every ruler guide on artboard `id` with `f` (whether it is vertical, its + /// position). + fn edit_artboard_guides(&mut self, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn visit(layer: &mut Arc, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn holds(n: &Node, id: u32) -> bool { + n.is_layer() + && n.children().is_some_and(|ch| { + ch.iter().any(|c| matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) || holds(c, id)) + }) + } + if !holds(layer, id) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + for c in ch.iter_mut() { + if matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) { + if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { + f(*vertical, pos); + } + } else if c.is_layer() { + visit(c, id, f); + } + } + } + for l in &mut self.layers { + visit(l, id, f); } - sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); - sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); - let before = self.guides.len(); - let mut flags = kept.into_iter(); - self.guides.retain(|_| flags.next().unwrap_or(true)); - before - self.guides.len() } /// Move the guides of artboard `id` by `d` (along with their artboard). pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - *g = g.moved(d); - } + self.edit_artboard_guides(id, &mut |vertical, pos| *pos += if vertical { d.x } else { d.y }); } /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork /// with Artboard). pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; - } + self.edit_artboard_guides(id, &mut |vertical, pos| { + *pos = if vertical { (xf * Point::new(*pos, 0.0)).x } else { (xf * Point::new(0.0, *pos)).y }; + }); } /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their - /// artboard). + /// artboard), each on its guide's layer with a new id. pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { let copies: Vec = - self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); - self.guides.extend(copies); + self.ruler_guides().into_iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); + for g in &copies { + self.add_ruler_guide(g); + } } /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of @@ -367,3 +559,122 @@ impl Document { n } } + +/// Does `n`, or anything below it (opacity-mask art included), hold a ruler guide? +fn any_guide(n: &Node) -> bool { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |m| any |= m.is_ruler_guide()); + any +} + +/// Remove every ruler guide below `n`, in its members and its opacity-mask art (`n` itself +/// stays). Mask art that is a ruler guide becomes an empty group. Returns how many went. +fn drop_guides_below(n: &mut Node) -> usize { + let mut gone = drop_mask_guides(n); + if let Some(ch) = n.children_mut() { + let before = ch.len(); + ch.retain(|c| !c.is_ruler_guide()); + gone += before - ch.len(); + for c in ch.iter_mut().filter(|c| any_guide(c)) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + gone +} + +/// Remove every ruler guide in the opacity-mask art of `n`. Mask art that is a ruler guide +/// becomes an empty group. Returns how many went. +fn drop_mask_guides(n: &mut Node) -> usize { + let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) else { return 0 }; + if mask.art.is_ruler_guide() { + mask.art = Arc::new(Node::group(mask.art.id, vec![])); + 1 + } else if any_guide(&mask.art) { + drop_guides_below(Arc::make_mut(&mut mask.art)) + } else { + 0 + } +} + +/// Does layer `l` hold a ruler guide out of place: below art, in an object or in mask art, or so +/// in one of its sublayers? +fn layer_untidy(l: &Node) -> bool { + let Some(ch) = l.children() else { return false }; + let start = Node::guide_run_start(ch); + l.extras().mask.as_deref().is_some_and(|m| any_guide(&m.art)) + || ch.iter().take(start).any(|c| c.is_ruler_guide() || if c.is_layer() { layer_untidy(c) } else { any_guide(c) }) +} + +/// [`Document::tidy_ruler_guides`] for layer `l`. Returns how many guides were removed. +fn tidy_layer(l: &mut Node) -> usize { + let mut gone = drop_mask_guides(l); + let Some(ch) = l.children_mut() else { return gone }; + for c in ch.iter_mut() { + if c.is_layer() { + if layer_untidy(c) { + gone += tidy_layer(Arc::make_mut(c)); + } + } else if !c.is_ruler_guide() && any_guide(c) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + if ch.iter().take(Node::guide_run_start(ch)).any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = std::mem::take(ch).into_iter().partition(|c| c.is_ruler_guide()); + *ch = art; + ch.extend(guides); + } + gone +} + +/// Node `n`, on layer `layer`, as a [`Guide`] value, if it is a ruler guide. +fn guide_of(n: &Node, layer: NodeId) -> Option { + let NodeKind::RulerGuide { vertical, pos, artboard } = n.kind else { return None }; + Some(Guide { vertical, pos, artboard, layer: Some(layer), id: Some(n.id), name: n.name.clone(), locked: n.locked, hidden: !n.visible }) +} + +/// The list [`Document::ruler_guides`] last made, with weak pointers to the top-level layers it was +/// made from. While a weak pointer to a layer's allocation is held, nothing else can be at that +/// address, and the layer can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut` +/// moves it), so any edit inside a layer, and any layer added, removed or replaced, gives a list of +/// layers the cache does not match. Clones of a document share the cached list. +#[derive(Default)] +pub(crate) struct GuideCache(std::sync::RwLock>); + +/// The top-level layers a guide list was made from, and the list. +type Listed = (Vec>, Arc>); + +impl GuideCache { + /// The cached list, when it was made from `layers`. + fn get(&self, layers: &[Arc]) -> Option>> { + let g = self.0.read().ok()?; + let (from, listed) = g.as_ref()?; + let same = from.len() == layers.len() && from.iter().zip(layers).all(|(w, l)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(l))); + same.then(|| listed.clone()) + } + + /// Keep `listed`, made from `layers`. + fn set(&self, layers: &[Arc], listed: &[Guide]) { + if let Ok(mut g) = self.0.write() { + *g = Some((layers.iter().map(Arc::downgrade).collect(), Arc::new(listed.to_vec()))); + } + } +} + +impl Clone for GuideCache { + fn clone(&self) -> Self { + GuideCache(std::sync::RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for GuideCache { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for GuideCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GuideCache") + } +} diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 15f04ce15..f3b655d76 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -136,9 +136,11 @@ impl crate::Document { } } -/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the -/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or -/// across the top, or in pie wedges). `None` draws the default 9 pt black. +/// The character style and anti-aliasing method a graph's labels keep through regeneration once formatted (Format +/// the text in graphs): the value axis numbers, the category labels (category axis, radar spokes, pie names) and the +/// legend labels (beside or across the top, or in pie wedges). `None` draws the default 9 pt black. The `*_para` +/// fields are the paragraph attributes each kind keeps the same way (`None`: the defaults); the graph still sets each +/// label's alignment. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LabelStyles { @@ -148,6 +150,22 @@ pub struct LabelStyles { pub categories: Option, #[serde(skip_serializing_if = "Option::is_none")] pub legend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub values_para: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_para: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_para: Option, + /// The anti-aliasing method the value axis numbers keep once set (Character panel › Anti-aliasing Method). + /// `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_anti_alias: Option, + /// The anti-aliasing method the category labels keep once set. `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_anti_alias: Option, + /// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_anti_alias: Option, } impl LabelStyles { @@ -525,7 +543,9 @@ impl GraphSpec { out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true)); out.push(','); } - out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::>().join(",")); + let line = r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::>().join(","); + // A lone blank value would be an empty line, which readers skip; a quoted empty field keeps the row. + out.push_str(if line.is_empty() && !labelled { "\"\"" } else { &line }); out.push('\n'); } out diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs index 08f828391..a3fb9969e 100644 --- a/crates/doc/src/guides.rs +++ b/crates/doc/src/guides.rs @@ -1,5 +1,8 @@ use super::{Deserialize, NodeId, Serialize, Vec2}; +/// A ruler guide as a value: an entry of the saved `guides` list, or a ruler guide node of a +/// document as [`crate::Document::ruler_guides`] lists it. In a document, ruler guides are +/// [`crate::NodeKind::RulerGuide`] nodes on their layers. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). @@ -14,12 +17,25 @@ pub struct Guide { /// from before guides had layers, and from files that don't say), always there. #[serde(default, skip_serializing_if = "Option::is_none")] pub layer: Option, + /// The id of its node. None in an entry written before ruler guides were nodes: it gets a new + /// id when the document is read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The name it shows in the Layers panel, if it was given one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Locked on its own (its layer's lock is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub locked: bool, + /// Hidden on its own (its layer's visibility is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hidden: bool, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None, layer: None } + Self { vertical, pos, artboard: None, layer: None, id: None, name: None, locked: false, hidden: false } } /// Moved by `d` (a vertical guide across, a horizontal one down). diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 14bda9a00..94fbb5ff5 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -588,7 +588,8 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols if !c.visible || editable_only && (c.locked || c.is_template()) { return false; } - if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { + // A ruler guide is picked by the guide commands, never by an object hit test. + if c.is_ruler_guide() || opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { return false; } // (An envelope's content sits where it was, not where the envelope draws it.) @@ -870,10 +871,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline) } // An image is hit within its frame (on the frame's edge only with Selection by Path Only, - // #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline - // mode. + // #3291, and in outline mode where the frame is all that is drawn, #6437), and where its + // own fills and strokes paint ([`image_paint`]) except in outline mode. NodeKind::Image(im) => { - if opt.path_only { + if opt.path_only || (opt.outline && !symbols.document().setup.outline_images) { use vectorcraft_geom::kurbo::Shape; let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1); frame.apply_affine(im.xf); @@ -909,6 +910,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio if stroke_contains(&bp, 0.0, opt.tol, p) { return Some(HitKind::Outline); } + // Outline mode draws the mesh lines, not the colours between them (#6437). + if opt.outline { + return stroke_contains(&m.lines().to_bezpath(), 0.0, opt.tol, p).then_some(HitKind::Outline); + } (!opt.path_only && fill_contains(&bp, vectorcraft_geom::FillRule::NonZero, p)).then_some(HitKind::Fill) } _ => None, @@ -1252,7 +1257,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh // marquee touches none of it, as clicks don't (#5309). Some(s) if !doc.is_editable(s.id) => vec![], Some(s) => match s.children() { - Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), + Some(c) => c.iter().enumerate().filter(|(i, c)| !c.is_ruler_guide() && !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it. None => doc .parent_of(s.id) @@ -1281,7 +1286,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh if c.visible && !c.locked && !c.is_template() { out.extend(layer_objects(c, r)); } - } else { + } else if !c.is_ruler_guide() { out.push((c, r)); } } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index cb5b19d1a..2c0a6984d 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -880,6 +880,45 @@ fn path_only_picks_paths_through_images() { assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame"); } +/// #6437: in outline mode an image is drawn as its frame and a gradient mesh as its mesh lines, +/// so a click inside them, on the outline of a path behind, picks that path, and a click on the +/// frame or a mesh line picks the image or the mesh. In Preview they cover the path as before, +/// and an image whose pixels show in outline mode (Show Images in Outline Mode) still does. +#[test] +fn outline_mode_picks_paths_through_images_and_meshes() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (path, image, under, mesh) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); + let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)); + let im = crate::ImageObject { key: "k".into(), width: 80, height: 80, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); + d.insert(Some(l), 2, Node::path(under, shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)), Appearance::default_art())).unwrap(); + let area = shapes::rectangle(Rect::new(110.0, 110.0, 190.0, 190.0)); + let m = crate::GradientMesh::for_path_with(&area, 2, 2, &|_| (Color::BLACK, 1.0)).unwrap(); + d.insert(Some(l), 3, Node::new(mesh, NodeKind::Mesh(m))).unwrap(); + let outline = HitOptions { outline: true, ..Default::default() }; + let hit = |d: &Document, p: Point, opt| hit_test(d, p, opt).map(|h| h.leaf); + // The image: the path's edge and the inside, then the frame. + let (path_edge, inside, frame) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(89.5, 50.0)); + assert_eq!(hit(&d, path_edge, HitOptions::default()), Some(image), "Preview: the image covers the path"); + assert_eq!(hit(&d, inside, HitOptions::default()), Some(image)); + assert_eq!(hit(&d, path_edge, outline), Some(path), "outline: the path through the image"); + assert_eq!(hit(&d, inside, outline), None, "outline: not inside the image"); + assert_eq!(hit(&d, frame, outline), Some(image), "outline: the image's frame"); + // The mesh: the path's edge and the inside, then its edge and a mesh line inside it. + let (under_edge, inside, edge, line) = (Point::new(120.5, 130.0), Point::new(130.0, 170.0), Point::new(189.5, 130.0), Point::new(150.0, 170.0)); + assert_eq!(hit(&d, under_edge, HitOptions::default()), Some(mesh), "Preview: the mesh covers the path"); + assert_eq!(hit(&d, inside, HitOptions::default()), Some(mesh)); + assert_eq!(hit(&d, under_edge, outline), Some(under), "outline: the path through the mesh"); + assert_eq!(hit(&d, inside, outline), None, "outline: not between the mesh lines"); + assert_eq!(hit(&d, edge, outline), Some(mesh), "outline: the mesh's edge"); + assert_eq!(hit(&d, line, outline), Some(mesh), "outline: a mesh line"); + // Show Images in Outline Mode: the image's pixels are drawn, and it covers the path again. + d.setup.outline_images = true; + assert_eq!(hit(&d, path_edge, outline), Some(image)); +} + /// Type Object Selection by Path Only: point type is picked on its baseline, area type on its /// frame and type on a path on its path, no longer anywhere in its bounds. #[test] diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index 696b27413..e10c424ee 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -110,8 +110,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) { } /// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an -/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no -/// link: changes to it are dropped. +/// image's colorize color, the fills and strokes a dynamic symbol instance keeps for objects of its +/// art, not opacity masks) with its swatch link and tint. Mesh points have no link: changes to it +/// are dropped. pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { for it in &mut n.appearance.items { match it { @@ -119,8 +120,13 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f), } } - if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) { - visit_paint(p, f); + if let Some(e) = n.extras.0.as_deref_mut() { + if let Some(p) = e.colorize.as_deref_mut() { + visit_paint(p, f); + } + for p in e.symbol_overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { + visit_paint(p, f); + } } match &mut n.kind { NodeKind::Text(t) => { @@ -144,7 +150,8 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { } /// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh -/// points) given its swatch link and tint, keeping the links. +/// points, a dynamic symbol instance's own fills and strokes) given its swatch link and tint, +/// keeping the links. pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) { visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint)))); } @@ -158,3 +165,32 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) - map_node_colors(Arc::make_mut(&mut s.art), f); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{NodeId, SymbolOverride}; + use vectorcraft_geom::Affine; + + #[test] + fn a_dynamic_instances_own_fills_and_strokes_are_visited() { + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let spot = Paint::Solid { color: red, swatch: Some("Spot".into()), tint: 0.5 }; + let mut inst = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY }); + inst.extras_mut().symbol_overrides = vec![ + SymbolOverride { path: vec![0], fill: Some(spot), stroke: Some(Paint::solid(red)), ..Default::default() }, + SymbolOverride { path: vec![1], opacity: Some(0.5), ..Default::default() }, + ]; + let mut links = vec![]; + map_node_colors(&mut inst, &mut |_, link| { + links.push(link.map(|(s, t)| (s.to_string(), t))); + blue + }); + assert_eq!(links, vec![Some(("Spot".to_string(), 0.5)), None], "each override paint is visited once, with its link"); + let o = &inst.extras().symbol_overrides; + assert_eq!(o[0].fill, Some(Paint::Solid { color: blue, swatch: Some("Spot".into()), tint: 0.5 }), "the link is kept"); + assert_eq!(o[0].stroke, Some(Paint::solid(blue))); + assert_eq!((o[1].fill.as_ref(), o[1].stroke.as_ref()), (None, None), "an override without paint gets none"); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5735d9b48..41a684abd 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -207,8 +207,12 @@ pub struct Document { /// Object › Graph › Design…: art graphs can draw as markers. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub graph_designs: Vec, - #[serde(default)] - pub guides: Vec, + /// The saved form of the ruler guides: read from a file, then made into + /// [`NodeKind::RulerGuide`] nodes on their layers ([`Document::guides_into_nodes`]), and + /// written from those nodes when saving ([`Document::guides_to_saved`]). Empty in between. + /// Each entry read counts as an object against the read's node budget. + #[serde(default, rename = "guides", deserialize_with = "crate::node::counted_list")] + saved_guides: Vec, /// View → New View… (at most [`SavedView::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, @@ -339,6 +343,11 @@ pub struct Document { /// names it as the file's active artboard. `None`: the first. Native files do not keep it. #[serde(skip)] pub opened_artboard: Option, + /// The file this document was read from recorded its units (a `.ai` file's + /// `%AI5_RulerUnits`): it opens in them. A file that records none opens in Preferences › + /// Units › General (#5820). Native files do not keep it: they always keep their units. + #[serde(skip)] + pub units_from_file: bool, /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this /// is set, so documents without any pay nothing for them. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -346,6 +355,9 @@ pub struct Document { /// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved. #[serde(skip)] index: id_index::IdIndex, + /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. + #[serde(skip)] + guide_cache: document_guides::GuideCache, } fn ppi72() -> f64 { diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 4a864a6e9..304c4e70f 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -643,6 +643,168 @@ mod tests { assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}"); } + /// The polylines of `bp` flattened to 0.01 pt. + fn flattened(bp: &BezPath) -> Vec> { + let mut out: Vec> = vec![]; + vectorcraft_geom::kurbo::flatten(bp.iter(), 0.01, |el| match el { + vectorcraft_geom::PathEl::MoveTo(p) => out.push(vec![p]), + vectorcraft_geom::PathEl::LineTo(p) => out.last_mut().into_iter().for_each(|v| v.push(p)), + vectorcraft_geom::PathEl::ClosePath => out.last_mut().into_iter().for_each(|v| v.push(v[0])), + _ => {} + }); + out + } + + /// The distance from `p` to the nearest of `lines`. + fn off_lines(lines: &[Vec], p: Point) -> f64 { + lines + .iter() + .flat_map(|v| v.windows(2)) + .map(|w| { + let d = w[1] - w[0]; + let t = if d.hypot2() > 0.0 { ((p - w[0]).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(w[0] + d * t) + }) + .fold(f64::INFINITY, f64::min) + } + + /// How far apart the border of `m` and the outline of `path` are, in points: the larger of the + /// farthest a point of either lies from the other. + fn mesh_border_off_path(m: &GradientMesh, path: &PathData) -> f64 { + let (border, outline) = (flattened(&m.outline().to_bezpath()), flattened(&path.to_bezpath())); + let one = border.iter().flatten().map(|p| off_lines(&outline, *p)).fold(0.0, f64::max); + let other = outline.iter().flatten().map(|p| off_lines(&border, *p)).fold(0.0, f64::max); + one.max(other) + } + + /// #2752: a gradient mesh keeps the exact outline of the path it is made from, sharp corners + /// included, whether made by Create Gradient Mesh (1×1 and 4×4) or by a Mesh tool click at a + /// point inside. Before, the border of each mesh was one cubic per edge between four corners + /// near the bounding box's: up to 49 pt off a five-point star, 54 pt off a notched rectangle. + #[test] + fn a_mesh_keeps_the_outline_of_every_shape() { + const OFF: f64 = 0.1; + let poly = |pts: &[(f64, f64)]| PathData::single(SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true)); + let curves = |start: (f64, f64), cubics: &[[(f64, f64); 3]]| { + let mut bp = BezPath::new(); + bp.move_to(start); + for [a, b, c] in cubics { + bp.curve_to(*a, *b, *c); + } + bp.close_path(); + PathData::from_bezpath(&bp) + }; + let box_ = Rect::new(100.0, 100.0, 500.0, 400.0); + let heart = curves( + (300.0, 400.0), + &[ + [(200.0, 320.0), (100.0, 250.0), (100.0, 170.0)], + [(100.0, 100.0), (180.0, 70.0), (230.0, 90.0)], + [(270.0, 105.0), (290.0, 130.0), (300.0, 150.0)], + [(310.0, 130.0), (330.0, 105.0), (370.0, 90.0)], + [(420.0, 70.0), (500.0, 100.0), (500.0, 170.0)], + [(500.0, 250.0), (400.0, 320.0), (300.0, 400.0)], + ], + ); + let teardrop = curves( + (300.0, 100.0), + &[ + [(350.0, 200.0), (420.0, 250.0), (420.0, 320.0)], + [(420.0, 386.0), (366.0, 440.0), (300.0, 440.0)], + [(234.0, 440.0), (180.0, 386.0), (180.0, 320.0)], + [(180.0, 250.0), (250.0, 200.0), (300.0, 100.0)], + ], + ); + let shapes: [(&str, PathData, Point); 14] = [ + ("rectangle", shapes::rectangle(box_), Point::new(260.0, 230.0)), + ("rotated rectangle", shapes::rectangle(box_).transformed(Affine::rotate_about(0.5, box_.center())), Point::new(280.0, 240.0)), + ("triangle", poly(&[(300.0, 100.0), (500.0, 400.0), (100.0, 400.0)]), Point::new(300.0, 320.0)), + ("five-point star", shapes::star(Point::new(300.0, 300.0), 200.0, 80.0, 5, 0.0), Point::new(300.0, 300.0)), + ("twelve-point star", shapes::star(Point::new(300.0, 300.0), 200.0, 140.0, 12, 0.0), Point::new(300.0, 300.0)), + ( + "L-shape", + poly(&[(100.0, 100.0), (200.0, 100.0), (200.0, 300.0), (400.0, 300.0), (400.0, 400.0), (100.0, 400.0)]), + Point::new(150.0, 350.0), + ), + ("rounded rectangle", shapes::rounded_rectangle(box_, 40.0), Point::new(260.0, 230.0)), + ("heart", heart, Point::new(300.0, 250.0)), + ("teardrop", teardrop, Point::new(300.0, 330.0)), + ("ellipse", shapes::ellipse(box_), Point::new(200.0, 330.0)), + ("thin rectangle", shapes::rectangle(Rect::new(100.0, 100.0, 500.0, 110.0)), Point::new(260.0, 104.0)), + ( + "notched rectangle", + poly(&[(100.0, 100.0), (250.0, 100.0), (300.0, 200.0), (350.0, 100.0), (500.0, 100.0), (500.0, 400.0), (100.0, 400.0)]), + Point::new(260.0, 300.0), + ), + // A notch shorter than the steps along the outline an edge is checked at, and deeper + // than the tolerance. + ( + "rectangle with a tiny notch", + poly(&[(100.0, 100.0), (301.1, 100.0), (301.2, 100.3), (301.3, 100.0), (500.0, 100.0), (500.0, 400.0), (100.0, 400.0)]), + Point::new(260.0, 300.0), + ), + // An outline that crosses itself, whose two lobes' areas cancel. + ("bow tie", poly(&[(100.0, 100.0), (500.0, 400.0), (500.0, 100.0), (100.0, 400.0)]), Point::new(150.0, 250.0)), + ]; + // Every shape and way of making the mesh is checked, and all that stray are named. + let mut strays = vec![]; + for (name, path, click) in shapes { + for (rows, cols) in [(1, 1), (4, 4)] { + let m = GradientMesh::for_path(&path, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let off = mesh_border_off_path(&m, &path); + if off >= OFF { + strays.push(format!("{name}, {rows}×{cols}: border {off:.2} pt off the outline")); + } + } + let mut m = GradientMesh::for_path(&path, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.add_lines_at(click, 0.0).is_some(), "{name}: the click at {click:?} is outside the mesh"); + let off = mesh_border_off_path(&m, &path); + if off >= OFF { + strays.push(format!("{name}, Mesh tool click: border {off:.2} pt off the outline")); + } + } + assert!(strays.is_empty(), "{strays:#?}"); + } + + /// The lines a mesh takes to keep a path's outline are as few as do it: a rectangle's and an + /// ellipse's meshes keep the rows and columns asked for, and a star's 1×1 mesh takes lines + /// through its points, at most one each way per anchor. Paths without an outline to follow + /// still give a mesh (an open path closes straight; a line there and back maps onto its box), + /// and a point that isn't finite gives none. + #[test] + fn a_mesh_takes_lines_only_where_the_outline_needs_them() { + let box_ = Rect::new(100.0, 100.0, 500.0, 400.0); + for path in [shapes::rectangle(box_), shapes::ellipse(box_)] { + for (rows, cols) in [(1, 1), (4, 4), (2, 5)] { + let m = GradientMesh::for_path(&path, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert_eq!((m.rows, m.cols), (rows, cols)); + } + } + let star = shapes::star(Point::new(300.0, 300.0), 200.0, 80.0, 5, 0.0); + let m = GradientMesh::for_path(&star, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.rows > 1 && m.cols > 1 && m.rows <= 10 && m.cols <= 10 && m.is_valid(), "{}×{}", m.rows, m.cols); + // Every anchor of the star is a point of the mesh. + for a in &star.subpaths[0].anchors { + assert!(m.points.iter().any(|q| q.p.distance(a.p) < 1e-6), "{:?} is not a mesh point", a.p); + } + // An open path closes with a straight segment. + let open = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false)); + let m = GradientMesh::for_path(&open, 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let closed = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], true)); + assert!(mesh_border_off_path(&m, &closed) < 0.1); + // A line there and back. + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 50.0)], true)); + let m = GradientMesh::for_path(&line, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.is_valid() && m.points.iter().all(|q| q.p.is_finite())); + let mut nan = shapes::rectangle(box_); + nan.subpaths[0].anchors[1].p.x = f64::NAN; + assert!(GradientMesh::for_path(&nan, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).is_none()); + // A path of many anchors stays within the line limit and keeps its outline. + let many = shapes::star(Point::new(300.0, 300.0), 200.0, 190.0, 400, 0.0); + let m = GradientMesh::for_path(&many, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.is_valid() && m.rows as usize <= mesh::MAX_BORDER_LINES && m.cols as usize <= mesh::MAX_BORDER_LINES, "{}×{}", m.rows, m.cols); + } + #[test] fn mesh_insert_and_remove_lines() { let mut m = diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 66d20f27b..1cc8ba086 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -140,6 +140,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { } // Its art is plain, so it warps at this depth. NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), + // A ruler guide has no shape to warp. + NodeKind::RulerGuide { .. } => {} } out } diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index ca08a3a2a..c59374dee 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -470,6 +470,384 @@ fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(V fit } +/// The cubic from `p0` to `p3` leaving `p0` along `d0` and reaching `p3` against `d1` (unit +/// vectors; `d1` points from `p3` back into the curve) that comes closest (least squares) to the +/// points `qs`, which lie along the way in order; `None` when no such cubic has handles of positive +/// length. Each point is matched to the curve's nearest point, refined over a few rounds, as in +/// [`fit_edge`]. +fn fit_tangents(qs: &[Point], (p0, d0): (Point, Vec2), (p3, d1): (Point, Vec2)) -> Option { + const ROUNDS: usize = 6; + let n = qs.len() + 1; + let mut ts: Vec = (1..n).map(|i| i as f64 / n as f64).collect(); + let mut fit = None; + for _ in 0..ROUNDS { + let (mut c11, mut c12, mut c22, mut x1, mut x2) = (0.0, 0.0, 0.0, 0.0, 0.0); + for (q, &t) in qs.iter().zip(&ts) { + let s = 1.0 - t; + let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t); + let (a1, a2) = (d0 * b1, d1 * b2); + let r = q.to_vec2() - (b0 + b1) * p0.to_vec2() - (b2 + b3) * p3.to_vec2(); + c11 += a1.dot(a1); + c12 += a1.dot(a2); + c22 += a2.dot(a2); + x1 += r.dot(a1); + x2 += r.dot(a2); + } + let det = c11 * c22 - c12 * c12; + if det.abs() < 1e-12 { + break; + } + let (l1, l2) = ((c22 * x1 - c12 * x2) / det, (c11 * x2 - c12 * x1) / det); + if !(l1.is_finite() && l2.is_finite() && l1 > 0.0 && l2 > 0.0) { + break; + } + let c = CubicBez::new(p0, p0 + d0 * l1, p3 + d1 * l2, p3); + fit = Some(c); + let (a, b, k) = (c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2); + for (q, t) in qs.iter().zip(ts.iter_mut()) { + let s = 1.0 - *t; + let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k); + let acc = 6.0 * (s * (b - a) + *t * (k - b)); + let e = c.eval(*t) - *q; + let den = v.hypot2() + e.dot(acc); + if den.abs() > 1e-12 { + *t = (*t - e.dot(v) / den).clamp(0.0, 1.0); + } + } + } + fit +} + +/// How far (in points) a border edge of a mesh made from a path may stray from the path's outline +/// before the mesh takes a line through an anchor of the outline there; a thousandth of the outline's +/// size when that is less. +const BORDER_TOLERANCE: f64 = 0.1; + +/// The most lines each way a mesh made from a path takes to keep its border on the outline +/// ([`BORDER_TOLERANCE`]); past that, each border edge keeps its closest fit. +pub(super) const MAX_BORDER_LINES: usize = 256; + +/// The most rounds of new lines a mesh made from a path takes to keep its border on the outline. +const MAX_BORDER_ROUNDS: usize = 64; + +/// An anchor whose directions in and out differ by more than this (radians) is a corner: a border +/// edge that can't follow the outline takes a mesh line through its sharpest corner first. +const SHARP_TURN: f64 = 0.01; + +/// Steps of each segment's parameter at which [`Outline`] measures the segment's length. +const LENGTH_STEPS: usize = 32; + +/// Points of the outline each border edge of a mesh made from a path is fitted to +/// ([`Outline::fit`]). +const FIT_POINTS: usize = 32; + +/// Points of the outline, and of the fitted edge, at which [`Outline::fit`] measures how far the +/// two stray from each other. +const CHECK_POINTS: usize = 128; + +/// The outline a gradient mesh is made from: the largest subpath of a path, running clockwise on +/// screen, an open one closed by a straight segment, without segments of no length. A place on it +/// is a length from its first anchor, measured along a polyline through even steps of each +/// segment's parameter; the same length always gives the same point. +struct Outline { + segs: Vec, + /// Where each segment starts, and the whole length last. + starts: Vec, + /// For each segment, the length from its start at each of [`LENGTH_STEPS`] + 1 even steps of + /// its parameter. + table: Vec<[f64; LENGTH_STEPS + 1]>, + /// Lengths closer than this are the same place. + eps: f64, +} + +/// One side of the patch a mesh made from a path spans: the stretch of the outline from `start`, +/// `len` long, run backwards when `reversed` (the bottom and left sides run against the outline). +#[derive(Clone, Copy)] +struct Side { + start: f64, + len: f64, + reversed: bool, +} + +impl Side { + /// The place on the outline at `f` (0 to 1) along the side. + fn at(&self, f: f64) -> f64 { + self.start + if self.reversed { (1.0 - f) * self.len } else { f * self.len } + } +} + +/// The direction a cubic leaves its start in (`false`) or reaches its end in (`true`), from the +/// first handle or point that is not at the end; zero when all its points are together. +fn cubic_tangent(c: &CubicBez, end: bool) -> Vec2 { + let near = 1e-12 * (c.p0.to_vec2().hypot() + c.p3.to_vec2().hypot() + 1.0); + let options = if end { [c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0] } else { [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0] }; + options.into_iter().find(|v| v.hypot() > near).unwrap_or(Vec2::ZERO) +} + +impl Outline { + /// The outline of `path`; `None` when it has no subpath of two anchors or more, a point that + /// isn't finite, or all its points on one line (a line there and back). + fn new(path: &PathData) -> Option { + let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; + if !sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return None; + } + let mut sp = sp.clone(); + if sp.signed_area() < 0.0 { + sp.reverse(); + } + // A straight segment's parameter runs evenly along it. + let line = |a: Point, b: Point| CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b); + let mut segs: Vec = + (0..sp.segment_count()).map(|i| if sp.segment_is_line(i) { line(sp.segment(i).p0, sp.segment(i).p3) } else { sp.segment(i) }).collect(); + if !sp.closed + && let (Some(a), Some(b)) = (sp.anchors.last(), sp.anchors.first()) + { + segs.push(line(a.p, b.p)); + } + segs.retain(|c| [c.p1, c.p2, c.p3].iter().any(|p| p.distance(c.p0) > 1e-9)); + if segs.len() < 2 { + return None; + } + let mut starts = Vec::with_capacity(segs.len() + 1); + let mut table = Vec::with_capacity(segs.len()); + let mut total = 0.0; + for c in &segs { + let mut lens = [0.0; LENGTH_STEPS + 1]; + let mut prev = c.p0; + for (k, len) in lens.iter_mut().enumerate().skip(1) { + let p = c.eval(k as f64 / LENGTH_STEPS as f64); + *len = prev.distance(p); + prev = p; + } + for k in 1..=LENGTH_STEPS { + lens[k] += lens[k - 1]; + } + starts.push(total); + total += lens[LENGTH_STEPS]; + table.push(lens); + } + starts.push(total); + if !(total.is_finite() && total > 0.0) { + return None; + } + // An outline whose points all lie on one line encloses nothing; one that crosses itself + // (a bow tie) may have areas that cancel, and is still followed. + let pts: Vec = segs.iter().flat_map(|c| [c.p0, c.p1, c.p2, c.p3]).collect(); + let first = pts.first().copied()?; + let far = pts.iter().copied().max_by(|a, b| a.distance_squared(first).total_cmp(&b.distance_squared(first)))?; + let dir = far - first; + let width = pts.iter().map(|p| ((*p - first).cross(dir) / dir.hypot()).abs()).fold(0.0, f64::max); + if !(dir.hypot() > 0.0 && width > 1e-9 * total) { + return None; + } + Some(Self { segs, starts, table, eps: total * 1e-9 }) + } + + fn total(&self) -> f64 { + self.starts.last().copied().unwrap_or_default() + } + + /// The bounding box of the outline. + fn bounds(&self) -> Option { + self.segs.iter().map(|c| c.bounding_box()).reduce(|a, b| a.union(b)) + } + + /// The parameter of segment `i` `local` along it (clamped to the segment). + fn seg_t(&self, i: usize, local: f64) -> f64 { + let Some(lens) = self.table.get(i) else { return 0.0 }; + let k = lens.partition_point(|x| *x <= local).clamp(1, LENGTH_STEPS) - 1; + let (l0, l1) = (lens[k], lens[k + 1]); + let f = if l1 > l0 { ((local - l0) / (l1 - l0)).clamp(0.0, 1.0) } else { 0.0 }; + (k as f64 + f) / LENGTH_STEPS as f64 + } + + /// The segment and its parameter at place `s` (wrapping around); a place at the very end of a + /// segment is the start of the next. + fn locate(&self, s: f64) -> (usize, f64) { + let n = self.segs.len(); + let s = s.rem_euclid(self.total()); + let mut i = self.starts.partition_point(|x| *x <= s).clamp(1, n) - 1; + if self.starts.get(i + 1).is_some_and(|end| end - s <= self.eps) { + i = (i + 1) % n; + return (i, 0.0); + } + (i, self.seg_t(i, s - self.starts[i])) + } + + fn point(&self, s: f64) -> Point { + let (i, t) = self.locate(s); + self.segs.get(i).map_or(Point::ZERO, |c| c.eval(t)) + } + + /// The anchors strictly inside the stretch from `a`, `len` long: each one's distance from `a` + /// and the segment it starts. + fn anchors_in(&self, a: f64, len: f64) -> Vec<(f64, usize)> { + let (total, n) = (self.total(), self.segs.len()); + let a = a.rem_euclid(total); + let (lo, hi) = (a + self.eps, a + len - self.eps); + let mut out = vec![]; + for lap in [0.0, total] { + let from = self.starts[..n].partition_point(|x| x + lap <= lo); + for (i, start) in self.starts[..n].iter().enumerate().skip(from) { + let p = start + lap; + if p >= hi { + break; + } + out.push((p - a, i)); + } + } + out + } + + /// The piece of the outline from `a`, `len` long, which no anchor lies inside. + fn piece(&self, a: f64, len: f64) -> CubicBez { + let (i, t0) = self.locate(a); + let Some(c) = self.segs.get(i) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) }; + // At a segment's start (a place at the end of the one before counts as that), none of it is behind. + let behind = if t0 == 0.0 { 0.0 } else { a.rem_euclid(self.total()) - self.starts[i] }; + let t1 = self.seg_t(i, behind + len); + c.subsegment(t0..t1.max(t0)) + } + + /// The direction the outline leaves place `s` in (`into` false) or reaches it in (`into` true). + fn tangent(&self, s: f64, into: bool) -> Vec2 { + let n = self.segs.len(); + let (i, t) = self.locate(s); + let c = match (into, t <= 0.0) { + (true, true) => self.segs[(i + n - 1) % n], + (true, false) => self.segs[i].subsegment(0.0..t), + (false, _) => self.segs[i].subsegment(t..1.0), + }; + cubic_tangent(&c, into) + } + + /// How sharply the outline turns at the anchor starting segment `i` (radians, 0 to π). + fn turn(&self, i: usize) -> f64 { + let s = self.starts[i]; + let (din, dout) = (self.tangent(s, true), self.tangent(s, false)); + din.cross(dout).atan2(din.dot(dout)).abs() + } + + /// The place on the outline nearest `q`. + fn nearest(&self, q: Point) -> f64 { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let mut best = (f64::INFINITY, 0.0); + for (i, c) in self.segs.iter().enumerate() { + let near = c.nearest(q, 1e-9); + if near.distance_sq < best.0 { + // The length at that parameter, from the table. + let lens = &self.table[i]; + let x = near.t * LENGTH_STEPS as f64; + let k = (x.floor() as usize).min(LENGTH_STEPS - 1); + let local = lens[k] + (lens[k + 1] - lens[k]) * (x - k as f64); + best = (near.distance_sq, self.starts[i] + local); + } + } + best.1 + } + + /// The cubic standing for the stretch from `a`, `len` long, and how far it strays from it: the + /// piece of the segment when no anchor lies inside, otherwise a cubic fitted to the outline with + /// its ends along the outline's own directions there. + fn fit(&self, a: f64, len: f64) -> (CubicBez, f64) { + let (p0, p3) = (self.point(a), self.point(a + len)); + if len <= self.eps { + return (CubicBez::new(p0, p0, p0, p0), 0.0); + } + let anchors = self.anchors_in(a, len); + if anchors.is_empty() { + return (self.piece(a, len), 0.0); + } + let qs: Vec = (1..FIT_POINTS).map(|i| self.point(a + len * i as f64 / FIT_POINTS as f64)).collect(); + let (d0, d1) = (self.tangent(a, false), -self.tangent(a + len, true)); + let unit = |v: Vec2| if v.hypot() > 0.0 { Some(v / v.hypot()) } else { None }; + let straight = CubicBez::new(p0, p0.lerp(p3, 1.0 / 3.0), p0.lerp(p3, 2.0 / 3.0), p3); + let c = match (unit(d0), unit(d1)) { + (Some(d0), Some(d1)) => fit_tangents(&qs, (p0, d0), (p3, d1)), + _ => None, + } + .unwrap_or(straight); + // Both ways: the outline's points from the cubic, and the cubic's points from the outline. + // The outline is checked at even steps and at every anchor inside, so a spike or a notch + // narrower than a step is not missed. + use vectorcraft_geom::kurbo::ParamCurveNearest; + let mut at: Vec = (0..=CHECK_POINTS).map(|i| len * i as f64 / CHECK_POINTS as f64).chain(anchors.iter().map(|(o, _)| *o)).collect(); + at.sort_by(f64::total_cmp); + let line: Vec = at.iter().map(|o| self.point(a + o)).collect(); + let off_outline = line.iter().map(|q| c.nearest(*q, 1e-9).distance_sq.sqrt()).fold(0.0, f64::max); + let off_cubic = (0..=CHECK_POINTS) + .map(|i| { + let p = c.eval(i as f64 / CHECK_POINTS as f64); + line.windows(2) + .map(|w| { + let (u, v) = (w[0], w[1]); + let d = v - u; + let t = if d.hypot2() > 0.0 { ((p - u).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(u + d * t) + }) + .fold(f64::INFINITY, f64::min) + }) + .fold(0.0, f64::max); + (c, off_outline.max(off_cubic)) + } + + /// The border edge of `side` from `f0` to `f1` (0 to 1 along it, rising), run the way the side + /// runs, and when it strays from the outline by more than `tol`, where along the side (0 to 1) + /// a mesh line through an anchor would help: the sharpest corner inside it, or the anchor + /// nearest its middle when none is sharp. + fn edge(&self, side: &Side, f0: f64, f1: f64, tol: f64) -> (CubicBez, Option) { + let len = (f1 - f0) * side.len; + let a = side.at(if side.reversed { f1 } else { f0 }); + let (c, off) = self.fit(a, len); + let c = if side.reversed { CubicBez::new(c.p3, c.p2, c.p1, c.p0) } else { c }; + if off <= tol || side.len <= 0.0 { + return (c, None); + } + let anchors = self.anchors_in(a, len); + let sharpest = anchors.iter().map(|&(o, i)| (self.turn(i), o)).max_by(|x, y| x.0.total_cmp(&y.0)); + let at = match sharpest { + Some((turn, o)) if turn > SHARP_TURN => Some(o), + _ => anchors.iter().map(|&(o, _)| o).min_by(|x, y| (x - len / 2.0).abs().total_cmp(&(y - len / 2.0).abs())), + }; + let split = at.map(|o| if side.reversed { f1 - o / side.len } else { f0 + o / side.len }); + (c, split.filter(|f| *f > f0 && *f < f1)) + } + + /// The cuts (0 to 1) of the lines across sides `one` and `other`: `even`, and as many more as + /// keep every border edge along them within `tol` of the outline (see [`Self::edge`]), at most + /// [`MAX_BORDER_LINES`] in all. + fn cuts(&self, one: &Side, other: &Side, even: Vec, tol: f64) -> Vec { + let mut cuts = even; + for _ in 0..MAX_BORDER_ROUNDS { + let mut add: Vec = + [one, other].iter().flat_map(|s| cuts.windows(2).filter_map(|w| self.edge(s, w[0], w[1], tol).1).collect::>()).collect(); + add.retain(|f| cuts.iter().all(|c| (c - f).abs() > 1e-9)); + add.truncate((MAX_BORDER_LINES + 1).saturating_sub(cuts.len())); + if add.is_empty() { + break; + } + cuts.extend(add); + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|x, y| (*x - *y).abs() <= 1e-9); + } + cuts + } + + /// The points along `side` at `knots` (rising, 0 to 1). + fn side_points(&self, side: &Side, knots: &[f64]) -> Vec { + knots.iter().map(|f| self.point(side.at(*f))).collect() + } +} + +/// The sorted union of `cuts` and `SIDE` even steps. +fn side_knots(cuts: &[f64]) -> Vec { + let mut k: Vec = (0..SIDE).map(|i| i as f64 / (SIDE - 1) as f64).chain(cuts.iter().copied()).collect(); + k.sort_by(f64::total_cmp); + k.dedup_by(|x, y| (*x - *y).abs() <= 1e-9); + k +} + /// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles /// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer /// ends: each outer handle grown back by the share of the curve its edge had, which undoes the @@ -599,19 +977,104 @@ impl GradientMesh { /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { - let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?; - m.highlight(appearance, highlight); + Self::for_path_shaded(path, rows, cols, &|_| (base, 1.0), appearance, highlight) + } + + /// As [`Self::for_path_with`], then lightened per `appearance` ([`Self::highlight`]), measured + /// by where each line crosses the patch. + pub fn for_path_shaded( + path: &PathData, + rows: u32, + cols: u32, + color_at: &dyn Fn(Point) -> (Color, f32), + appearance: MeshAppearance, + highlight: f64, + ) -> Option { + let (mut m, us, vs) = Self::build_for_path(path, rows, cols, color_at)?; + m.highlight_at(appearance, highlight, &us, &vs); Some(m) } /// Create Gradient Mesh for a path: the outline split at its corners, each point taking the /// colour and opacity `color_at` gives at its position (a gradient fill's, say). pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option { - let coons = Coons::from_path(path)?; + Self::build_for_path(path, rows, cols, color_at).map(|(m, ..)| m) + } + + /// The mesh of [`Self::for_path_with`] and where its column and row lines cross the patch (0 + /// to 1). Its border lies on the outline of the path's largest subpath (#2752): the patch's + /// corners are the outline's places nearest the corners of its bounding box (when those don't + /// come in order round the outline, the top left one and the places a quarter, a half and three + /// quarters of the way round from it), its points on the + /// border sit on the outline, and each border edge is the piece of the outline between its + /// points, a single cubic. Where the outline bends through an anchor between two of the `rows` + /// × `cols` lines and no single cubic follows it within [`BORDER_TOLERANCE`], the mesh takes a + /// line through that anchor (its sharpest corner first), as many as that needs up to + /// [`MAX_BORDER_LINES`] each way. `None` when the path has no outline or a point that isn't + /// finite; an outline whose points all lie on one line gives the mesh of its bounding box. + fn build_for_path(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<(Self, Vec, Vec)> { + if !path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return None; + } let (us, vs) = (even_steps(cols), even_steps(rows)); + let Some((o, sides)) = Outline::new(path).and_then(|o| { + let r = o.bounds()?; + let [tl, mut tr, mut br, mut bl] = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| o.nearest(Point::new(x, y))); + let span = |a: f64, b: f64| (b - a).rem_euclid(o.total()); + // Corners met out of order around the outline would make sides that overlap: the + // other three then go a quarter of the way round from the top left each. + let around = span(tl, tr) + span(tr, br) + span(br, bl) + span(bl, tl); + if (around - o.total()).abs() > 1e-6 * o.total() { + let quarter = o.total() / 4.0; + (tr, br, bl) = (tl + quarter, tl + 2.0 * quarter, tl + 3.0 * quarter); + } + let sides = [ + Side { start: tl, len: span(tl, tr), reversed: false }, + Side { start: tr, len: span(tr, br), reversed: false }, + Side { start: br, len: span(br, bl), reversed: true }, + Side { start: bl, len: span(bl, tl), reversed: true }, + ]; + Some((o, sides)) + }) else { + // No outline to follow: the patch of the flattened outline (its bounding box when it + // encloses nothing), with each border edge fitted. + let coons = Coons::from_path(path)?; + let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)); + m.fit_border(&coons, &us, &vs); + return Some((m, us, vs)); + }; + let [top, right, bottom, left] = sides; + let size = o.bounds().map_or(0.0, |r| r.width() + r.height()); + let tol = BORDER_TOLERANCE.min(size * 1e-3).max(o.eps); + let us = o.cuts(&top, &bottom, us, tol); + let vs = o.cuts(&left, &right, vs, tol); + let (ku, kv) = (side_knots(&us), side_knots(&vs)); + let coons = Coons { + top: o.side_points(&top, &ku), + right: o.side_points(&right, &kv), + bottom: o.side_points(&bottom, &ku), + left: o.side_points(&left, &kv), + knots: Some(Box::new([ku.clone(), kv.clone(), ku, kv])), + }; let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)); - m.fit_border(&coons, &us, &vs); - Some(m) + let (rows, cols) = (m.rows as usize, m.cols as usize); + let edges = [(0, &top), (rows, &bottom)] + .map(|(r, side)| (r, side, true)) + .into_iter() + .chain([(0, &left), (cols, &right)].map(|(c, side)| (c, side, false))); + for (line, side, along_u) in edges { + let steps = if along_u { &us } else { &vs }; + for (k, w) in steps.windows(2).enumerate() { + let (a, b, out, back) = + if along_u { (m.idx(line, k), m.idx(line, k + 1), H_RIGHT, H_LEFT) } else { (m.idx(k, line), m.idx(k + 1, line), H_DOWN, H_UP) }; + let (c, _) = o.edge(side, w[0], w[1], tol); + m.points[a].p = c.p0; + m.points[b].p = c.p3; + m.points[a].handles[out] = c.p1 - c.p0; + m.points[b].handles[back] = c.p2 - c.p3; + } + } + Some((m, us, vs)) } /// Give each edge on the mesh's border the handles that make it follow the outline it stands @@ -657,11 +1120,21 @@ impl GradientMesh { /// the edges, by at most `highlight` %. Each point lightens towards white in its own colour /// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232). pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) { + let even = |n: u32| (0..=n).map(|i| f64::from(i) / f64::from(n)).collect::>(); + let (us, vs) = (even(self.cols), even(self.rows)); + self.highlight_at(appearance, highlight, &us, &vs); + } + + /// As [`Self::highlight`], with the column lines at `us` and the row lines at `vs` (0 to 1 + /// across the patch); a mesh whose lines don't match them is left as it is. + fn highlight_at(&mut self, appearance: MeshAppearance, highlight: f64, us: &[f64], vs: &[f64]) { let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; let (rows, cols) = (self.rows as usize, self.cols as usize); - for r in 0..=rows { - for c in 0..=cols { - let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 { + return; + } + for (r, &v) in vs.iter().enumerate() { + for (c, &u) in us.iter().enumerate() { let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge let w = match appearance { MeshAppearance::Flat => 0.0, diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index e431fc9d1..e2d27650b 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -88,7 +88,7 @@ impl Document { } let mut n = 0; n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::(); - n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); + n += vec_bytes(&self.saved_guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::(); n += self.images.iter().map(|(k, b)| 4 * size_of::() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::(); n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 774e82439..eeec6ab16 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -33,6 +33,7 @@ pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; pub use ops::{flattens, keeps_gradient_fit}; use wire::NodeWire; +pub(crate) use wire::counted_list; pub use wire::{MAX_READ_NODES, with_node_budget}; fn yes() -> bool { diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index 208bdda7f..cb66ba114 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -19,7 +19,7 @@ impl Node { fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { match &self.kind { - NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } | NodeKind::SymbolInstance { .. } => {} NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), NodeKind::Compound { children, rule } => { let mut bp = BezPath::new(); diff --git a/crates/doc/src/node/ids.rs b/crates/doc/src/node/ids.rs index 9e09a47a9..58915eb66 100644 --- a/crates/doc/src/node/ids.rs +++ b/crates/doc/src/node/ids.rs @@ -56,4 +56,30 @@ impl LayerColor { LayerColor::Custom(c) => c, } } + + /// The color as text: a preset's name from [`LAYER_COLORS`], else `#rrggbb` (lowercase). + pub fn label_text(self) -> String { + let preset = match self { + LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(i)), + LayerColor::Custom(_) => None, + }; + preset.map_or_else( + || { + let [r, g, b] = self.rgb(); + format!("#{r:02x}{g:02x}{b:02x}") + }, + |(name, _)| (*name).to_string(), + ) + } + + /// The color [`LayerColor::label_text`] names: a preset's name exactly as [`LAYER_COLORS`] + /// spells it, or `#rrggbb` with six hex digits. None for anything else. + pub fn from_label_text(s: &str) -> Option { + if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| *name == s) { + return u8::try_from(i).ok().map(LayerColor::Preset); + } + let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; + let byte = |k: usize| u8::from_str_radix(&hex[k..k + 2], 16).ok(); + Some(LayerColor::Custom([byte(0)?, byte(2)?, byte(4)?])) + } } diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs index 22470cbf3..7f9e21a0f 100644 --- a/crates/doc/src/node/kind.rs +++ b/crates/doc/src/node/kind.rs @@ -122,6 +122,17 @@ pub enum NodeKind { Intertwine { children: Vec>, }, + /// A ruler guide: a straight line across the canvas, or across one artboard, that draws on + /// screen only. It is always a direct child of a layer or sublayer, above the layer's other + /// children, and has no bounds. + RulerGuide { + /// true: a vertical guide at x = `pos`; false: a horizontal one at y = `pos`. + vertical: bool, + pos: f64, + /// The [`crate::Artboard::id`] of the artboard it runs across; None: across the canvas. + #[serde(default, skip_serializing_if = "Option::is_none")] + artboard: Option, + }, } /// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every @@ -215,6 +226,12 @@ mod kind_de { Intertwine { children: Vec>, }, + RulerGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + }, } impl<'de> Deserialize<'de> for NodeKind { diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index f76957258..415b2946e 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -80,6 +80,16 @@ impl Node { matches!(self.kind, NodeKind::Layer { .. }) } + /// A ruler guide ([`NodeKind::RulerGuide`]). + pub fn is_ruler_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. }) + } + + /// A guide of either kind: a ruler guide, or a path made into a guide. + pub fn is_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. } | NodeKind::Path { guide: true, .. }) + } + /// A template layer (Layer Options → Template). pub fn is_template(&self) -> bool { matches!(self.kind, NodeKind::Layer { template: true, .. }) @@ -91,7 +101,7 @@ impl Node { pub fn layer_art(&self, editable: bool) -> Vec { fn visit(n: &Node, editable: bool, out: &mut Vec) { for c in n.children().into_iter().flatten() { - if editable && (!c.visible || c.locked || c.is_template()) { + if c.is_ruler_guide() || (editable && (!c.visible || c.locked || c.is_template())) { continue; } if c.is_layer() { @@ -142,6 +152,7 @@ impl Node { NodeKind::Repeat(r) => r.kind.label(), NodeKind::PlacedDocument(_) => "Placed Document", NodeKind::Intertwine { .. } => "Intertwine", + NodeKind::RulerGuide { .. } => "Guide", } } @@ -310,6 +321,8 @@ impl Node { NodeKind::PlacedDocument(p) => Some(p.bounds()), // The members drawn over the others are drawn where they are. NodeKind::Intertwine { children } => union(children), + // A ruler guide runs across the canvas (or an artboard): it adds nothing to a box. + NodeKind::RulerGuide { .. } => None, } } @@ -373,7 +386,10 @@ impl Node { NodeKind::SymbolInstance { symbol, xf } => { let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) { Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric), - Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), + // A symbol drawn at other than its natural size goes through its 9-slice guides. + Some((s, art)) if moves_only(*xf) && s.document().symbol_slice_map_of(symbol, art.geometric, *xf).is_none() => { + xf.transform_rect_bbox(art.visual) + } Some((s, art)) => { let placed = xf.transform_rect_bbox(art.geometric); match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) { @@ -519,6 +535,9 @@ impl Node { } NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), + // A ruler guide stays where it is when the art around it (its layer) is transformed: + // the guide commands move it. + NodeKind::RulerGuide { .. } => {} } // The frames of the objects attached to a path move with them, and the distances along the // path follow the path as it is now (a live shape's path is regenerated). @@ -554,10 +573,10 @@ impl Node { } } - /// Count of nodes in this subtree. + /// Count of nodes in this subtree, ruler guides left out. pub fn count(&self) -> usize { let mut n = 0; - self.walk(&mut |_| n += 1); + self.walk(&mut |c| n += usize::from(!c.is_ruler_guide())); n } @@ -566,7 +585,7 @@ impl Node { pub fn count_with_masks(&self) -> usize { let mut n = 0; self.walk(&mut |c| { - n += 1; + n += usize::from(!c.is_ruler_guide()); if let Some(m) = &c.extras().mask { n += m.art.count_with_masks(); } @@ -685,7 +704,7 @@ impl Node { /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { - for c in children.iter().filter(|c| c.visible) { + for c in children.iter().filter(|c| c.visible && !c.is_ruler_guide()) { match c.children() { Some(ch) if c.passes_knockout_through() => push(ch, out), _ => out.push(c), @@ -698,8 +717,62 @@ impl Node { } /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. + /// A layer whose first child is a ruler guide holds no art, so a guide never clips it. pub fn clips(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + match &self.kind { + NodeKind::Group { clip, .. } => *clip, + NodeKind::Layer { clip, children, .. } => *clip && children.first().is_none_or(|c| !c.is_ruler_guide()), + _ => false, + } + } + + /// Where the ruler guides at the end of `children` (a layer's) begin: the index past the last + /// child that is not one. Art goes below this index and ruler guides at or above it. + pub fn guide_run_start(children: &[Arc]) -> usize { + children.iter().rposition(|c| !c.is_ruler_guide()).map_or(0, |i| i + 1) + } + + /// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. + pub fn strip_ruler_guides(&mut self) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + ch.retain(|c| !c.is_ruler_guide()); + for c in ch.iter_mut().filter(|c| c.holds_ruler_guides()) { + Arc::make_mut(c).strip_ruler_guides(); + } + } + } + + /// Call `f` on the position (along the axis it crosses) of each ruler guide of this layer and + /// of its sublayers, with whether the guide is vertical. Only layers that hold one are copied. + pub fn map_ruler_guide_positions(&mut self, f: &mut impl FnMut(bool, &mut f64)) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + for c in ch.iter_mut().filter(|c| c.is_ruler_guide() || c.holds_ruler_guides()) { + let c = Arc::make_mut(c); + match &mut c.kind { + NodeKind::RulerGuide { vertical, pos, .. } => f(*vertical, pos), + _ => c.map_ruler_guide_positions(f), + } + } + } + } + + /// Whether this layer or one of its sublayers holds a ruler guide. + pub fn holds_ruler_guides(&self) -> bool { + self.is_layer() && self.children().is_some_and(|ch| ch.iter().any(|c| c.is_ruler_guide() || c.holds_ruler_guides())) + } + + /// The index that an insert into `children` at `index` uses for `node`: art stays below the + /// layer's ruler guides and a ruler guide goes among them. + pub fn insert_index(children: &[Arc], index: usize, node: &Node) -> usize { + let start = Self::guide_run_start(children); + let i = index.min(children.len()); + if node.is_ruler_guide() { i.max(start) } else { i.min(start) } } /// Make this group or layer clip its children by its first child, or stop it. diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index b54d3f4b8..2722e7bee 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -145,9 +145,10 @@ fn count_one() -> Result<(), E> { } /// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol -/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed -/// file of repeated empty items stays small while they take memory once read. -fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { +/// overrides, a document's saved ruler guides), each item counting against [`with_node_budget`] +/// as an object does: a compressed file of repeated empty items stays small while they take +/// memory once read. +pub(crate) fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { struct Items(std::marker::PhantomData); impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items { type Value = Vec; diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 2a98df62c..d1333bef0 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -584,6 +584,8 @@ impl<'a> NestedCount<'a> { (NodeKind::Repeat(r), _) => r.in_all(self), (NodeKind::SymbolInstance { symbol, .. }, _) => Cost { instances: self.symbol(symbol), copies: 1 }, (NodeKind::Path { path, .. }, _) => Cost { instances: 0, copies: path.anchor_count().saturating_add(1) }, + // A ruler guide draws nothing, so a repeat makes no copies of it. + (NodeKind::RulerGuide { .. }, _) => Cost::default(), (_, Some(c)) => self.nodes(c).plus(Cost { instances: 0, copies: 1 }), _ => Cost { instances: 0, copies: 1 }, }; diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index cc3c141d1..8279507e0 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -7,7 +7,6 @@ use vectorcraft_geom::hit::fill_contains; use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; -use crate::appearance::HEAD_REACH; use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; /// Arc-length accuracy for placing a point along a profile (document points). @@ -395,18 +394,19 @@ impl StrokeLayer { } /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path - /// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With + /// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and + /// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With /// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap) /// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the /// chord the path bends through under it, so anywhere within its length of the end. fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool { - let hw = self.arrow_weight(end); - let v = p - at; + let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false }; + let (hw, v) = (self.arrow_weight(end), p - at); match self.arrow_align { - ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol, + ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol, ArrowAlign::Extend => { let along = v.dot(out); - v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol + v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol } } } diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 643f81c36..c514aab99 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -39,16 +39,16 @@ pub struct Selection { /// clears them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, - /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new + /// Ruler guides selected with a selection tool, by node id ([`Document::ruler_guides`]). A new /// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub guides: Vec, - /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index - /// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set - /// with `key`; [`Self::prune`] clears it when no object is selected to align to it. + pub guides: Vec, + /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (one of + /// [`Self::guides`]): the objects line up on it. Never set with `key`; [`Self::prune`] clears + /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_guide: Option, + pub key_guide: Option, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -139,10 +139,10 @@ impl Selection { } } } - /// Select ruler guides `indexes` (see [`Selection::guides`]) and nothing else. - pub fn set_guides(&mut self, indexes: impl IntoIterator) { + /// Select ruler guides `ids` (see [`Selection::guides`]) and nothing else. + pub fn set_guides(&mut self, ids: impl IntoIterator) { self.clear(); - for i in indexes { + for i in ids { if !self.guides.contains(&i) { self.guides.push(i); } @@ -150,7 +150,7 @@ impl Selection { } /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a /// Shift-click on a guide). - pub fn toggle_guide(&mut self, i: usize) { + pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); @@ -201,7 +201,9 @@ impl Selection { pub fn prune(&mut self, doc: &Document) { // Every id this looks up, found in one walk of the tree. let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target); - let found = doc.index_paths(ids); + let mut found = doc.index_paths(ids); + // A ruler guide is selected in `guides` only, never as an object, an anchor or the key. + found.retain(|_, p| !doc.node_at(p).is_some_and(|n| n.is_ruler_guide())); self.objects.retain(|id| found.contains_key(id)); self.anchors.retain(|id, _| found.contains_key(id)); self.segments.retain(|id, refs| { @@ -209,8 +211,12 @@ impl Selection { refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count())); !refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id) }); - // Guides on a hidden or locked layer can't stay selected. - self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g))); + // Guides that are gone, or hidden or locked (themselves or by their layer), can't stay + // selected. + if !self.guides.is_empty() { + let editable = doc.editable(); + self.guides.retain(|id| doc.node(*id).is_some_and(|n| n.is_ruler_guide()) && editable(*id)); + } // The key object stays while another selected object lies outside it, or while ruler // guides are selected for it to line up (#2143). let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| { diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index 7882fef73..d0d81faee 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -20,25 +20,30 @@ fn basic_ops() { fn guides_are_selected_on_their_own() { let mut s = Selection::default(); s.add(NodeId(1)); - s.set_guides([2, 0, 2]); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); + let (g0, g2) = (NodeId(10), NodeId(12)); + s.set_guides([g2, g0, g2]); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![g2, g0])); assert!(s.has_objects_or_guides() && s.is_empty()); // Shift-adding an object keeps them; a new selection doesn't. s.add(NodeId(1)); - assert_eq!(s.guides, vec![2, 0]); + assert_eq!(s.guides, vec![g2, g0]); s.set([NodeId(1)]); assert!(s.guides.is_empty()); // Toggled, a guide joins (or leaves) the selected objects. - s.toggle_guide(0); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); - s.toggle_guide(0); + s.toggle_guide(g0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![g0])); + s.toggle_guide(g0); assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); - // Pruning drops guides the document no longer has. + // Pruning drops guides the document no longer has, ids of other objects, and guides that are + // locked or hidden themselves. let mut d = Document::new(100.0, 100.0); - d.guides.push(crate::Guide::new(true, 10.0)); - s.set_guides([0, 1]); + let layer = d.layers[0].id; + let kept = d.add_ruler_guide(&crate::Guide::new(true, 10.0)); + let locked = d.add_ruler_guide(&crate::Guide { locked: true, ..crate::Guide::new(true, 20.0) }); + let hidden = d.add_ruler_guide(&crate::Guide { hidden: true, ..crate::Guide::new(true, 30.0) }); + s.set_guides([kept, NodeId(9999), layer, locked, hidden]); s.prune(&d); - assert_eq!(s.guides, vec![0]); + assert_eq!(s.guides, vec![kept]); } /// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers @@ -131,16 +136,26 @@ fn guides_show_and_lock_with_their_layer() { assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); - d.guides.push(g); - assert_eq!(d.guides_on(layer).count(), 1); - // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. + // As a node, a guide shows and locks with its layer and on its own. + let id = d.add_ruler_guide(&g); + let node = d.ruler_guide(id).unwrap(); + assert_eq!(d.guides_on(layer), std::slice::from_ref(&node)); + assert!(!d.guide_shown(&node) && !d.guide_editable(&node), "its layer is hidden"); d.node_mut(layer).unwrap().visible = true; d.node_mut(layer).unwrap().locked = false; - d.guides.push(crate::Guide::new(false, 5.0)); - d.guides.push(gone); - assert_eq!(d.adopt_guides(), 2); - assert!(d.guides.iter().all(|g| g.layer == Some(layer))); - assert_eq!(d.adopt_guides(), 0, "once"); + assert!(d.guide_shown(&node) && d.guide_editable(&node)); + d.node_mut(id).unwrap().locked = true; + let node = d.ruler_guide(id).unwrap(); + assert!(d.guide_shown(&node) && !d.guide_editable(&node), "locked itself"); + d.node_mut(id).unwrap().visible = false; + assert!(!d.guide_shown(&node), "hidden itself"); + // Saved guides on no layer, or a gone one, go on the top visible, unlocked layer. + d.saved_guides.push(crate::Guide::new(false, 5.0)); + d.saved_guides.push(gone); + assert_eq!(d.guides_into_nodes(), 2); + assert!(d.ruler_guides().iter().all(|g| g.layer == Some(layer))); + assert_eq!(d.ruler_guides().len(), 3); + assert_eq!(d.guides_into_nodes(), 0, "once"); // A guide saved before guides had layers reads as one on no layer. let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); assert_eq!(old.layer, None); @@ -267,7 +282,7 @@ fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection { s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied(); s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0); s.slices = ids.iter().copied().take(below(r, 3) as usize).collect(); - s.guides = vec![1, 0]; + s.guides = vec![NodeId(1), NodeId(0)]; s } diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 6eda3acef..f64bdb430 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -158,6 +158,8 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ 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 }, + BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud }, + BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects }, ]; /// Built-in library `id`, made once. @@ -453,6 +455,71 @@ fn blends_transparency() -> Vec { ] } +/// Game HUD: fills and glows for health, mana, damage and quest chrome (original arcade/HUD look). +fn game_hud() -> Vec { + vec![ + style("Health Fill", vec![F(fill("#e74c3c")), S(stroke("#5c1a1a", 1.25))], vec![]), + style( + "Mana Glow", + vec![F(fill("#3498db"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 7.0, "color": "#5dade2"}))], + ), + style( + "Damage Flash", + vec![F(fill("#ff6b35"))], + vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 80.0, "blur": 8.0, "color": "#ffe66d", "source": "edge"}))], + ), + style("Quest Outline", vec![F(fill("#f7f3e8")), S(round("#2c3e50", 3.0))], vec![]), + style( + "Coin Shine", + vec![F(fill("#f1c40f")), S(stroke("#b7950b", 1.5))], + vec![fx("stylize.dropShadow", json!({"mode": "multiply", "opacity": 40.0, "x": 2.0, "y": 3.0, "blur": 4.0, "color": "#1d1b2a"}))], + ), + style( + "Boss Alert", + vec![F(fill("#1a1a2e")), S(round("#e94560", 2.5))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 6.0, "color": "#e94560"}))], + ), + ] +} + +/// Status Effects: buffs and debuffs for RPG/gamedev art (original, catalogue stylize effects only). +fn status_effects() -> Vec { + let stealth = GraphicStyle { + opacity: 0.45, + blend: BlendMode::Normal, + ..style("Stealth Fade", vec![F(fill("#7f8c8d")), S(stroke("#2c3e50", 1.0))], vec![]) + }; + vec![ + style( + "Poison Tint", + vec![F(fill("#2ecc71"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 80.0, "blur": 6.0, "color": "#58d68d"}))], + ), + style( + "Freeze Edge", + vec![F(fill("#aed6f1")), S(stroke("#5dade2", 2.0))], + vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 70.0, "blur": 5.0, "color": "#d6eaf8", "source": "edge"}))], + ), + style( + "Burn Aura", + vec![F(fill("#e67e22"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 8.0, "color": "#f39c12"}))], + ), + style( + "Stun Pulse", + vec![F(fill("#f4d03f"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 7.0, "color": "#f7dc6f"}))], + ), + stealth, + style( + "Shield Buff", + vec![F(fill("#85c1e9")), S(round("#2874a6", 2.0))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 70.0, "blur": 5.0, "color": "#5dade2"}))], + ), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -475,6 +542,14 @@ mod tests { } assert!(Arc::ptr_eq(&builtin_style_library("hand-drawn").unwrap(), &builtin_style_library("hand-drawn").unwrap()), "made once"); assert!(builtin_style_library("nope").is_none()); + let game = builtin_style_library("game-hud").unwrap(); + assert_eq!(game.name, "Game HUD"); + let names: Vec<&str> = game.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]); + let status = builtin_style_library("status-effects").unwrap(); + 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"]); } #[test] diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 5aa6147fa..80db3d923 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -195,9 +195,10 @@ impl Document { /// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0", /// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30"). pub fn color_name(c: Color) -> String { - // Clamped first, so a conversion's tiny negative never reads "-0". - let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round(); - let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round(); + // Clamped first, so a conversion's tiny negative never reads "-0". `clamp` keeps a -0.0, so + // `+ 0.0` turns it into 0. + let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() + 0.0; + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() + 0.0; match c { Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), @@ -493,6 +494,13 @@ mod tests { assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0"); } + #[test] + fn default_names_read_negative_zero_as_zero() { + assert_eq!(color_name(Color::Rgb { r: -0.0, g: 0.5, b: -0.0 }), "R=0 G=128 B=0"); + assert_eq!(color_name(Color::Cmyk { c: -0.0, m: 0.2, y: 0.3, k: -0.0 }), "C=0 M=20 Y=30 K=0"); + assert_eq!(color_name(Color::Gray { k: -0.0 }), "Gray K=0"); + } + #[test] fn free_swatch_names_match_naming_each_swatch_as_it_is_added() { let (mut d, _, _) = doc(); diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 995445f57..6949aa86f 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -492,7 +492,8 @@ impl NineSliceMap { /// [`Self::most_placed`] for a container placed by [`Self::container`]. fn most_container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let own = self.most_mask(n, boxes, depth).saturating_add(1); - n.children().into_iter().flatten().fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) + // As [`Self::container`] places it: a layer's ruler guides left out. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) } /// [`Self::most_placed`] for the opacity mask of `n` as [`Self::place_mask`] places it: a @@ -560,7 +561,8 @@ impl NineSliceMap { let kids = out.children_mut().map(std::mem::take).unwrap_or_default(); out.transform(center, false); if let Some(children) = out.children_mut() { - *children = kids.iter().map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); + // A ruler guide of a layer is not art: it is not placed. + *children = kids.iter().filter(|c| !c.is_ruler_guide()).map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); } self.place_mask(n, &mut out, boxes, depth); out diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 4b75b0149..dcbcd8702 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -5,6 +5,52 @@ mod lookups; mod slices; mod units; +/// A guide label's text is a layer color's name or `#rrggbb`, and reads back as the same color; +/// other spellings, an alpha part and a short form are not labels. +#[test] +fn guide_label_text_names_a_layer_color_or_rgb() { + for (i, (name, _)) in LAYER_COLORS.iter().enumerate() { + let c = LayerColor::Preset(u8::try_from(i).unwrap()); + assert_eq!(c.label_text(), *name); + assert_eq!(LayerColor::from_label_text(name), Some(c)); + } + let custom = LayerColor::Custom([0x0a, 0xbc, 0xff]); + assert_eq!(custom.label_text(), "#0abcff"); + assert_eq!(LayerColor::from_label_text("#0ABCff"), Some(custom)); + // A preset past the layer colors has no name: its text is the color it draws in. + let [r, g, b] = LayerColor::Preset(200).rgb(); + assert_eq!(LayerColor::Preset(200).label_text(), format!("#{r:02x}{g:02x}{b:02x}")); + for bad in ["red", "Red ", "", "#", "#fff", "#0abcff00", "0abcff", "#0abcfg", "#0abcff"] { + assert_eq!(LayerColor::from_label_text(bad), None, "{bad:?}"); + } +} + +/// An artboard's guide label: none unless set, left out of the JSON when none, and a preset past +/// the layer colors reading as none. +#[test] +fn artboard_guide_label_defaults_and_skips_in_json() { + let mut a = Document::new(100.0, 100.0).artboards[0].clone(); + assert_eq!(a.guide_label, None); + let plain = serde_json::to_value(&a).unwrap(); + assert!(plain.get("guide_label").is_none(), "{plain}"); + a.guide_label = Some(LayerColor::Custom([1, 2, 3])); + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v["guide_label"], serde_json::json!({"Custom": [1, 2, 3]})); + assert_eq!(serde_json::from_value::(v).unwrap(), a); + let read = |label: serde_json::Value| { + let mut v = plain.clone(); + v["guide_label"] = label; + serde_json::from_value::(v).map(|a| a.guide_label) + }; + assert_eq!(read(serde_json::json!({"Preset": 26})).unwrap(), Some(LayerColor::Preset(26))); + assert_eq!(read(serde_json::json!({"Preset": 27})).unwrap(), None); + assert_eq!(read(serde_json::json!({"Preset": 255})).unwrap(), None); + assert_eq!(read(serde_json::Value::Null).unwrap(), None); + assert!(read(serde_json::json!({"Preset": -1})).is_err()); + assert!(read(serde_json::json!({"Tint": 1})).is_err()); + assert_eq!(serde_json::from_value::(plain).unwrap().guide_label, None, "older files have none"); +} + /// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch /// are binary fractions now, and the minor ticks between them stay binary too; every other /// unit is still read in tenths. @@ -45,6 +91,268 @@ fn an_inch_ruler_steps_in_binary_fractions() { assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); } +/// Ruler guide nodes on a layer and a sublayer are not art: Select All, a layer's target circle, +/// the object count, the art's bounds, clicks, the marquee and a knockout layer's elements are +/// the same as without them, and transforming the layer leaves them where they are. +#[test] +fn ruler_guide_nodes_are_not_art() { + use crate::hit::{HitOptions, hit_test, marquee, objects_at}; + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); + let s1 = d.alloc_id(); + d.insert(Some(sub), usize::MAX, Node::path(s1, shapes::rectangle(Rect::new(40.0, 0.0, 50.0, 10.0)), Appearance::default_art())).unwrap(); + let opt = HitOptions::default(); + let all = Rect::new(-100.0, -100.0, 700.0, 900.0); + let before = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + let guide = |id, vertical, pos| Node::new(id, NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (g1, g2, g3) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, true, 5.0)).unwrap(); + d.insert(Some(layer), usize::MAX, guide(g2, false, 5.0)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g3, true, 45.0)).unwrap(); + let after = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + assert_eq!(after, before); + assert_eq!(after.0, [a, b, s1]); + for p in [Point::new(5.0, 300.0), Point::new(300.0, 5.0), Point::new(45.0, 300.0)] { + assert!(hit_test(&d, p, opt).is_none(), "nothing to click on the guide at {p:?}"); + assert!(objects_at(&d, p, opt, None).is_empty()); + } + let mut sel = Selection::default(); + sel.set_target(&d, layer); + assert_eq!(sel.objects, [a, b, s1]); + let children = d.layers[0].children().unwrap().clone(); + assert_eq!(Node::knockout_elements(&children).iter().map(|c| c.id).collect::>(), [a, b, s1]); + assert!(d.node(g1).is_some_and(Node::is_ruler_guide) && d.node(g1).is_some_and(Node::is_guide)); + assert_eq!(d.node(g1).map(Node::geometric_bounds), Some(None)); + let mut moved = (*d.layers[0]).clone(); + moved.transform(Affine::translate((10.0, 10.0)), true); + let g = moved.children().unwrap().iter().find(|c| c.id == g1).unwrap(); + assert!(matches!(g.kind, NodeKind::RulerGuide { pos, .. } if pos == 5.0)); +} + +/// Art inserted or moved onto a layer goes below the layer's ruler guides, a ruler guide goes +/// among them, and a clipped layer whose art is gone does not clip by a guide. +#[test] +fn layer_art_stays_below_its_ruler_guides() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id, pos| Node::new(id, NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, 5.0)).unwrap(); + // Asked for the bottom, a guide still goes above the art. + d.insert(Some(layer), 0, guide(g2, 7.0)).unwrap(); + let ids = |d: &Document| d.layers[0].children().unwrap().iter().map(|c| c.id).collect::>(); + assert_eq!(ids(&d), [a, b, g2, g1]); + let c = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(c, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art())).unwrap(); + assert_eq!(ids(&d), [a, b, c, g2, g1]); + d.move_node(a, Some(layer), usize::MAX).unwrap(); + assert_eq!(ids(&d), [b, c, a, g2, g1]); + d.move_node(g1, Some(layer), 0).unwrap(); + assert_eq!(ids(&d), [b, c, a, g1, g2]); + assert!(d.layer_object(g1).is_none()); + assert_eq!(d.layer_object(a).map(|n| n.id), Some(a)); + + // A clipped layer clips by its first art object; with only guides left it clips nothing. + if let Some(NodeKind::Layer { clip, .. }) = d.node_mut(layer).map(|n| &mut n.kind) { + *clip = true; + } + assert!(d.node(layer).is_some_and(Node::clips)); + for id in [a, b, c] { + d.remove(id).unwrap(); + } + assert!(!d.node(layer).is_some_and(Node::clips)); + + // Reading a file that clips a layer by a path beyond the canvas empties the layer of its art + // only: its guides stay. + let far = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(far, shapes::rectangle(Rect::new(0.0, 0.0, 1e9, 10.0)), Appearance::default_art())).unwrap(); + assert!(d.node(layer).is_some_and(Node::clips)); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone.objects, 1); + assert_eq!(ids(&d), [g1, g2]); +} + +/// `ruler_guides` keeps its list while the layers are unchanged, and lists again after any change +/// inside a layer, made in place or on a shared copy, and after layers are added, removed or +/// reordered. +#[test] +fn ruler_guides_follow_every_edit() { + let (mut d, _, _) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 5.0) }); + let g2 = d.add_ruler_guide(&Guide { layer: Some(sub), ..Guide::new(false, 6.0) }); + let line = |d: &Document| d.ruler_guides().iter().map(|g| (g.id, g.pos, g.hidden, g.layer)).collect::>(); + assert_eq!(line(&d), [(Some(g1), 5.0, false, Some(layer)), (Some(g2), 6.0, false, Some(sub))]); + // Changed in place: the document is the only holder of its layers. + assert!(d.edit_ruler_guide(g2, |g| g.pos = 9.0)); + assert_eq!(line(&d)[1], (Some(g2), 9.0, false, Some(sub))); + d.node_mut(g1).unwrap().visible = false; + assert_eq!(line(&d)[0], (Some(g1), 5.0, true, Some(layer))); + // A copy shares the list until one of them changes. + let copy = d.clone(); + assert_eq!(line(©), line(&d)); + d.remove(g1).unwrap(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub))]); + assert_eq!(line(©).len(), 2); + // A new layer with a guide, then the layers reordered and one removed. + let top = d.add_layer(None); + let g3 = d.add_ruler_guide(&Guide { layer: Some(top), ..Guide::new(true, 1.0) }); + assert_eq!(line(&d).len(), 2); + d.layers.reverse(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub)), (Some(g3), 1.0, false, Some(top))]); + d.layers.retain(|l| l.id != layer); + assert_eq!(line(&d), [(Some(g3), 1.0, false, Some(top))]); +} + +#[test] +fn strip_ruler_guides_leaves_a_layers_art() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id| Node::new(id, NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g2)).unwrap(); + let mut n = (*d.layers[0]).clone(); + assert!(n.holds_ruler_guides()); + n.strip_ruler_guides(); + assert!(!n.holds_ruler_guides()); + let mut left = vec![]; + n.walk(&mut |c| left.push(c.id)); + assert_eq!(left, [layer, a, b, sub]); + // Other nodes hold none and are left as they are. + let mut p = d.node(a).unwrap().clone(); + p.strip_ruler_guides(); + assert_eq!(&p, d.node(a).unwrap()); +} + +/// The saved `guides` list becomes ruler guide nodes and is written back from them unchanged: +/// guides on a layer, a sublayer, no layer and a deleted layer, with an artboard guide, keep their +/// order, ids, names and their own lock and visibility; a saved id that another node has is +/// replaced; and the saved form of an older entry still reads. +#[test] +fn saved_guides_become_nodes_and_are_saved_back_the_same() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let top = d.add_layer(Some("Top")); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let json = serde_json::json!([ + {"vertical": true, "pos": 10.0, "layer": layer}, + {"vertical": false, "pos": 20.0, "layer": sub, "artboard": 1}, + {"vertical": true, "pos": 30.0}, + {"vertical": false, "pos": 40.0, "layer": 9999}, + {"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}, + {"vertical": true, "pos": 60.0, "layer": layer, "id": a}, + ]); + d.saved_guides = serde_json::from_value(json).unwrap(); + let before = d.peek_next_id(); + assert_eq!(d.guides_into_nodes(), 6); + assert!(d.saved_guides.is_empty()); + let guides = d.ruler_guides(); + let lines: Vec<(f64, Option)> = guides.iter().map(|g| (g.pos, g.layer)).collect(); + // A guide on no layer, or on a deleted one, goes on the top visible, unlocked layer. + assert_eq!(lines[..4], [(50.0, Some(layer)), (10.0, Some(layer)), (20.0, Some(sub)), (30.0, Some(top))], "by id: the kept id first"); + assert_eq!(lines[4..], [(40.0, Some(top)), (60.0, Some(layer))]); + assert_eq!(guides[0].id, Some(NodeId(500))); + assert!(guides[1..].iter().all(|g| g.id.unwrap().0 > 500), "new ids start above a kept one"); + assert_ne!(guides[5].id, Some(a), "an id another object has is not reused"); + assert!(d.peek_next_id() > 500 && before <= 500); + let edge = d.node(NodeId(500)).unwrap(); + assert!(edge.locked && !edge.visible && edge.name.as_deref() == Some("Edge")); + assert_eq!(guides[2].artboard, Some(1)); + // Each layer's guides sit at its top, above its art and its sublayer. + let kids: Vec = d.node(layer).unwrap().children().unwrap().iter().map(|c| c.is_ruler_guide()).collect(); + assert_eq!(kids, [false, false, false, true, true, true]); + assert_eq!(d.guides_into_nodes(), 0, "a second run does nothing"); + + // Saved back: the guides leave the layers and the list names each one's layer and id. + let mut saved = d.clone(); + saved.guides_to_saved(); + assert!(saved.ruler_guides().is_empty()); + assert_eq!(saved.node_count(), d.node_count()); + assert_eq!(saved.saved_guides, guides); + let json = serde_json::to_value(&saved).unwrap(); + assert_eq!(json["guides"].as_array().map(Vec::len), Some(6)); + assert_eq!( + json["guides"][0], + serde_json::json!({"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}) + ); + // Read again, the same guides come back with the same ids. + let mut back: Document = serde_json::from_value(json).unwrap(); + back.fix_next_id(); + back.guides_into_nodes(); + assert_eq!(back.ruler_guides(), guides); + // An older reader of the list ignores the new fields. + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Old { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, + } + let old: Vec = serde_json::from_value(serde_json::to_value(&saved.saved_guides).unwrap()).unwrap(); + assert_eq!(old[0], Old { vertical: true, pos: 50.0, artboard: None, layer: Some(layer) }); + + // A document with no layer gets a "Layer 1" for its guides. + let mut empty = Document::new(10.0, 10.0); + empty.layers.clear(); + empty.saved_guides = vec![Guide::new(true, 1.0)]; + assert_eq!(empty.guides_into_nodes(), 1); + assert_eq!(empty.layers.len(), 1); + assert_eq!(empty.layers[0].name.as_deref(), Some("Layer 1")); + assert_eq!(empty.ruler_guides().len(), 1); +} + +/// Editing, deleting and the artboard moves of ruler guide nodes, and the selection keeping step. +#[test] +fn ruler_guide_nodes_are_edited_by_id() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 10.0) }); + let g2 = d.add_ruler_guide(&Guide { artboard: Some(1), ..Guide::new(false, 20.0) }); + assert!(d.edit_ruler_guide(g1, |g| g.pos = 15.0)); + assert!(!d.edit_ruler_guide(a, |g| g.pos = 15.0), "not a guide"); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0)); + d.move_artboard_guides(1, Vec2::new(5.0, 7.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(27.0)); + d.map_artboard_guides(1, Affine::scale(2.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(54.0)); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0), "a canvas guide stays"); + d.copy_artboard_guides(1, 2, Vec2::new(0.0, 100.0)); + let copies: Vec<(f64, Option)> = d.ruler_guides().iter().filter(|g| g.artboard == Some(2)).map(|g| (g.pos, g.artboard)).collect(); + assert_eq!(copies, [(154.0, Some(2))]); + let mut sel = Selection::default(); + sel.set_guides([g1, g2]); + sel.key_guide = Some(g2); + assert_eq!(d.retain_guides(&mut sel, |g| g.artboard.is_none()), 2); + assert_eq!(sel.guides, [g1]); + assert_eq!(sel.key_guide, None); + assert_eq!(d.ruler_guides().iter().map(|g| g.id).collect::>(), [Some(g1)]); + assert_eq!(d.retain_guides(&mut sel, |_| true), 0); +} + fn doc_with_rects() -> (Document, NodeId, NodeId) { let mut d = Document::new(612.0, 792.0); let layer = d.layers[0].id; @@ -146,6 +454,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); let rect = |d: &mut Document, r: Rect, appearance: Appearance| { let id = d.alloc_id(); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 74aa5f444..0be38d261 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -77,6 +77,12 @@ 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. + #[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). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub wrap: Vec, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 67ccb60a4..fd828c79a 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -22,10 +22,10 @@ pub struct CharStyle { /// Leading in points; None = Auto (120% of size). #[serde(default, skip_serializing_if = "Option::is_none")] pub leading: Option, - /// Tracking in 1/1000 em. + /// Tracking in 1/1000 em; in horizontal type, of the em as [`Self::h_scale`] draws it. #[serde(default)] pub tracking: f64, - /// Manual pair spacing in 1/1000 em; None uses the automatic mode. + /// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option, /// Use glyph shapes for automatic pair spacing; false uses font metrics. diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 12bc7ec89..1092b871f 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -89,6 +89,7 @@ impl TextObject { path_effect: PathEffect::default(), path_align: PathAlign::default(), path_spacing: 0.0, + dynamic_text: false, wrap: Vec::new(), optical_margin: false, anti_alias: TypeAntiAlias::Sharp, @@ -367,6 +368,24 @@ impl TextObject { pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d)) } + /// Move the out (`out`) or in direction handle of anchor `ai` of subpath `si` of type on a + /// path's path to `pos` in document space, as a path's anchor takes it + /// ([`vectorcraft_geom::Anchor::set_handle`]; `independent`: that side only, the anchor a + /// corner): a Direct Selection drag of a handle (#3565). The handle is set in document space, + /// so a smooth anchor keeps its other handle's length as drawn; the brackets keep their + /// fractions of the path. False, changing nothing, for other type, when the anchor doesn't + /// exist, a degenerate `xf` or a path that would not be finite. + pub fn set_path_handle(&mut self, (si, ai): (usize, usize), out: bool, pos: Point, independent: bool) -> bool { + let xf = self.xf; + matches!(self.kind, TextKind::OnPath { .. }) + && self.reshape_local(|path, to_text| { + let Some(a) = path.anchor_mut(si, ai) else { return false }; + let mut drawn = a.transform(xf); + drawn.set_handle(out, pos, independent); + *a = drawn.transform(to_text); + true + }) + } /// Edit a copy of the area frame with `f` (given the document → text space map) and keep it /// when `f` succeeds and the result is finite. fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 6a997c9e5..aa403b421 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -1,5 +1,19 @@ use super::*; +/// Dynamic Text is off for new type and for type saved before it, and saved only when on. +#[test] +fn dynamic_text_is_saved_only_when_on() { + let t = TextObject::point(Point::ZERO, "Hi", CharStyle::default()); + assert!(!t.dynamic_text); + let off = serde_json::to_value(&t).unwrap(); + assert!(off.get("dynamicText").is_none()); + assert!(!serde_json::from_value::(off).unwrap().dynamic_text); + let on = TextObject { dynamic_text: true, ..t }; + let saved = serde_json::to_value(&on).unwrap(); + assert_eq!(saved["dynamicText"], true); + assert!(serde_json::from_value::(saved).unwrap().dynamic_text); +} + /// Character Direction is saved only when set, and styles written before it existed read as /// Default. #[test] diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index a656f11e8..c34513625 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -468,12 +468,14 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images } /// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in -/// `d` (named by their content), and a symbol instance stands for its symbol's art. -pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { +/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art` +/// when given, otherwise the symbol's art with the instance's own appearance changes. +pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { - let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)); + let symbol = symbol_art + .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))); art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f66937446..5d27ac60a 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -107,12 +107,13 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { // A cancelled export leaves the rest of the document as it is: the caller drops the result. - if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + // A ruler guide draws nothing and stays as it is, last in its layer. + if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. if has_adjustment(n) - && let Some(m) = adjust_in_document(d, n) + && let Some(m) = adjust_in_document(d, n, None) { return Some(bake_node(d, &m).unwrap_or(m)); } @@ -124,7 +125,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crate::addons::halftone::halftone_art(d, n) { return Some(bake_pieces(d, m)); } - if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { + if let Some(m) = crate::revolve_art_in(Some(d), n).or_else(|| crate::solid_art_in(Some(d), n)) { let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } @@ -395,7 +396,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { if !kept { return None; } - reshape_leaves(&mut m, &fx, Some(d)); + reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; @@ -481,7 +482,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, & /// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and /// effects are dropped. `None` for other kinds of objects. pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option { - if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) { + if let Some(m) = crate::revolve::expand_revolve_art_in(Some(d), n).or_else(|| crate::solid_art_in(Some(d), n)) { let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } @@ -551,7 +552,7 @@ 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 { - if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { + if let Some(m) = crate::revolve_art_in(doc, n).or_else(|| crate::solid_art_in(doc, n)) { return Some(m); } if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 85248e08c..a2e5478b2 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -26,6 +26,10 @@ pub struct GeomContext<'a> { /// The document that the swatches and patterns named by a live-effect plug-in that paints its /// pieces are looked up in. Without one, a result that names any is refused. pub doc: Option<&'a Document>, + /// The object, fill or stroke and effect index a deferred live-effect plug-in is listed by + /// ([`vectorcraft_plugins::deferred`]). When `None`, it is the object `seed` names, `item` and + /// the effect's index in the list being applied. + pub stage: Option, } impl<'a> GeomContext<'a> { @@ -36,7 +40,17 @@ impl<'a> GeomContext<'a> { _ => FillRule::NonZero, }; let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0); - Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None } + Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None, stage: None } + } + + /// The context of effect `e`, one of `list`: unless [`Self::stage`] is set, a live-effect + /// plug-in there is listed by `e`'s index in `list`. + pub(crate) fn at(self, list: &[Effect], e: &Effect) -> Self { + let stage = self.stage.or_else(|| { + let effect = list.iter().position(|x| std::ptr::eq(x, e))?; + Some(vectorcraft_plugins::deferred::Stage { object: self.seed, effect, item: self.item }) + }); + Self { stage, ..self } } /// The context of the effects on `item`, one of the same object's appearance items: a @@ -335,7 +349,7 @@ pub(crate) fn apply_geometry_carrying_within( } let p = merged_params(&e.id, &e.params); let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); - if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { + if let Some(next) = apply_one(&e.id, &p, &cur, b, &ctx.at(effects, e), limit) { if let Some(c) = carry.as_deref_mut() { c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed)); } @@ -377,7 +391,7 @@ fn builds_within(id: &str) -> bool { fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { use util::{join, num}; if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { - let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; + let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item, at: ctx.stage }; return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone()); } match id { diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index d04982d98..99e9d9e6f 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -209,7 +209,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod Some(area) => Some(reshaped(&area, n, doc)), None => { let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes); - let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes follow the effects as its members do. if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); @@ -225,7 +225,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); crate::reshape::outline_nested_text(doc, &mut art, false); - let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc); + let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc); // The art's own fills and strokes follow the effects as its members do. if carry.moves(&art.appearance) { carry.apply(&mut art.appearance); @@ -455,6 +455,17 @@ pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option] = group.children()?; + // A layer's ruler guides take no part in the operation: they stay above its result. + let art = Node::guide_run_start(source); + if art < source.len() { + let mut without = group.clone(); + if let Some(ch) = without.children_mut() { + ch.truncate(art); + } + let mut out = pathfinder_children(doc, &without, hook)?; + out.extend(source.iter().skip(art).cloned()); + return Some(out); + } let mut stack: Vec> = if group.is_compound_shape() { // The group's own appearance paints the area ([`evaluate_container`]), so the art has none. match compound_shape_area(doc, group, hook) { @@ -727,9 +738,11 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } 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(); if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); - let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default(); + let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } @@ -738,7 +751,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes paint the members as a whole: their gradients follow // the effects as the members' do. if paint && carry.moves(&m.appearance) { @@ -762,6 +775,9 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } m.appearance.items.clear(); m.appearance.contents_index = None; + if let Some(ch) = m.children_mut() { + ch.extend(guides); + } Some(m) } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 94456c7d5..bf702b273 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -820,3 +820,45 @@ fn nested_compound_shapes_take_part_as_their_area() { assert!((area(&out[0]) - 50.0).abs() < 0.01); assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape"); } + +/// The ruler guides of a layer with a Pathfinder effect, a Transform effect that copies its art, or +/// fills and strokes of its own take no part: they stay last in the layer, once and unchanged. +#[test] +fn a_layers_ruler_guides_stay_last_through_its_effects() { + use vectorcraft_doc::LayerColor; + let guide = |id, pos| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: true, pos, artboard: None })); + let layer = |effect: &str, params: serde_json::Value| { + 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)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), guide(20, 3.0), guide(21, 8.0)]; + } + l.appearance.effects.push(Effect { id: effect.into(), params, visible: true }); + l + }; + let tail = |n: &Node| { + let ch = n.children().unwrap(); + assert_eq!(ch.iter().filter(|c| c.is_ruler_guide()).count(), 2, "each guide once"); + ch.iter().rev().take(2).map(|c| c.kind.clone()).collect::>() + }; + let guides = vec![guide(21, 8.0).kind.clone(), guide(20, 3.0).kind.clone()]; + + let add = layer("pathfinder.add", serde_json::json!({})); + let out = pathfinder_children(None, &add, None).unwrap(); + assert_eq!(out.len(), 3, "the united area, then the two guides"); + assert!((area(&out[0]) - 150.0).abs() < 0.01); + assert!(out[1].is_ruler_guide() && out[2].is_ruler_guide()); + assert_eq!(tail(&evaluate_container(None, &add).unwrap()), guides); + + let copies = layer("distort.transform", serde_json::json!({"moveH": 30, "copies": 2})); + let art = evaluate_container(None, &copies).unwrap(); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "three groups of the art, then the two guides"); + let mut art = copies.clone(); + crate::reshape::reshape_leaves(&mut art, &copies.appearance.effects.iter().collect::>(), &copies.appearance.effects, None); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "reshaped as members too"); + + let mut painted = layer("distort.transform", serde_json::json!({"moveH": 30})); + painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; + assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 82db95b5b..ae8b7d9e9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -83,11 +83,12 @@ pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; -pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; +pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in}; pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, - classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as, - validate_solid, validate_three_d, validate_three_d_at, validate_three_d_params, view_options, + classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, + three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at, + validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index 5c06fae8f..7785aff33 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -161,7 +161,8 @@ pub fn any_plugin_paints_output() -> bool { vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id)) } -/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. +/// Changes whenever a plug-in is installed or removed (the registry's revision), and when deferred +/// live-effect plug-in stages give results ([`vectorcraft_plugins::deferred::revision`]). pub fn plugin_revision() -> u64 { - vectorcraft_plugins::registry::revision() + vectorcraft_plugins::registry::revision() ^ vectorcraft_plugins::deferred::revision().rotate_left(32) } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index f6c5cf2ae..c629cc10c 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -573,7 +573,7 @@ fn as_art_opts( } _ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?, }; - reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc); + reshape_leaves_apart(&mut art, &fx, &n.appearance.effects, Budget::EFFECTS, apart, paint_doc); if apart { hoist_copies(&mut art); } @@ -636,21 +636,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The /// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path /// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns -/// that live-effect plug-ins painting their pieces name are looked up in `doc` -/// ([`reshape_leaves_apart`]). -pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { - reshape_leaves_within(art, fx, Budget::EFFECTS, doc); +/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one +/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]). +pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) { + reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc); } /// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and /// strokes) follow the effects ([`crate::Carry`]). -pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry { - reshape_leaves_within(art, fx, Budget::EFFECTS, doc) +pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) -> crate::Carry { + reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc) } /// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost. +#[cfg(test)] pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry { - reshape_leaves_apart(art, fx, budget, false, doc) + reshape_leaves_apart(art, fx, &[], budget, false, doc) } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -669,12 +670,15 @@ fn hoist_copies(art: &mut Node) { } } -/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects +/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost; with +/// `apart`, a Transform effect's copies of each path are objects /// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect /// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]), /// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). -/// Returns how gradients that paint the art as a whole follow the effects. -fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { +/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect +/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when +/// `owner` does not hold it. +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { let mut whole = crate::Carry::default(); if fx.is_empty() { return whole; @@ -693,6 +697,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b }); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); let style_safe = leaves_safe && !art_stroke; + let listed = fx; let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; @@ -703,12 +708,15 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + // Where a live-effect plug-in sits, which it is listed by when it is deferred. + let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); if let Some(plugin) = painted_plugin(e) { + let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; let before = Budget::of(&path); let room = max.minus(used.minus(before)); - if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc) { + if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) { used = used.minus(before).plus(made); } }); @@ -778,7 +786,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; } } - let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { + let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); return; }; @@ -965,7 +974,7 @@ fn effects_cost(leaf: &Node, path: &PathData) -> Budget { /// Remove the guides anywhere under `n`. fn without_guides(n: &mut Node) { let Some(children) = n.children_mut() else { return }; - children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })); + children.retain(|c| !c.is_guide()); children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c))); } @@ -975,7 +984,9 @@ fn without_guides(n: &mut Node) { /// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what /// the art costs after. fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { - let Some(members) = art.children().cloned() else { return art_cost(art) }; + let Some(mut members) = art.children().cloned() else { return art_cost(art) }; + // A layer's ruler guides are not copied: they stay above the copies, once. + let guides = members.split_off(Node::guide_run_start(&members)); let original = Node::group(art.id, members); let mut made = copy_cost(&original); let mut drawn = original.clone(); @@ -1005,6 +1016,7 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: // The last copy is the lowest. placements.reverse(); placements.push(Arc::new(original)); + placements.extend(guides); if let Some(children) = art.children_mut() { *children = placements; } @@ -1056,10 +1068,18 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling /// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces /// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost /// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are -/// refused, or they would pass `budget`. -fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option { +/// refused, or they would pass `budget`. A deferred run is listed by `at`. +fn paint_leaf( + leaf: &mut Node, + plugin: &str, + params: &serde_json::Value, + bounds: Rect, + budget: Budget, + doc: Option<&Document>, + at: Option, +) -> Option { let (path, rule) = leaf_geometry(leaf)?; - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None, at }; let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); if !budget.fits(made) { @@ -1097,18 +1117,18 @@ fn piece_cost(path: &PathData) -> Budget { /// other fills and strokes; one that gives none keeps the leaf's. Returns /// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, /// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass -/// `budget`. +/// `budget`. A deferred run is listed by `at`. fn paint_piece( leaf: &mut Node, item: &AppearanceItem, - plugin: &str, - params: &serde_json::Value, + (plugin, params): (&str, &serde_json::Value), bounds: Rect, budget: Budget, doc: Option<&Document>, + at: Option, ) -> Option { let (path, rule) = leaf_geometry(leaf)?; - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at }; let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); if !budget.fits(made) { @@ -1241,6 +1261,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt }; let Some(tb) = g.bounds() else { continue }; let params = merged_params(&fx[at].id, &fx[at].params); + // Where each effect the piece keeps sits among the item's effects. + let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); @@ -1250,7 +1272,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt carry.apply(&mut one); } let it = one.items.pop().unwrap_or(it); - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) }; + let stage = vectorcraft_plugins::deferred::Stage { object: ctx.seed, effect: at, item: Some(i) }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i), at: Some(stage) }; let painted = vectorcraft_plugins::effect::apply_painted(plugin, ¶ms, &g, tb, rule, paints, doc) .map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::>()) .filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus))); @@ -1264,7 +1287,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, ctx.seed, limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1281,7 +1304,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt set_geometry(&mut p, g.clone(), rule); let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); - transform_piece(&mut p, ctx.seed, limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1297,14 +1320,17 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's -/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of +/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of /// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with /// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path /// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and /// strokes; their other effects stay. The paths keep to `max` together with the rest of the art -/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. -fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source, +/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the +/// piece was made from, and the index among that item's effects of each effect the piece kept, +/// which a deferred live-effect plug-in is listed by. +fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { @@ -1320,8 +1346,9 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: // What the other paths leave of the budget. let before = Budget::of(&path); let room = max.minus(used.minus(before)); + let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) }; if let Some(plugin) = painted_plugin(&e) { - if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) { + if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) { *used = used.minus(before).plus(made); } return; @@ -1340,7 +1367,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: AppearanceItem::Stroke(s) => Some(s), AppearanceItem::Fill(_) => None, }; - let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc }; + let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) }; let mut carry = crate::Carry::default(); let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry)); *used = used.minus(before).plus(Budget::of(&out)); diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 89f3bba86..43b339ccf 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -3,11 +3,11 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; +use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -25,17 +25,33 @@ const MAX_OVERLAP_TESTS: usize = 1 << 22; /// The width of the stroke around each face that covers the seams between faces. const SEAM_WIDTH: f64 = 0.75; -/// About the bytes one copy of `paint` takes. +/// About the bytes one copy of `paint` takes, with the strings it owns (swatch and pattern names). fn paint_bytes(paint: &Paint) -> usize { use std::mem::size_of; use vectorcraft_doc::color::{FreeformPoint, GradientStop}; let own = size_of::(); - let Paint::Gradient(g) = paint else { return own }; - let freeform = g - .freeform - .as_ref() - .map_or(0, |f| f.points.len() * size_of::() + f.lines.iter().map(|l| l.len() * size_of::()).sum::()); - own + size_of::() + g.gradient.stops.len() * size_of::() + freeform + let name = |s: &Option| s.as_ref().map_or(0, String::len); + let g = match paint { + Paint::Solid { swatch, .. } => return own + name(swatch), + Paint::Pattern { pattern, .. } => return own + pattern.len(), + Paint::Gradient(g) => g, + _ => return own, + }; + let freeform = g.freeform.as_ref().map_or(0, |f| { + // Each line is a Vec: its header too, not only its indices. + f.points.len() * size_of::() + + f.lines.len() * size_of::>() + + f.lines.iter().map(|l| l.len() * size_of::()).sum::() + }); + let stops: usize = g.gradient.stops.iter().map(|s| size_of::() + name(&s.swatch)).sum(); + own + size_of::() + name(&g.swatch) + stops + freeform +} + +/// Add `copies` copies of `paint` to the running byte total `used`; whether it still fits within +/// [`MAX_PAINT_COPIES`] (for the solid surfaces, whose items' copies count together). +pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -> bool { + *used = used.saturating_add(copies.saturating_mul(paint_bytes(paint))); + *used <= MAX_PAINT_COPIES } fn visible(fx: &[Effect]) -> bool { @@ -75,7 +91,14 @@ pub fn revolve_options(p: &Value) -> Revolve { /// Validate before a command mutates the document. Loading external files is still bounded /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { + validate_revolve_in(None, n, p) +} + +/// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group +/// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now. +pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; + let n = &*resolve_for(doc, n, "Revolve", false)?; let effects = &n.appearance.effects; // The profile is measured before the object's one Revolve, hidden or shown. let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); @@ -318,12 +341,26 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::Blend /// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs /// to its entire surface, not to each face, so overlapping faces don't accumulate alpha. pub fn revolve_art(n: &Node) -> Option { - art(n, false) + revolve_art_in(None, n) } -/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged. +/// [`revolve_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone +/// or in a group, revolve the art they show ([`resolve_for`]). `None` too when that art can't be +/// resolved. +pub fn revolve_art_in(doc: Option<&Document>, n: &Node) -> Option { + art(doc, n, false) +} + +/// [`expand_revolve_art_in`] without a document. +#[cfg(test)] pub(crate) fn expand_revolve_art(n: &Node) -> Option { - art(n, true) + expand_revolve_art_in(None, n) +} + +/// Revolve's own Expand Appearance path, with the symbols of `doc` ([`revolve_art_in`]). The shared +/// command and UI remain unchanged. +pub(crate) fn expand_revolve_art_in(doc: Option<&Document>, n: &Node) -> Option { + art(doc, n, true) } /// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on @@ -388,10 +425,11 @@ fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result Option { +fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } + let n = &*resolve_for(doc, n, "Revolve", false).ok()?; if is_container(n) { return group_art(n, expand); } @@ -441,22 +479,19 @@ fn art(n: &Node, expand: bool) -> Option { /// The members' `surfaces`, or runs of their faces, in order, from the [`Scope`] at `level` of each /// one's `Profile::scopes` inward: consecutive surfaces that share a scope go in one group with its -/// opacity and blending mode, so each applies once, to the faces it holds together. -fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Arc)>, level: usize) -> Vec> { - fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option, level: usize, out: &mut Vec>) { +/// opacity, blending mode and opacity mask, so each applies once, to the faces it holds together. +pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option<&Scope>, level: usize, out: &mut Vec>) { if let Some(scope) = scope { - let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); - group.opacity = scope.opacity; - group.blend = scope.blend; - out.push(Arc::new(group)); + out.push(Arc::new(scope.group(id, scoped(id, std::mem::take(run), level + 1)))); } } let mut out = vec![]; let mut run = vec![]; - let mut scope = None; + let mut scope: Option<&'a Scope> = None; for (scopes, s) in surfaces { - let at = scopes.get(level).copied(); - if at.map(|a| a.key) != scope.map(|s: Scope| s.key) { + let at = scopes.get(level); + if at.map(|a| a.key) != scope.map(|s| s.key) { close(id, &mut run, scope, level, &mut out); scope = at; } @@ -1143,6 +1178,38 @@ mod tests { assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget"); } + #[test] + fn the_art_of_symbol_instances_and_blends_shares_the_budget_of_paint_copies() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{BlendSpacing, BlendSpec, Symbol}; + let plain = member(1, 0.0); + let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let painted = |id: u64, x: f64| { + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut node = (*member(id, x)).clone(); + node.appearance = Appearance::basic(paint, Paint::None, 0.0).into(); + Arc::new(node) + }; + let mut d = Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: false, name: "P".into(), art: painted(0, 0.0) }); + let instance = |id: u64| Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: "P".into(), xf: Affine::IDENTITY })); + // One instance's art fits; two in the same place, each of whose surfaces fits alone, pass + // the budget together, as two paths would. + assert_eq!(validate_revolve_in(Some(&d), &group_with(json!({}), vec![instance(1)]), &json!({})), Ok(())); + let two = group_with(json!({}), vec![instance(1), instance(2)]); + let refused = validate_revolve_in(Some(&d), &two, &json!({})).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(revolve_art_in(Some(&d), &two), None, "past the budget the source draws"); + // A blend's keys and step count together too. + let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() }); + let blend = Arc::new(Node::new(NodeId(3), NodeKind::Blend { children: vec![painted(4, 0.0), painted(5, 0.0)], spec })); + let refused = validate_revolve_in(Some(&d), &group_with(json!({}), vec![blend]), &json!({})).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + } + #[test] fn the_fills_of_one_object_share_the_budget_of_paint_copies() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ab119192c..be6c68ba7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; @@ -17,6 +17,9 @@ use crate::{GeomContext, apply_geometry_with, is_geometry}; mod classic; pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; +mod source; +pub(crate) use source::{resolve, resolve_for}; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; @@ -198,10 +201,22 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { Ok(()) } +/// The name an error gives 3D effect `id`. +pub(crate) fn label_of(id: &str) -> &'static str { + crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')) +} + /// Validate before a command changes the document; the evaluator bounds loaded files too. pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_solid_in(None, n, id, p) +} + +/// [`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_params(id, p)?; - let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); + 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"))?; @@ -213,31 +228,85 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { } /// [`profile`], or the error a command reports for object `n`: type with no outlines to build from -/// (empty, or only spaces) is a shape without anchors, and any other object is the wrong kind. +/// (empty, or only spaces) is a shape without anchors, an image is traced first, and any other +/// object is the wrong kind. pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> { - profile(n).ok_or_else(|| { - if matches!(n.kind, NodeKind::Text(_)) { - format!("{label} requires a shape with at least two anchors") - } else { - format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") - } + profile(n).ok_or_else(|| match n.kind { + NodeKind::Text(_) => format!("{label} requires a shape with at least two anchors"), + NodeKind::Image(_) => source::image_refusal(label), + _ => format!("{label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first."), }) } -/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending -/// mode is not Normal. The members of one group share its `key`. -#[derive(Clone, Copy, Debug, PartialEq)] +/// A group in a 3D effect's group, or a member of it, whose opacity is below 100%, whose blending +/// mode is not Normal or that has an opacity mask. The members of one group share its `key`. +#[derive(Clone, Debug, PartialEq)] pub(crate) struct Scope { pub(crate) key: usize, pub(crate) opacity: f32, pub(crate) blend: BlendMode, + /// The object's opacity mask, unless it has none or it is disabled. The mask art is in + /// document coordinates, as the solids' faces are. + pub(crate) mask: Option>, +} + +impl Scope { + /// The scope of `n` with key `key`, or `None` when `n` is opaque, blends normally and has no + /// opacity mask that applies. + fn of(n: &Node, key: usize) -> Option { + let mask = n.extras().mask.as_deref().filter(|m| !m.disabled); + (n.opacity < 1.0 || n.blend != BlendMode::Normal || mask.is_some()).then(|| Scope { + key, + opacity: n.opacity, + blend: n.blend, + mask: mask.map(|m| Arc::new(m.clone())), + }) + } + + /// A group of `children` with id `id` and this scope's opacity, blending mode and opacity mask. + pub(crate) fn group(&self, id: NodeId, children: Vec>) -> Node { + let mut group = Node::group(id, children); + group.opacity = self.opacity; + group.blend = self.blend; + if let Some(mask) = &self.mask { + group.extras_mut().mask = Some(Box::new((**mask).clone())); + } + group + } +} + +/// Does `scope` draw its members' faces other than plainly: does it have an opacity mask or a +/// blending mode other than Normal? +fn composites(scope: &Scope) -> bool { + scope.mask.is_some() || scope.blend != BlendMode::Normal +} + +/// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity +/// masks and blending modes of those scopes, so each applies to the faces of the members it holds. +/// The members' opacity is already in their paint ([`Profile::opacity`]), so these groups are +/// opaque. +fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc)>) -> Vec> { + let masks: Vec<(Vec, Arc)> = surfaces + .into_iter() + .map(|(scopes, s)| { + let masks = scopes.iter().filter(|s| composites(s)).map(|s| Scope { opacity: 1.0, ..s.clone() }).collect(); + (masks, s) + }) + .collect(); + crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0) +} + +/// 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 { + shapes.iter().any(|s| s.scopes.iter().any(composites)) } /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint /// it, and the opacity of the objects it is in multiplied together (1 for the object with the -/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from -/// the outermost group down to the member itself (none for the object itself). Group Revolve -/// applies each once, to the surfaces of the members it holds together. +/// effect itself). `scopes` lists those objects with transparency or an opacity mask of their own +/// ([`Scope`]), from the outermost group down to the member itself (none for the object itself). +/// Group Revolve applies each once, to the surfaces of the members it holds together; the other 3D +/// effects apply each opacity mask that way. pub(crate) struct Profile { pub(crate) path: PathData, pub(crate) rule: FillRule, @@ -264,8 +333,10 @@ const MAX_MEMBERS: usize = 4096; const MAX_MESH_TRIANGLES: usize = 2_000_000; /// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, -/// compound path and type in it, at any depth of its groups, each through its own geometry -/// effects and painted with its own fills and strokes. Like the effect on a group in the +/// compound path and type in it (after [`resolve`] gives it the art of its symbol instances, blends +/// and clipping groups), at any depth of its groups, each through its own geometry +/// effects, then those of each group it is in (on that group's members together), and painted +/// with its own fills and strokes. Like the effect on a group in the /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. pub(crate) fn members(n: &Node, label: &str) -> Result, String> { @@ -277,28 +348,33 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { let label = w.label; for c in n.children().into_iter().flatten() { - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } let opacity = opacity * c.opacity; let mut scopes = scopes.to_vec(); - if c.blend != BlendMode::Normal || c.opacity < 1.0 { + if let Some(scope) = Scope::of(c, w.keys + 1) { w.keys += 1; - scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend }); + scopes.push(scope); } if is_container(c) { + let first = w.out.len(); walk(c, opacity, &scopes, w)?; + // The group's own geometry effects reshape its members together, as it is drawn. + if let Some(shapes) = w.out.get_mut(first..) { + reshape_members(c, &c.appearance.effects, shapes); + } continue; } let Some((path, rule)) = profile(c) else { let what = match &c.kind { - NodeKind::Group { clip: true, .. } => "clipping groups", - NodeKind::Image(_) => "images", - NodeKind::SymbolInstance { .. } => "symbol instances", + NodeKind::Image(_) => return Err(source::image_refusal(label)), NodeKind::Path { clipping: true, .. } => "clipping paths", - _ => "objects other than paths, compound paths and type", + _ => "other objects", }; - return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); + return Err(format!( + "{label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first." + )); }; if w.out.len() >= MAX_MEMBERS { return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); @@ -319,7 +395,12 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { /// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by /// [`validate_solid`]. pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } + validate_three_d_in(None, n, id, p) +} + +/// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]). +pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) } } /// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of @@ -328,10 +409,18 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// item's geometry effects before it, together with the object's other items that carry a Revolve. /// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws. pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { + validate_three_d_at_in(None, n, id, p, at) +} + +/// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a +/// blend and a clipping group take a 3D effect as groups do: on the object itself. +pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { match at { - Some(_) if is_container(n) => Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()), + Some(_) if is_container(n) || source::resolves(n) => { + 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(n, id, p), + _ => validate_three_d_in(doc, n, id, p), } } @@ -402,6 +491,21 @@ fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) { } } +/// `item`'s paint, borrowed (for checks that must not copy it). +fn paint_ref(item: &AppearanceItem) -> &Paint { + match item { + AppearanceItem::Fill(f) => &f.paint, + AppearanceItem::Stroke(s) => &s.paint, + } +} + +/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored +/// lights give each facet derived paints (`lighting::solid_surface`). +fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { + let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some()); + facets.len().saturating_mul(if derived { 6 } else { 2 }) +} + /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the /// stroke's outline (whose contours nest as its holes do) for a stroke. fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) { @@ -411,22 +515,26 @@ 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]); + /// 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 and the region it paints. -/// The solids share the bounds of all their parts (`fallback` when none has any): one center to -/// turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines. -fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32, PathData, FillRule)>, Rect) { - let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes +/// a group's members from the back up, each with its shape's opacity, the region it paints and its +/// shape's scopes. The solids share the bounds of all their parts (`fallback` when none has any): +/// one center to turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines. +fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { + let parts: Vec = shapes .iter() .flat_map(|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)| { let (area, rule) = region(&s.path, s.rule, i); - (i, s.opacity, area, rule) + (i, s.opacity, area, rule, s.scopes.as_slice()) }) }) .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) } @@ -449,12 +557,17 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile /// 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); -/// otherwise each solid is a surface of its own, in that 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>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE; let colored = !classic && view_options(&p).has_colored_lights(); + let masks = has_masks(shapes); + // The paint copies of every solid together: past the allowance the source path is drawn. + let mut paint_bytes = 0usize; if fx.id == ROTATE { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. @@ -464,24 +577,28 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> let (paint, opacity, blend) = paint_of(item, s.opacity); let (area, rule) = region(&s.path, s.rule, item); let f = facets(ROTATE, &area, rule, b, &p).ok()?; - out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); + if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) { + return None; + } + out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored)))); } } - return Some(out); + return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } 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, _, _), f) in parts.iter().zip(built) { + for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { - Ok(f) => solids.push((item, *opacity, 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)), // 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 @@ -493,30 +610,33 @@ 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)> = solids + let surfaces: Vec<(Vec, Node, &[Scope])> = solids .into_iter() - .map(|(item, member, f)| { + .map(|(item, member, scopes, f)| { let depths: Vec = f.iter().map(|f| f.depth).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) + (depths, art, scopes) }) .collect(); + if masks { + return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect())); + } // One order takes a plain group of faces from each solid, one face for each facet. With // colored lights a surface can instead be one clipped composite of all its facets, or carry an // opacity mask, and then it stays whole. - let plain = surfaces.iter().all(|(depths, s)| { + let plain = surfaces.iter().all(|(depths, s, _)| { s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()) }); if !opaque || !plain { - return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect()); + return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect()); } let mut faces = vec![]; - for (depths, s) in surfaces { + for (depths, s, _) in surfaces { if let NodeKind::Group { children, .. } = s.kind { faces.extend(depths.into_iter().zip(children)); } @@ -535,9 +655,17 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// On a group or layer the effect builds a solid from each path, compound path and type in it, /// all turned about one shared axis ([`members`]). pub fn solid_art(n: &Node) -> Option { + solid_art_in(None, n) +} + +/// [`solid_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone +/// or in a group, build from the art they show ([`resolve`]). `None` too when that art can't be +/// resolved. +pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { if !has_solid(n) { return None; } + let n = &*resolve(doc, n, "").ok()?; if is_container(n) { return group_solid_art(n); } @@ -660,11 +788,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())), @@ -689,28 +817,44 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) /// such effect, its solids can't be built, none has a triangle, or together they have more than /// [`MAX_MESH_TRIANGLES`]. pub fn three_d_meshes(n: &Node) -> Result, String> { - meshes_within(n, &mut 0, MAX_MESH_TRIANGLES) + three_d_meshes_in(None, n) +} + +/// [`three_d_meshes`] with the symbols of `doc`: a symbol instance, a blend and a clipping group +/// build from the art they show ([`resolve`]). +pub fn three_d_meshes_in(doc: Option<&Document>, n: &Node) -> Result, String> { + meshes_within(doc, n, &mut 0, MAX_MESH_TRIANGLES) } /// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports /// them: the objects share one budget of [`MAX_MESH_TRIANGLES`]. pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator) -> Result, String> { - meshes_of_within(nodes, MAX_MESH_TRIANGLES) + three_d_meshes_of_in(None, nodes) } -/// [`three_d_meshes_of`] with a budget of `limit` triangles. -fn meshes_of_within<'a>(nodes: impl IntoIterator, limit: usize) -> Result, String> { +/// [`three_d_meshes_of`] with the symbols of `doc` ([`three_d_meshes_in`]). +pub fn three_d_meshes_of_in<'a>(doc: Option<&Document>, nodes: impl IntoIterator) -> Result, String> { + meshes_of_within(doc, nodes, MAX_MESH_TRIANGLES) +} + +/// [`three_d_meshes_of_in`] with a budget of `limit` triangles. +fn meshes_of_within<'a>(doc: Option<&Document>, nodes: impl IntoIterator, limit: usize) -> Result, String> { let mut total = 0usize; let mut out = vec![]; for n in nodes { - out.extend(meshes_within(n, &mut total, limit)?); + out.extend(meshes_within(doc, n, &mut total, limit)?); } Ok(out) } -/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an +/// [`three_d_meshes_in`], with the triangles of every solid added to `total` as it is built and an /// error as soon as `total` passes `limit`. -fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result, String> { +fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usize) -> Result, String> { + if !crate::has_revolve(n) && !has_solid(n) { + return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into()); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + let n = &*resolve(doc, n, label)?; let meshes = if crate::has_revolve(n) { crate::revolve::revolve_meshes(n, total, limit)? } else if !has_solid(n) { @@ -722,7 +866,7 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result String { + format!("{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.") +} + +/// Is `n` a symbol instance, a blend or a clipping group or layer? +pub(crate) fn resolves(n: &Node) -> bool { + matches!(n.kind, NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. }) || n.clips() +} + +/// Is `n`, or a visible member of `n` at any depth of its groups and layers, one [`resolves`] takes? +fn needs(n: &Node) -> bool { + let mut stack = vec![n]; + while let Some(n) = stack.pop() { + if resolves(n) { + return true; + } + if is_container(n) { + stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| &**c)); + } + } + false +} + +/// `n` as a 3D effect builds from it: unchanged when neither it nor a visible member of its groups +/// is a symbol instance, a blend or a clipping group, otherwise a group with `n`'s id, appearance +/// and transparency holding that art (a symbol instance's symbol art from `doc`). An error names +/// what can't be resolved: an image, a missing symbol, art nested or numbering past the limits, or +/// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut +/// for the solid effect `n` carries ([`resolve_for`]). +pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result, String> { + let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE); + resolve_for(doc, n, label, every_fill) +} + +/// [`resolve`] for the 3D effect that builds from `n`: `every_fill` for Rotate, which turns every +/// fill, so each fill a clipping path cuts keeps its own place among the strokes. Otherwise the +/// effect builds from the top fill, and the fills cut go together in the top fill's place. +pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, every_fill: bool) -> Result, String> { + if !needs(n) { + return Ok(Cow::Borrowed(n)); + } + let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None }; + let children = r.content(n, 0); + #[cfg(test)] + WORK.with(|w| w.set(r.work)); + let mut out = n.clone(); + out.kind = NodeKind::Group { children: children?, clip: false }; + Ok(Cow::Owned(out)) +} + +#[cfg(test)] +thread_local! { + /// The objects the last [`resolve`] on this thread visited or made ([`Resolver::work`]). + pub(super) static WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +struct Resolver<'a> { + doc: Option<&'a Document>, + label: &'a str, + /// Does each fill a clipping path cuts become a piece of its own ([`resolve_for`])? + every_fill: bool, + /// The objects other than groups and layers kept so far, counted against [`MAX_MEMBERS`]. + leaves: usize, + /// The objects visited or made so far, counted against [`MAX_WORK`]. + work: usize, + /// Inside clipping groups, the box of what their clipping paths show together. + shown: Option, +} + +impl Resolver<'_> { + /// Count `steps` more objects visited or made, or an error past [`MAX_WORK`]. + fn spend(&mut self, steps: usize) -> Result<(), String> { + self.work = self.work.saturating_add(steps); + if self.work > MAX_WORK { + return Err(self.too_much_work()); + } + Ok(()) + } + + fn too_much_work(&self) -> String { + format!("{} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects", self.label) + } + + /// `f` run on the art of `c`. The effects of an object or group can move its art into a clip, + /// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]). + fn inside(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result) -> Result { + let outer = self.shown; + if has_effects(c) { + self.shown = None; + } + let out = f(self); + self.shown = outer; + out + } + + /// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one, + /// shows, as the members of a plain group. + fn content(&mut self, n: &Node, depth: usize) -> Result>, String> { + let label = self.label; + if depth > MAX_DEPTH { + return Err(self.too_deep()); + } + self.spend(1)?; + match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => { + let missing = || format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available"); + let doc = self.doc.ok_or_else(missing)?; + let sym = doc.symbols.iter().find(|s| s.name == *symbol).ok_or_else(missing)?; + // Counted before the art is placed, so a symbol past the limit is never copied: + // first its own objects, which the instance's overrides keep, then the most + // objects placing makes, with those overrides and the symbol's 9-slice guides. + if self.work.saturating_add(sym.art.count_with_masks()) > MAX_WORK { + return Err(self.too_much_work()); + } + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc)); + if (self.work as u64).saturating_add(most) > MAX_WORK as u64 { + return Err(self.too_much_work()); + } + let placed = doc.place_symbol_art(symbol, &art, *xf).ok_or_else(missing)?; + Ok(self.list(&[Arc::new(placed)], depth + 1)?.0) + } + NodeKind::Blend { children, spec } => { + // Counted before the steps are made, so a blend past the limit is never expanded. + let made = vectorcraft_doc::blend::blend_steps(children, spec).into_iter().fold(children.len(), usize::saturating_add); + if self.leaves.saturating_add(made) > MAX_MEMBERS { + return Err(self.too_many()); + } + self.spend(made)?; + let steps: Vec> = crate::expand_live(self.doc, n).into_iter().map(Arc::new).collect(); + Ok(self.list(&steps, depth + 1)?.0) + } + _ if n.clips() => { + let Some((first, rest)) = kids(n).split_first() else { return Ok(vec![]) }; + if clip_nested_past(first, MAX_DEPTH.saturating_sub(depth)) { + return Err(self.too_deep()); + } + let clip = clip_area(first); + // An image wholly outside every clip around it shows nothing and is left out. + let outer = self.shown; + let here = clip.0.bounds().unwrap_or(Rect::ZERO); + self.shown = Some(outer.map_or(here, |o| o.intersect(here))); + let listed = self.list(rest, depth + 1); + self.shown = outer; + let (inner, _) = listed?; + let mut out = vec![]; + for c in &inner { + if let Some(c) = self.clipped(c, &clip, depth + 1)? { + out.push(Arc::new(c)); + } + } + Ok(out) + } + _ => Ok(self.list(kids(n), depth + 1)?.0), + } + } + + /// The visible members of `children` as plain art: images refused, guides left out, symbol + /// instances, blends and clipping groups resolved into groups with their transparency and + /// geometry effects, groups holding them rebuilt. Also whether any member changed: a group + /// whose members did not is kept as it is. Each object is visited once. + fn list(&mut self, children: &[Arc], depth: usize) -> Result<(Vec>, bool), String> { + let mut out = Vec::with_capacity(children.len()); + let mut changed = false; + for c in children { + self.spend(1)?; + if !c.visible || c.is_guide() { + continue; + } + if matches!(c.kind, NodeKind::Image(_)) { + // Only an image with no effects or fills and strokes of its own is measured by its + // frame; any other one may draw inside the clip. + let plain = !has_effects(c) && c.appearance.items.iter().all(|i| !i.visible()); + let hidden = plain && self.shown.is_some_and(|k| c.visual_bounds().is_none_or(|b| b.intersect(k).area() <= 0.0)); + if hidden { + changed = true; + continue; + } + return Err(image_refusal(self.label)); + } + if resolves(c) { + let kids = self.inside(c, |r| r.content(c, depth))?; + out.push(Arc::new(with_effects(wrap(c, kids), c))); + changed = true; + continue; + } + if is_container(c) { + // At the depth limit a group is kept, and not walked further, only when it holds no + // group with members and nothing in it needs resolving. + if depth >= MAX_DEPTH { + if nested_past(c, 1) || needs(c) { + return Err(self.too_deep()); + } + out.push(c.clone()); + continue; + } + let (kids, inner) = self.inside(c, |r| r.list(kids(c), depth + 1))?; + if inner { + out.push(Arc::new(with_effects(wrap(c, kids), c))); + changed = true; + } else { + out.push(c.clone()); + } + continue; + } + self.leaves += 1; + if self.leaves > MAX_MEMBERS { + return Err(self.too_many()); + } + out.push(c.clone()); + } + Ok((out, changed)) + } + + fn too_complex(&self) -> String { + format!("{}: the clipping path is too complex to cut the art with", self.label) + } + + fn too_many(&self) -> String { + format!("{} takes at most {MAX_MEMBERS} objects", self.label) + } + + fn too_deep(&self) -> String { + format!("{} can't build solids from art nested more than {MAX_DEPTH} levels deep", self.label) + } + + /// `n`, plain art inside a clipping group, cut to `clip`: each shape's fills as its area inside + /// the clip and each stroke as the part of its outline inside it, filled in the stroke's paint, + /// after the geometry effects of the shape and of the groups it is in; hidden objects and guides + /// left out. `None` when nothing of it is inside. Objects that build no solid stay as they are. + fn clipped(&mut self, n: &Node, clip: &(PathData, FillRule), depth: usize) -> Result, String> { + if depth > MAX_DEPTH { + return Err(self.too_deep()); + } + self.spend(1)?; + if is_container(n) { + let mut kids = vec![]; + if n.appearance.effects.iter().any(|e| e.visible && is_geometry(&e.id)) { + // The group's geometry effects reshape its members together before the clip cuts + // them, as they are drawn: each member becomes its reshaped outline. + if nested_past(n, MAX_DEPTH.saturating_sub(depth)) { + return Err(self.too_deep()); + } + let mut shapes = members(n, self.label)?; + reshape_members(n, &n.appearance.effects, &mut shapes); + let mut cut = vec![]; + for s in shapes { + if let Some(c) = self.clipped(&shape(n, s.path, s.rule, s.items), clip, depth + 1)? { + cut.push((s.scopes, c)); + } + } + kids = nest(n, cut, 0); + } else { + for c in n.children().into_iter().flatten() { + if !c.visible || c.is_guide() { + continue; + } + if let Some(c) = self.clipped(c, clip, depth + 1)? { + kids.push(Arc::new(c)); + } + } + } + return Ok((!kids.is_empty()).then(|| wrap(n, kids))); + } + let Some((path, rule)) = profile(n) else { return Ok(Some(n.clone())) }; + let items = solid_paints(n); + // The fills' piece goes where the top fill is, the one Extrude & Bevel and Inflate build + // their solid from, so the strokes above and below it keep their places. Rotate turns + // every fill, so each fill is a piece in its own place. + let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); + let fill_pieces = + if self.every_fill { items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() } else { usize::from(top_fill.is_some()) }; + // Each cut makes a piece, counted before it is made: the shape was counted once already. + let made = fill_pieces + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + self.spend(made)?; + self.leaves = self.leaves.saturating_add(made.saturating_sub(1)); + if self.leaves > MAX_MEMBERS { + return Err(self.too_many()); + } + let path = geometry(&path, &n.appearance.effects, &GeomContext::of(n)); + let cut = |area: &PathData, rule: FillRule| -> Result, String> { + let inside = + po::try_boolean(area, rule, &clip.0, clip.1, po::BoolOp::Intersect, po::DEFAULT_PRECISION).map_err(|_| self.too_complex())?; + Ok(inside.bounds().is_some().then_some(inside)) + }; + let fills: Vec = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect(); + // Every fill paints the same area: it is cut once. + let mut fill_area: Option> = None; + let mut pieces = vec![]; + for (k, item) in items.iter().enumerate() { + match item { + AppearanceItem::Fill(_) if self.every_fill || Some(k) == top_fill => { + let area = match &fill_area { + Some(area) => area.clone(), + None => fill_area.insert(cut(&path, rule)?).clone(), + }; + if let Some(area) = area { + let painted = if self.every_fill { vec![item.clone()] } else { fills.clone() }; + pieces.push(Arc::new(shape(n, area, FillRule::NonZero, painted))); + } + } + AppearanceItem::Fill(_) => {} + AppearanceItem::Stroke(s) => { + let (outline, outline_rule) = region(&path, rule, item); + if let Some(area) = cut(&outline, outline_rule)? { + let mut fill = FillLayer::new(s.paint.clone()); + fill.opacity = s.opacity; + fill.blend = s.blend; + pieces.push(Arc::new(shape(n, area, FillRule::NonZero, vec![AppearanceItem::Fill(fill)]))); + } + } + } + } + Ok((!pieces.is_empty()).then(|| wrap(n, pieces))) + } +} + +/// The area clipping path `first` keeps, as the renderer clips by it ([`crate::clip_outline`]): a +/// nested clipping group's own clipping path, the union of a group's members. Empty when it keeps +/// nothing. +fn clip_area(first: &Node) -> (PathData, FillRule) { + crate::clip_outline(first).map_or_else(|| (PathData::default(), FillRule::NonZero), |(bp, rule)| (PathData::from_bezpath(&bp), rule)) +} + +/// `pieces`, each with the [`Scope`]s of the groups it was in, back in groups of `n`'s id with +/// those groups' opacity, blending mode and opacity mask: consecutive pieces sharing the scope at +/// `level` go into one group, so its transparency applies to them together. `level` stays below the +/// nesting the pieces came from ([`nested_past`]). +fn nest(n: &Node, pieces: Vec<(Vec, Node)>, level: usize) -> Vec> { + let mut out = vec![]; + let mut run: Vec<(Vec, Node)> = vec![]; + let mut key: Option = None; + let close = |run: &mut Vec<(Vec, Node)>, scope: Option, out: &mut Vec>| { + let Some(scope) = scope else { return }; + out.push(Arc::new(scope.group(n.id, nest(n, std::mem::take(run), level + 1)))); + }; + for (scopes, piece) in pieces { + let here = scopes.get(level).cloned(); + if here.as_ref().map(|s| s.key) != key.as_ref().map(|s| s.key) { + close(&mut run, key.take(), &mut out); + key = here.clone(); + } + match here { + Some(_) => run.push((scopes, piece)), + None => out.push(Arc::new(piece)), + } + } + close(&mut run, key, &mut out); + out +} + +/// Does `n` carry a visible effect? +fn has_effects(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible) +} + +/// Does the group `n` hold shown objects nested more than `levels` deep in groups and layers? +/// Only the members [`members`] walks are followed: hidden members are skipped, and the paths of +/// a compound path are one shape. Walked without recursion. +fn nested_past(n: &Node, levels: usize) -> bool { + let mut stack = vec![(n, 0usize)]; + while let Some((n, level)) = stack.pop() { + if level > levels { + return true; + } + if level == 0 || is_container(n) { + stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| (&**c, level + 1))); + } + } + false +} + +/// Does the clipping path `n` reach objects nested more than `levels` deep along the members that +/// [`Node::clip_shapes`] visits: the first member of a clipping group, the shown members of other +/// groups and layers (not a group's shaper), and every member of a live object. Walked without +/// recursion. +fn clip_nested_past(n: &Node, levels: usize) -> bool { + let mut stack = vec![(n, 0usize)]; + while let Some((n, level)) = stack.pop() { + if level > levels { + return true; + } + match &n.kind { + NodeKind::Group { children, clip: true } => stack.extend(children.first().map(|c| (&**c, level + 1))), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + let skip = usize::from(n.extras().shaper.is_some()); + stack.extend(children.iter().skip(skip).filter(|c| c.visible).map(|c| (&**c, level + 1))); + } + NodeKind::Compound { .. } | NodeKind::SymbolInstance { .. } => {} + _ => stack.extend(kids(n).iter().map(|c| (&**c, level + 1))), + } + } + false +} + +/// The members of `n`, none for an object without members. +fn kids(n: &Node) -> &[Arc] { + n.children().map_or(&[], Vec::as_slice) +} + +/// A path of `n`'s id on `path`, painted by `items` under `rule`. +fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec) -> Node { + let mut piece = Node::path(n.id, path, Appearance { items, ..Default::default() }); + if let NodeKind::Path { rule: r, .. } = &mut piece.kind { + *r = rule; + } + piece +} + +/// A plain group of `children` with `n`'s id, visibility, opacity, blending mode and opacity mask. +fn wrap(n: &Node, children: Vec>) -> Node { + let mut g = Node::group(n.id, children); + g.name = n.name.clone(); + g.opacity = n.opacity; + g.blend = n.blend; + if let Some(mask) = &n.extras().mask { + g.extras_mut().mask = Some(mask.clone()); + } + g +} + +/// `g`, the group `n` was resolved into, with `n`'s appearance: a 3D effect's group reshapes its +/// members with its geometry effects ([`members`]), which read its stroke as they do when `n` is +/// drawn. A group's own fills and strokes build no solid. +fn with_effects(mut g: Node, n: &Node) -> Node { + g.appearance = n.appearance.clone(); + g +} diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 29bbdb3bd..7273a44b2 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -205,12 +205,12 @@ fn a_stroked_open_path_builds_a_ribbon() { /// still draws. #[test] fn a_stroke_past_the_limits_is_left_out() { - // 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline. - let points: Vec = (0..1500) + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) .map(|k| { - let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos(); - let r = if k % 2 == 0 { 100.0 } else { 96.0 }; - Point::new(200.0 + r * c, 200.0 + r * s) + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) }) .collect(); let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); @@ -591,13 +591,14 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() { // Hidden members and guides are left out; a group holding what can't become a solid is // refused with what to do. let mut g = group(EXTRUDE, json!({})); - let mut clip = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]); - clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true }; + let mut woven = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]); + woven.kind = NodeKind::Intertwine { children: woven.children().unwrap().clone() }; if let NodeKind::Group { children, .. } = &mut g.kind { - children.push(Arc::new(clip)); + children.push(Arc::new(woven)); } let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err(); - assert!(err.contains("clipping groups"), "{err}"); + assert!(err.contains("release or expand its other objects"), "{err}"); + assert!(err.contains("type, groups, clipping groups, blends and symbol instances"), "{err}"); assert!(solid_art(&g).is_none(), "drawn as the group it is"); } diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index 7b223f389..1ae347c97 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -79,7 +79,7 @@ fn revolve_stops_at_the_first_mesh_past_the_budget() { assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}"); // With room for less than the fill's mesh, the stroke's is never built. let mut total = 0; - let capped = meshes_within(&n, &mut total, triangles - 1); + let capped = meshes_within(None, &n, &mut total, triangles - 1); assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}"); assert_eq!(total, triangles); } @@ -92,10 +92,10 @@ fn the_objects_of_one_asset_share_one_triangle_budget() { b.appearance.effects.push(fx(EXTRUDE, json!({}))); let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::(); let (ta, tb) = (triangles(&a), triangles(&b)); - assert!(meshes_of_within([&a], ta).is_ok()); - assert!(meshes_of_within([&b], tb).is_ok()); - assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export"))); - assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4)); + assert!(meshes_of_within(None, [&a], ta).is_ok()); + assert!(meshes_of_within(None, [&b], tb).is_ok()); + assert!(meshes_of_within(None, [&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export"))); + assert_eq!(meshes_of_within(None, [&a, &b], ta + tb).map(|m| m.len()), Ok(4)); assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4)); } diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs new file mode 100644 index 000000000..b2f6daf1c --- /dev/null +++ b/crates/effects/src/solids/tests_sources.rs @@ -0,0 +1,642 @@ +use serde_json::json; +use vectorcraft_doc::color::Color; +use vectorcraft_doc::{AppearanceItem, BlendSpacing, BlendSpec, Document, FillLayer, ImageObject, NodeId, StrokeLayer, Symbol}; +use vectorcraft_geom::{Affine, shapes}; + +use super::*; + +const TYPES: [&str; 5] = [EXTRUDE, CLASSIC_EXTRUDE, INFLATE, ROTATE, crate::REVOLVE]; + +/// Rotate facing the viewer: the art drawn where it is. +fn front() -> Value { + json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0}) +} + +fn rect(id: u64, x0: f64, y0: f64, x1: f64, y1: f64, stroke: f64) -> Node { + let path = shapes::rectangle(Rect::new(x0, y0, x1, y1)); + let stroke_paint = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None }; + Node::path(NodeId(id), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), stroke_paint, stroke)) +} + +fn with(mut n: Node, id: &str, params: Value) -> Node { + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + n +} + +fn art(d: &Document, n: &Node, id: &str) -> Option { + if id == crate::REVOLVE { crate::revolve_art_in(Some(d), n) } else { solid_art_in(Some(d), n) } +} + +fn bounds(n: &Node) -> Rect { + n.visual_bounds().unwrap() +} + +fn close(a: Rect, b: Rect, tolerance: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() <= tolerance) +} + +fn document() -> Document { + let mut d = Document::new(1000.0, 1000.0); + d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(rect(0, 0.0, 0.0, 100.0, 100.0, 2.0)) }); + d +} + +/// A rectangular clipping path covering `clip`. +fn clipping_path(id: u64, clip: Rect) -> Node { + let mut path = Node::path(NodeId(id), shapes::rectangle(clip), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + path +} + +/// A clipping group: `art` inside a rectangular clipping path covering `clip`. +fn clip_group(id: u64, clip: Rect, art: Node) -> Node { + Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clipping_path(id + 1, clip)), Arc::new(art)], clip: true }) +} + +fn blend(id: u64, steps: u32) -> Node { + let keys = vec![Arc::new(rect(id + 1, 100.0, 100.0, 140.0, 140.0, 0.0)), Arc::new(rect(id + 2, 300.0, 120.0, 360.0, 180.0, 0.0))]; + let spec = BlendSpec { spacing: BlendSpacing::Steps(steps), ..Default::default() }; + Node::new(NodeId(id), NodeKind::Blend { children: keys, spec: Box::new(spec) }) +} + +fn instance(id: u64, symbol: &str, xf: Affine) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +fn image(id: u64) -> Node { + Node::new( + NodeId(id), + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })), + ) +} + +fn sources() -> [(&'static str, Node); 3] { + [ + ("clipping group", clip_group(10, Rect::new(100.0, 100.0, 150.0, 200.0), rect(12, 100.0, 100.0, 200.0, 200.0, 2.0))), + ("blend", blend(20, 3)), + ("symbol instance", instance(30, "Box", Affine::translate((300.0, 0.0)))), + ] +} + +#[test] +fn every_three_d_type_builds_clipping_groups_blends_and_symbol_instances_alone_and_in_a_group() { + let d = document(); + for (what, source) in sources() { + for id in TYPES { + let n = with(source.clone(), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id} on a {what}"); + assert!(art(&d, &n, id).is_some_and(|a| a.visual_bounds().is_some()), "{id} on a {what} draws"); + assert!(three_d_meshes_in(Some(&d), &n).is_ok_and(|m| !m.is_empty()), "{id} on a {what} exports"); + let g = with(Node::group(NodeId(90), vec![Arc::new(source.clone()), Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0))]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding a {what}"); + assert!(art(&d, &g, id).is_some(), "{id} on a group holding a {what} draws"); + assert!(three_d_meshes_in(Some(&d), &g).is_ok_and(|m| !m.is_empty()), "{id} on a group holding a {what} exports"); + } + } +} + +#[test] +fn a_clipping_groups_solid_is_built_from_its_art_inside_the_clip() { + let d = document(); + let clip = Rect::new(100.0, 100.0, 150.0, 200.0); + let fill_only = with(clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 0.0)), ROTATE, front()); + let cut = with(Node::group(NodeId(10), vec![Arc::new(rect(12, 100.0, 100.0, 150.0, 200.0, 0.0))]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &fill_only).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); + // The stroke is cut to the clip too: nothing reaches past it but the faces' seams. + let stroked = with(clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 8.0)), EXTRUDE, front()); + let b = bounds(&solid_art_in(Some(&d), &stroked).unwrap()); + let outside = b.x0 < clip.x0 - 1.0 || b.x1 > clip.x1 + 1.0; + let extruded = + bounds(&solid_art(&with(Node::group(NodeId(10), vec![Arc::new(rect(12, 100.0, 100.0, 150.0, 200.0, 0.0))]), EXTRUDE, front())).unwrap()); + assert!(!outside && close(b, extruded, 1.0), "{b:?} within {clip:?}"); + // A clip inside a clip keeps what both show. + let nested = + with(clip_group(40, Rect::new(120.0, 0.0, 400.0, 400.0), clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))), ROTATE, front()); + let both = with(Node::group(NodeId(10), vec![Arc::new(rect(12, 120.0, 100.0, 150.0, 200.0, 0.0))]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &nested).unwrap()), bounds(&solid_art(&both).unwrap()), 0.01)); + // A clipping group used as the clipping path clips by its own clipping path, not by its art. + let mask = Arc::new(clip_group(70, clip, rect(72, 100.0, 100.0, 110.0, 110.0, 0.0))); + let by_mask = with( + Node::new(NodeId(10), NodeKind::Group { children: vec![mask, Arc::new(rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))], clip: true }), + ROTATE, + front(), + ); + assert!(close(bounds(&solid_art_in(Some(&d), &by_mask).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); + // A stroke between two fills keeps its place below the top fill's solid. + let mut layered = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0); + layered.appearance = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))), + AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), 6.0)), + AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))), + ], + ..Default::default() + } + .into(); + let colors = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), EXTRUDE, json!({}))).unwrap().iter().map(|m| m.color).collect::>(); + let plain = colors(&Node::group(NodeId(10), vec![Arc::new(layered.clone())])); + assert_eq!(plain.len(), 2, "the stroke's solid and the top fill's"); + assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered.clone())), plain); + // Rotate turns both fills and the stroke, each in its own place. + let turned = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), ROTATE, front())).unwrap().iter().map(|m| m.color).collect::>(); + let plain = turned(&Node::group(NodeId(10), vec![Arc::new(layered.clone())])); + assert_eq!(plain.len(), 3, "both fills' surfaces and the stroke's"); + let clipped = clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered); + assert_eq!(turned(&clipped), plain); + assert_eq!(validate_three_d_in(Some(&d), &clipped, ROTATE, &json!({})), Ok(())); + // Each fill Rotate turns counts against the limit on objects; another effect takes the top + // fill only, whatever 3D effect the object carries before it changes type. + let mut many = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0); + many.appearance = + Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); MAX_MEMBERS + 1], ..Default::default() }.into(); + let rotated = with(clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), many), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &rotated, ROTATE, &json!({})).is_err_and(|e| e.contains("takes at most"))); + for id in [EXTRUDE, crate::REVOLVE] { + assert_eq!(validate_three_d_in(Some(&d), &rotated, id, &json!({})), Ok(()), "Rotate changed to {id}"); + } + // Art nested past the limit inside a clipping group is refused, not followed to any depth. + let mut deep = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0); + for i in 0..200 { + deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]); + } + let n = with(clip_group(10, clip, deep.clone()), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep"))); + assert!(solid_art_in(Some(&d), &n).is_none()); + // So is a clipping path nested past the limit, and a symbol's art nested past it. + let by_deep = + Node::new(NodeId(10), NodeKind::Group { children: vec![Arc::new(deep.clone()), Arc::new(rect(12, 0.0, 0.0, 10.0, 10.0, 0.0))], clip: true }); + let mut deep_symbol = d.clone(); + deep_symbol.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(deep) }); + for n in [with(by_deep, ROTATE, front()), with(instance(30, "Deep", Affine::IDENTITY), ROTATE, front())] { + assert!(validate_three_d_in(Some(&deep_symbol), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep")), "{:?}", n.id); + assert!(three_d_meshes_in(Some(&deep_symbol), &n).is_err()); + } + // A compound path counts as one shape at the depth limit: a symbol's art nested as deep as a + // path may be also takes a compound path there. + let square = rect(80, 0.0, 0.0, 10.0, 10.0, 0.0); + let mut compound = + Node::new(NodeId(81), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance = square.appearance.clone(); + let mut seen = vec![]; + for levels in 60..70 { + let ok = |leaf: &Node| { + let mut art = leaf.clone(); + for i in 0..levels { + art = Node::group(NodeId(2000 + i), vec![Arc::new(art)]); + } + let mut doc = d.clone(); + doc.symbols.push(Symbol { dynamic: false, name: "Nested".into(), art: Arc::new(art) }); + validate_three_d_in(Some(&doc), &with(instance(30, "Nested", Affine::IDENTITY), ROTATE, front()), ROTATE, &json!({})).is_ok() + }; + let path_ok = ok(&square); + assert_eq!(ok(&compound), path_ok, "{levels} levels"); + seen.push(path_ok); + } + assert!(seen.contains(&true) && seen.contains(&false), "the levels cross the limit: {seen:?}"); + // A clipping group used as the clipping path clips by its first member only, so deep art it + // clips is not walked for the clip. + let mask = Node::new( + NodeId(70), + NodeKind::Group { children: vec![Arc::new(clipping_path(71, clip)), Arc::new(deep_symbol.symbols[1].art.as_ref().clone())], clip: true }, + ); + let by_deep_mask = with( + Node::new(NodeId(10), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))], clip: true }), + ROTATE, + front(), + ); + assert_eq!(validate_three_d_in(Some(&d), &by_deep_mask, ROTATE, &json!({})), Ok(())); + assert!(close(bounds(&solid_art_in(Some(&d), &by_deep_mask).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); + // A clip that shows none of the art builds nothing, and says so. + let empty = clip_group(10, Rect::new(500.0, 500.0, 550.0, 550.0), rect(12, 100.0, 100.0, 200.0, 200.0, 2.0)); + for id in TYPES { + let n = with(empty.clone(), id, json!({})); + assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("at least two anchors")), "{id}"); + assert!(art(&d, &n, id).is_none(), "{id}"); + } +} + +#[test] +fn a_symbol_instances_solid_is_its_symbols_art_where_the_instance_places_it() { + let d = document(); + let placed = with(instance(30, "Box", Affine::translate((300.0, 50.0))), EXTRUDE, json!({})); + let same = with(Node::group(NodeId(30), vec![Arc::new(rect(31, 300.0, 50.0, 400.0, 150.0, 2.0))]), EXTRUDE, json!({})); + assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&same).unwrap()), 0.01)); + // A redefined symbol reshapes the solid. + let mut wider = d.clone(); + wider.symbols[0].art = Arc::new(rect(0, 0.0, 0.0, 200.0, 100.0, 2.0)); + assert!(bounds(&solid_art_in(Some(&wider), &placed).unwrap()).width() > bounds(&solid_art_in(Some(&d), &placed).unwrap()).width() + 50.0); + // Without the symbol there is nothing to build from. + let missing = with(instance(30, "Gone", Affine::IDENTITY), EXTRUDE, json!({})); + assert!(validate_three_d_in(Some(&d), &missing, EXTRUDE, &json!({})).is_err_and(|e| e.contains("\"Gone\""))); + assert!(validate_three_d(&placed, EXTRUDE, &json!({})).is_err(), "no document: no symbol art"); + // A symbol holding an instance of itself is refused, not followed without end. + let mut looped = d.clone(); + let inner = Arc::new(instance(0, "Loop", Affine::translate((10.0, 10.0)))); + looped.symbols.push(Symbol { + dynamic: false, + name: "Loop".into(), + art: Arc::new(Node::group(NodeId(0), vec![Arc::new(rect(0, 0.0, 0.0, 10.0, 10.0, 0.0)), inner])), + }); + let n = with(instance(30, "Loop", Affine::IDENTITY), EXTRUDE, json!({})); + assert!(validate_three_d_in(Some(&looped), &n, EXTRUDE, &json!({})).is_err()); + assert!(solid_art_in(Some(&looped), &n).is_none()); +} + +#[test] +fn a_blends_solid_is_built_from_its_keys_and_steps() { + let d = document(); + for steps in [1, 3, 8] { + let n = with(blend(20, steps), ROTATE, front()); + let expanded = Node::group(NodeId(20), crate::expand_live(Some(&d), &blend(20, steps)).into_iter().map(Arc::new).collect()); + assert_eq!(expanded.children().unwrap().len(), steps as usize + 2); + let same = with(expanded, ROTATE, front()); + let (a, b) = (solid_art_in(Some(&d), &n).unwrap(), solid_art(&same).unwrap()); + assert_eq!(a.children().unwrap().len(), b.children().unwrap().len(), "a surface for each key and step"); + assert!(close(bounds(&a), bounds(&b), 0.01)); + } + // A blend making more objects than a 3D effect takes is refused. + let keys = (0..6).map(|i| Arc::new(rect(21 + i, 100.0 * i as f64, 0.0, 100.0 * i as f64 + 40.0, 40.0, 0.0))).collect(); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() }; + let n = with(Node::new(NodeId(20), NodeKind::Blend { children: keys, spec: Box::new(spec) }), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("at most 4096 objects"))); +} + +#[test] +fn images_are_refused_alone_and_in_a_group_with_image_trace_named() { + let d = document(); + for id in TYPES { + for n in [ + with(image(50), id, json!({})), + with(Node::group(NodeId(51), vec![Arc::new(rect(52, 0.0, 0.0, 10.0, 10.0, 0.0)), Arc::new(image(53))]), id, json!({})), + with(clip_group(60, Rect::new(0.0, 0.0, 10.0, 10.0), image(62)), id, json!({})), + ] { + let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err(); + assert!(err.contains("Object › Image Trace › Make"), "{id}: {err}"); + assert!(art(&d, &n, id).is_none(), "{id}"); + assert!(three_d_meshes_in(Some(&d), &n).is_err_and(|e| e.contains("Image Trace")), "{id}"); + } + // An image wholly outside the clip, alone or in a group, shows nothing and is left out; + // one the clip shows part of is refused. + let clip = Rect::new(100.0, 100.0, 150.0, 150.0); + let masked = |inside: bool| { + let mut img = image(63); + if let NodeKind::Image(i) = &mut img.kind { + i.xf = Affine::translate(if inside { (95.0, 95.0) } else { (300.0, 300.0) }); + } + img + }; + for img in [masked(false), Node::group(NodeId(64), vec![Arc::new(masked(false))])] { + let children = vec![Arc::new(clipping_path(61, clip)), Arc::new(rect(62, 100.0, 100.0, 200.0, 200.0, 0.0)), Arc::new(img)]; + let n = with(Node::new(NodeId(60), NodeKind::Group { children, clip: true }), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}"); + assert!(art(&d, &n, id).is_some(), "{id}"); + } + let partly = with(clip_group(60, clip, masked(true)), id, json!({})); + assert!(validate_three_d_in(Some(&d), &partly, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}"); + // An effect on the image, or on a group holding it, can move it into the clip: refused. + let moved = |n: Node| with(n, "distort.transform", json!({"moveH": -200.0, "moveV": -200.0})); + for img in [moved(masked(false)), moved(Node::group(NodeId(64), vec![Arc::new(masked(false))]))] { + let n = with(clip_group(60, clip, img), id, json!({})); + assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}"); + } + // A hidden image is not drawn, so it builds nothing and refuses nothing. + let mut hidden = image(53); + hidden.visible = false; + let g = with(Node::group(NodeId(51), vec![Arc::new(rect(52, 0.0, 0.0, 10.0, 10.0, 0.0)), Arc::new(hidden)]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id}"); + } +} + +/// Symbols `L0` to `L{levels}`: `L0` holds `leaf`, each later one `fan` instances of the one +/// before it. +fn nested_symbols(levels: usize, fan: usize, leaf: Node) -> Document { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "L0".into(), art: Arc::new(Node::group(NodeId(0), vec![Arc::new(leaf)])) }); + for level in 1..=levels { + let below = format!("L{}", level - 1); + let members = (0..fan).map(|i| Arc::new(instance(0, &below, Affine::translate((i as f64 * 2.0, 0.0))))).collect(); + d.symbols.push(Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(Node::group(NodeId(0), members)) }); + } + d +} + +#[test] +fn nested_symbol_instances_that_repeat_hidden_art_are_refused_past_the_work_limit() { + // Four instances at each of ten levels repeat the hidden path about a million times, with no + // object to count against the 4,096-object limit. + let mut hidden = rect(0, 0.0, 0.0, 10.0, 10.0, 0.0); + hidden.visible = false; + let d = nested_symbols(10, 4, hidden); + for id in TYPES { + let n = with(instance(30, "L10", Affine::IDENTITY), id, json!({})); + let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{id}: {err}"); + assert!(source::WORK.with(std::cell::Cell::get) <= source::MAX_WORK + 1, "{id}: stopped at the limit"); + assert!(art(&d, &n, id).is_none(), "{id}"); + assert!(three_d_meshes_in(Some(&d), &n).is_err(), "{id}"); + } + // A clipped shape with more strokes than a 3D effect takes objects is refused before its + // strokes are cut into pieces. + let mut stroked = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0); + let strokes = (0..5000).map(|_| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 2.0))).collect(); + stroked.appearance = Appearance { items: strokes, ..Default::default() }.into(); + let n = with(clip_group(10, Rect::new(100.0, 100.0, 150.0, 200.0), stroked), ROTATE, front()); + let err = validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).unwrap_err(); + assert!(err.contains("takes at most 4096 objects") && !err.contains("on a group"), "{err}"); + // The same nesting with 1,024 visible squares stays far below the limit and builds: each + // square, group and instance is visited once (an instance twice: listed, then resolved). + let d = nested_symbols(5, 4, rect(0, 0.0, 0.0, 10.0, 10.0, 0.0)); + let n = with(instance(30, "L5", Affine::IDENTITY), ROTATE, front()); + assert_eq!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})), Ok(())); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 1024 + 1365 * 3, "{work}"); + assert!(solid_art_in(Some(&d), &n).is_some()); +} + +#[test] +fn a_symbol_holding_more_objects_than_the_work_limit_is_refused_before_its_art_is_placed() { + let mut d = document(); + let members = (0..=source::MAX_WORK).map(|i| Arc::new(rect(0, i as f64, 0.0, i as f64 + 1.0, 1.0, 0.0))).collect(); + d.symbols.push(Symbol { dynamic: false, name: "Big".into(), art: Arc::new(Node::group(NodeId(0), members)) }); + let n = with(instance(30, "Big", Affine::IDENTITY), EXTRUDE, json!({})); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}"); +} + +/// A gradient fill: a path filled with one is drawn in clip groups where it crosses 9-slice guides. +fn gradient() -> Paint { + use vectorcraft_doc::color::{Gradient, GradientPaint}; + Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))) +} + +/// A document whose symbol "Sliced" has 9-slice guides and holds `count` paths across all of them, +/// each filled with `fill`, and an Extrude on an instance of it at twice its size. +fn sliced(count: usize, fill: impl Fn() -> Paint) -> (Document, Node) { + let mut d = document(); + let members = (0..count).map(|i| { + Arc::new(Node::path(NodeId(i as u64 + 1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill(), Paint::None, 0.0))) + }); + d.symbols.push(Symbol { dynamic: true, name: "Sliced".into(), art: Arc::new(Node::group(NodeId(0), members.collect())) }); + d.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let n = with(instance(30, "Sliced", Affine::scale(2.0)), EXTRUDE, json!({})); + (d, n) +} + +#[test] +fn a_symbol_whose_9_slice_guides_would_place_too_many_objects_is_refused_before_its_art_is_placed() { + // Each gradient path becomes a group of nine clip groups, so the art placed is far past the + // limit while the symbol itself holds less than a tenth of it. + let (d, n) = sliced(source::MAX_WORK / 20, gradient); + assert!(d.symbols[1].art.count_with_masks() < source::MAX_WORK); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}"); +} + +#[test] +fn a_dynamic_symbol_whose_overrides_would_place_too_many_objects_is_refused_before_its_art_is_placed() { + // Solid paths are cut at the guides and stay one path each; the instance's overrides fill them + // with a gradient, which places each in nine clip groups. + let (d, mut n) = sliced(source::MAX_WORK / 20, || Paint::solid(Color::BLACK)); + n.extras_mut().symbol_overrides = (0..source::MAX_WORK / 20) + .map(|i| vectorcraft_doc::SymbolOverride { path: vec![i as u32], fill: Some(gradient()), ..Default::default() }) + .collect(); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the instance's art is placed and listed: {work}"); + // A few such paths build. + let (d, n) = sliced(4, || Paint::solid(Color::BLACK)); + assert!(solid_art_in(Some(&d), &n).is_some()); +} + +#[test] +fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "Empty".into(), art: Arc::new(Node::group(NodeId(0), vec![])) }); + let clip = Rect::new(0.0, 0.0, 50.0, 100.0); + d.symbols.push(Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(clip_group(0, clip, rect(0, 0.0, 0.0, 100.0, 100.0, 0.0))) }); + let empty_clip = Node::new(NodeId(10), NodeKind::Group { children: vec![], clip: true }); + let empty_symbol = instance(30, "Empty", Affine::IDENTITY); + let no_keys = Node::new(NodeId(20), NodeKind::Blend { children: vec![], spec: Box::default() }); + for id in TYPES { + // Alone, an empty clipping group, an instance of an empty symbol and a blend without keys + // build nothing and say so. + for empty in [&empty_clip, &empty_symbol, &no_keys] { + let n = with(empty.clone(), id, json!({})); + let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err(); + assert!(err.contains("at least two anchors"), "{id} on {:?}: {err}", empty.kind); + assert!(art(&d, &n, id).is_none(), "{id}"); + assert!(three_d_meshes_in(Some(&d), &n).is_err(), "{id}"); + // In a group they add nothing, and the group's other members still build. + let g = with(Node::group(NodeId(90), vec![Arc::new(empty.clone()), Arc::new(rect(91, 0.0, 0.0, 50.0, 50.0, 0.0))]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding {:?}", empty.kind); + assert!(art(&d, &g, id).is_some(), "{id}"); + } + // A blend set to no steps draws one step, and builds from its keys and that step. + let n = with(blend(20, 0), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}"); + } + let drawn = Node::group(NodeId(20), crate::expand_live(Some(&d), &blend(20, 0)).into_iter().map(Arc::new).collect()); + assert_eq!(drawn.children().unwrap().len(), 3); + let (a, b) = (solid_art_in(Some(&d), &with(blend(20, 0), ROTATE, front())).unwrap(), solid_art(&with(drawn, ROTATE, front())).unwrap()); + assert_eq!(a.children().unwrap().len(), b.children().unwrap().len()); + assert!(close(bounds(&a), bounds(&b), 0.01)); + // A symbol holding a clipping group builds from the art inside the clip, where the instance + // places it. + let placed = with(instance(30, "Clipped", Affine::translate((200.0, 0.0))), ROTATE, front()); + let cut = with(Node::group(NodeId(30), vec![Arc::new(rect(31, 200.0, 0.0, 250.0, 100.0, 0.0))]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); +} + +/// The id of the art of [`masked_by`]'s opacity masks. +const MASK_ART: NodeId = NodeId(900); + +/// `n` with an opacity mask of white art covering `r`. +fn masked_by(mut n: Node, r: Rect) -> Node { + let art = Node::path(MASK_ART, shapes::rectangle(r), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + n +} + +/// The faces (paths) of `art`, and how many of them are inside an object [`masked_by`] masks with +/// a mask that is not disabled. +fn masked_faces(art: &Node) -> (usize, usize) { + fn walk(n: &Node, masked: bool, out: &mut (usize, usize)) { + let masked = masked || n.extras().mask.as_ref().is_some_and(|m| m.art.id == MASK_ART && !m.disabled); + match n.children() { + Some(children) => children.iter().for_each(|c| walk(c, masked, out)), + None => { + out.0 += 1; + out.1 += usize::from(masked); + } + } + } + let mut out = (0, 0); + walk(art, false, &mut out); + out +} + +#[test] +fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() { + let d = document(); + let mask = Rect::new(0.0, 0.0, 400.0, 400.0); + let mut members = vec![("a path", rect(1, 100.0, 100.0, 150.0, 200.0, 2.0))]; + members.extend(sources()); + for (what, member) in members { + let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0)); + let in_group = Node::group(NodeId(80), vec![Arc::new(member.clone())]); + let cases = [ + ("masked", Node::group(NodeId(90), vec![Arc::new(masked_by(member.clone(), mask)), other()])), + ("in a masked group", Node::group(NodeId(90), vec![Arc::new(masked_by(in_group, mask)), other()])), + ]; + for (how, g) in cases { + for id in TYPES { + let g = with(g.clone(), id, json!({})); + let (faces, masked) = masked_faces(&art(&d, &g, id).expect("drawn")); + assert!(masked > 0, "{id}: the faces of {what} {how} are masked"); + assert!(masked < faces, "{id}: the other member's faces are not ({masked} of {faces})"); + } + } + // A disabled mask masks nothing. + let mut off = masked_by(member.clone(), mask); + if let Some(m) = off.extras_mut().mask.as_mut() { + m.disabled = true; + } + for id in TYPES { + let g = with(Node::group(NodeId(90), vec![Arc::new(off.clone()), other()]), id, json!({})); + assert_eq!(masked_faces(&art(&d, &g, id).expect("drawn")).1, 0, "{id}: {what} with a disabled mask"); + } + } +} + +/// The leaf faces of `art` that draw something, each with its visual bounds and whether it is drawn +/// inside a group with blending mode `mode`. +fn faces_in_mode(art: &Node, mode: BlendMode) -> Vec<(Rect, bool)> { + fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut Vec<(Rect, bool)>) { + let inside = inside || (n.children().is_some() && n.blend == mode); + match n.children() { + Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)), + None => out.extend(n.visual_bounds().map(|b| (b, inside))), + } + } + let mut out = Vec::new(); + walk(art, false, mode, &mut out); + out +} + +#[test] +fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_solids() { + let d = document(); + let mode = |mut n: Node, mode: BlendMode| { + n.blend = mode; + n + }; + let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0)); + let member = || rect(1, 100.0, 100.0, 150.0, 200.0, 2.0); + let cases = [ + ("a multiplied member", Node::group(NodeId(90), vec![Arc::new(mode(member(), BlendMode::Multiply)), other()])), + ( + "a member of a multiplied group", + Node::group(NodeId(90), vec![Arc::new(mode(Node::group(NodeId(80), vec![Arc::new(member())]), BlendMode::Multiply)), other()]), + ), + ]; + for (what, g) in cases { + for id in TYPES { + let g = with(g.clone(), id, json!({})); + // The member spans y 100 to 200 and the other member y 600 to 650, and every type keeps + // its faces near those heights (Revolve turns them around a vertical axis): a face whose + // center is above y 400 belongs to the member. + let (mine, others): (Vec<_>, Vec<_>) = + faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply).into_iter().partition(|(b, _)| b.center().y < 400.0); + assert!(!mine.is_empty() && !others.is_empty(), "{id}: both members draw faces ({} and {})", mine.len(), others.len()); + assert!(mine.iter().all(|(_, m)| *m), "{id}: every face of {what} is drawn in Multiply"); + assert!(others.iter().all(|(_, m)| !m), "{id}: no face of the other member is drawn in Multiply"); + } + } +} + +#[test] +fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "Left".into(), art: Arc::new(rect(0, 0.0, 100.0, 50.0, 200.0, 0.0)) }); + let moved = |n: Node| with(n, "distort.transform", json!({"moveH": 100.0})); + let clip = Rect::new(100.0, 100.0, 150.0, 200.0); + let target = rect(1, 100.0, 100.0, 150.0, 200.0, 0.0); + let expected = bounds(&solid_art(&with(Node::group(NodeId(90), vec![Arc::new(target.clone())]), ROTATE, front())).unwrap()); + let left = || rect(2, 0.0, 100.0, 50.0, 200.0, 0.0); + let members = [ + ("a symbol instance", moved(instance(30, "Left", Affine::IDENTITY))), + ("a clipping group", moved(clip_group(40, Rect::new(0.0, 100.0, 50.0, 200.0), left()))), + ("a plain group", moved(Node::group(NodeId(50), vec![Arc::new(left())]))), + ("a group in a clipping group", clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(left())])))), + ]; + for (what, member) in members { + for id in TYPES { + let g = with(Node::group(NodeId(90), vec![Arc::new(member.clone())]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding {what}"); + } + let g = with(Node::group(NodeId(90), vec![Arc::new(member)]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &g).unwrap()), expected, 0.01), "{what} moved by its Transform effect"); + } + // A blend's effect moves its keys and steps together. + let blended = with(Node::group(NodeId(90), vec![Arc::new(moved(blend(20, 3)))]), ROTATE, front()); + let unmoved = with(Node::group(NodeId(90), vec![Arc::new(blend(20, 3))]), ROTATE, front()); + let (a, b) = (bounds(&solid_art_in(Some(&d), &blended).unwrap()), bounds(&solid_art_in(Some(&d), &unmoved).unwrap())); + assert!(close(a, Rect::new(b.x0 + 100.0, b.y0, b.x1 + 100.0, b.y1), 0.01), "{a:?} is {b:?} moved"); + // A geometry effect that reads the object's stroke reads the resolved object's, as a group's. + let stroked = |kind: NodeKind| { + let mut n = Node::new(NodeId(20), kind); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0).into(); + with(n, "path.outlineStroke", json!({})) + }; + let NodeKind::Blend { children, spec } = blend(20, 3).kind else { panic!("a blend") }; + let steps = crate::expand_live(Some(&d), &blend(20, 3)).into_iter().map(Arc::new).collect(); + let group_of = |n: Node| with(Node::group(NodeId(90), vec![Arc::new(n)]), ROTATE, front()); + let resolved = bounds(&solid_art_in(Some(&d), &group_of(stroked(NodeKind::Blend { children, spec }))).unwrap()); + let plain = bounds(&solid_art(&group_of(stroked(NodeKind::Group { children: steps, clip: false }))).unwrap()); + assert!(close(resolved, plain, 0.01), "{resolved:?} {plain:?}"); + // A member's opacity and blending mode stay on it when its group's effects reshape it in a clip. + let mut multiplied = left(); + multiplied.opacity = 0.5; + multiplied.blend = BlendMode::Multiply; + let n = clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(multiplied), Arc::new(left())]))); + let resolved = resolve(Some(&d), &n, "").unwrap().into_owned(); + let mut found = vec![]; + resolved.walk(&mut |c| { + if c.blend == BlendMode::Multiply { + found.push((c.opacity, c.children().map_or(0, Vec::len))); + } + }); + assert_eq!(found, vec![(0.5, 1)], "one Multiply group at 50% holding the one member's piece"); + // Groups nested past the limit under a group with geometry effects in a clipping group are + // refused before the effects reshape them. + let mut deep = left(); + for i in 0..200 { + deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]); + } + let n = with(clip_group(60, clip, moved(deep.clone())), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep"))); + // A hidden member nested past the limit is left out, as it is drawn, and does not refuse. + deep.visible = false; + let n = with(clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(deep), Arc::new(left())]))), ROTATE, front()); + assert_eq!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})), Ok(())); + // Hidden members of a group inside a clipping group build nothing. + let mut hidden = rect(63, 100.0, 100.0, 150.0, 200.0, 0.0); + hidden.visible = false; + let n = with(clip_group(60, clip, Node::group(NodeId(62), vec![Arc::new(hidden)])), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("at least two anchors"))); +} diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index e101c6d29..acb980204 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -1,5 +1,5 @@ //! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw` -//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. +//! wide; the line arrow's arms reach twice as far out ([`Arrowhead::half_width`]). Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. //! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, //! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by @@ -8,7 +8,7 @@ use std::borrow::Cow; -use std::f64::consts::{FRAC_PI_2, PI}; +use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer}; @@ -264,6 +264,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; /// Flattening tolerance of the curved local heads, in units. const LOCAL_TOL: f64 = 1e-3; +/// Where the stroke ends under a head drawn in local units ([`local`]), in those units. fn local_stop(kind: Arrowhead) -> Stop { match kind { Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), @@ -273,7 +274,7 @@ fn local_stop(kind: Arrowhead) -> Stop { Arrowhead::Fork => Stop::At(2.7), Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3), Arrowhead::Swallowtail => Stop::At(1.4), - Arrowhead::Chevron => Stop::At(1.0), + Arrowhead::Chevron | Arrowhead::LineArrow => Stop::At(1.0), Arrowhead::Slash => Stop::At(1.2), Arrowhead::DoubleSlash => Stop::At(1.05), Arrowhead::DoubleArrow => Stop::At(1.7), @@ -285,8 +286,9 @@ fn local_stop(kind: Arrowhead) -> Stop { } /// The outline of a head added after the first ten, in local units: x from the tip (0) back -/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction -/// the head points). Every shape is generated here. +/// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2, +/// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape +/// is generated here. fn local(kind: Arrowhead) -> BezPath { let mut p = BezPath::new(); let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>()); @@ -375,6 +377,14 @@ fn local(kind: Arrowhead) -> BezPath { poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)]) } Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)), + Arrowhead::LineArrow => { + // Arms one unit thick (the head's weight: the line's at 100 % for lines of 0.25 pt or + // more) at right angles to each other, from a sharp tip to their outer corners at + // (4, ±4), cut square across: a unit across an arm at 45° is ½√2 along each axis, and + // the arms' inner edges meet √2 behind the tip. + let t = FRAC_1_SQRT_2; + poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); + } // The first ten are drawn by `shape`. Arrowhead::Triangle | Arrowhead::TriangleOpen diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index f660a6c8f..400f3b58b 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -14,6 +14,10 @@ use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent}; /// More dashes than this (a tiny pattern on a long path) draws the line solid instead. const MAX_DASHES: f64 = 200_000.0; +/// Pattern entries walked in all (every subpath and every corner restart starts the pattern over, and +/// zero-length entries cost no path length, so a long pattern is walked in full even on a short +/// run); past this the line is drawn solid. About 100x what real art needs. +const MAX_STEPS: f64 = 20_000_000.0; /// A pattern whose longest entry is shorter than this can't advance along the path (each entry /// ends within the generator's tolerance of where it starts), so it draws the line solid. const MIN_ENTRY: f64 = 1e-6; @@ -80,6 +84,10 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt if plans.iter().map(|p| p.periods).sum::() * pat.len() as f64 > MAX_DASHES { return None; } + let restarts = |p: &Plan| p.restart.iter().filter(|r| **r).count() as f64; + if plans.iter().map(|p| (p.periods.ceil().max(1.0) + restarts(p)) * pat.len() as f64).sum::() > MAX_STEPS { + return None; + } // Where an exact pattern starts: `offset` into it. let mut off = d.offset.rem_euclid(period); let mut ix0 = 0; @@ -89,8 +97,13 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt } let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 }; let mut out = Dashed::default(); + // The estimates above trust the fitted scales; this counts the pattern entries actually walked, + // so entries that fit down to nothing can't keep a subpath looping. + let mut budget = MAX_STEPS as usize; for plan in &plans { - dash_subpath(&mut out, plan, &pat, phase, keep); + if !dash_subpath(&mut out, plan, &pat, phase, keep, &mut budget) { + return None; + } } Some(out) } @@ -183,7 +196,8 @@ impl Piece { } } -fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) { +/// Dash one subpath; `false` when `budget` (pattern entries left to walk, shared by all subpaths) runs out. +fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool, budget: &mut usize) -> bool { let first_dot = out.dots.len(); let total: f64 = plan.lens.iter().sum(); // Fraction of the subpath's length at `x` along the plan. @@ -244,6 +258,8 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) }); } } + let Some(rest) = budget.checked_sub(1) else { return false }; + *budget = rest; cuts += 1; s = end; ph.ix = (ph.ix + 1) % pat.len(); @@ -273,7 +289,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: write(&mut out.path, &d.segs); out.path.close_path(); out.spans.push((frac(0.0), frac(0.0) + 1.0)); - return; + return true; } if starts_on && ends_on && dashes.len() > 1 { // The dash running through the start point is one dash. @@ -291,6 +307,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: let t0 = frac(d.start); out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 })); } + true } /// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 39d6d9d57..432804899 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -1,6 +1,6 @@ -//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut -//! arms reach a little further back), without self-intersections, and covering the end of the -//! stroke under it. +//! Every arrowhead's outline: drawn, inside its reach and half width (4 × 4 weights for most; the +//! open arrow's square-cut arms reach a little further back, the line arrow's twice as far out), +//! without self-intersections, and covering the end of the stroke under it. use kurbo::{BezPath, PathEl, Point, Shape}; use vectorcraft_color::{Color, Paint}; @@ -53,7 +53,13 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { let h = head(kind); assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); let b = h.outline.bounding_box(); - assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}"); + let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width()); + assert!(b.x1 <= 100.0 + 1e-6 && b.y0 >= -side - 1e-6 && b.y1 <= side + 1e-6, "{kind:?} leaves its box: {b:?}"); + if kind.half_width() == 2.0 { + assert!(b.x0 >= 91.0 - 1e-6, "{kind:?} is longer than the 4 × 4 box: {b:?}"); + } + let far = contours(&h.outline).iter().flatten().map(|p| p.distance(h.tip)).fold(0.0, f64::max); + assert!(far <= reach + 1e-6, "{kind:?} reaches {far} from its tip, past {reach}"); let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect(); assert!(edges.len() >= 3, "{kind:?}"); for (i, a) in edges.iter().enumerate() { @@ -74,6 +80,31 @@ fn the_stroke_ends_inside_every_head() { } } +#[test] +fn the_line_arrow_is_a_right_angle_of_two_arms_as_thick_as_the_line() { + // Weight 2 at 100 %: a local unit (x back from the tip, y across) is 2 pt. + let h = head(Arrowhead::LineArrow); + let at = |x: f64, y: f64| Point::new(100.0 - 2.0 * x, 2.0 * y); + // A sharp tip on the end point; outer corners 4 units back and out, square-cut ends. + let pts: Vec = contours(&h.outline).concat(); + for (corner, name) in [(at(0.0, 0.0), "tip"), (at(4.0, 4.0), "right corner"), (at(4.0, -4.0), "left corner")] { + assert!(pts.iter().any(|p| p.distance(corner) < 1e-9), "{name} {corner:?} in {pts:?}"); + } + // Open between the arms, solid at the tip. + assert_eq!(h.outline.winding(at(2.5, 0.0)), 0); + assert_ne!(h.outline.winding(at(0.7, 0.0)), 0); + // Halfway along each arm, 45° off the line, it is one unit (the line's weight) across. + let s = std::f64::consts::FRAC_1_SQRT_2; + for side in [1.0, -1.0] { + let (mx, my) = (2.0 + 0.5 * s, side * (2.0 - 0.5 * s)); + let (nx, ny) = (-side * s, s); + for (d, inside) in [(0.45, true), (-0.45, true), (0.55, false), (-0.55, false)] { + let w = h.outline.winding(at(mx + nx * d, my + ny * d)); + assert_eq!(w != 0, inside, "arm {side} at {d} across"); + } + } +} + #[test] fn half_arrows_barb_on_their_own_side() { // Pointing along +x on a y-down page, left is −y. diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index d3269ddcd..b389cab85 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -223,3 +223,17 @@ fn smooth_points_of_curves_stay_smooth_with_any_join() { assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}"); } } + +#[test] +fn a_long_pattern_restarted_on_many_short_subpaths_draws_solid() { + // Each subpath walks the zero-length entries in full although it is far shorter than a period. + let mut pattern = vec![0.0; 2000]; + pattern[0] = 1.0; + let mut b = BezPath::new(); + for i in 0..20_000 { + b.move_to((i as f64 * 2.0, 0.0)); + b.line_to((i as f64 * 2.0 + 0.01, 0.0)); + } + let d = Dash { pattern, offset: 0.0, align_corners: false }; + assert!(dash(&b, &d).is_none()); +} diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 325ce9b38..3877a60ae 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -1101,8 +1101,9 @@ pub fn with_id(xml: &str, id: &str) -> Option { let f = filter_element(&doc).ok()?; let at = f.attributes().find(|a| a.name() == "id" && a.namespace().is_none())?.range_value(); let (start, end) = (at.start.checked_sub(head)?, at.end.checked_sub(head)?); - // The value may sit in single quotes: those are escaped too. - let id = escaped(id).replace('\'', "'"); + // The value may sit in single quotes: those are escaped too. A tab, line feed or carriage + // return is written as a character reference, which attribute-value normalization keeps. + let id = escaped(id).replace('\'', "'").replace('\t', " ").replace('\n', " ").replace('\r', " "); Some(format!("{}{}{}", xml.get(..start)?, id, xml.get(end..)?)) } diff --git a/crates/effects/src/trap.rs b/crates/effects/src/trap.rs index f60232bed..b2721ed0f 100644 --- a/crates/effects/src/trap.rs +++ b/crates/effects/src/trap.rs @@ -218,34 +218,66 @@ fn visible(sources: &[TrapSource]) -> Vec { /// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within /// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their /// left edge, so shapes spread over the page cost about one check each rather than one per pair. -fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> { +/// `None` when there are more than [`MAX_NEAR_PAIRS`] (many shapes all within reach of each other +/// make the pair list grow with the square of their count) or the sweep examines more than +/// `MAX_NEAR_SCAN` pairs (many shapes sharing an x range, far apart in y). +fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Option> { + near_pairs_within(rects, reach_x, reach_y, MAX_NEAR_SCAN) +} + +fn near_pairs_within(rects: &[Option], reach_x: f64, reach_y: f64, max_scan: usize) -> Option> { let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect(); order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0))); let mut pairs = vec![]; + let mut scanned = 0usize; for (k, &(i, a)) in order.iter().enumerate() { // Later rects start at or right of `a`, so only how far right they start can rule them out. for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) { + // Rects sharing an x range but far apart in y are examined without being stored. + scanned += 1; + if scanned > max_scan { + return None; + } if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y { + if pairs.len() >= MAX_NEAR_PAIRS { + return None; + } pairs.push((i.min(j), i.max(j))); } } } pairs.sort_unstable(); - pairs + Some(pairs) } +/// Most pairs the sweep in `near_pairs` examines, stored or not (a plain bounds check each). +const MAX_NEAR_SCAN: usize = 1 << 27; + +/// Most candidate pairs one trap evaluation keeps; past it no traps are made (the art draws as is). +/// Each pair costs a boolean trap band, so real art stays far below this. +pub const MAX_NEAR_PAIRS: usize = 1 << 20; + /// The traps between `sources` (back to front) with options `o`: for each two sources of /// different solid colours whose visible parts meet, the strip of the darker one's visible part /// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per -/// [`trap_paint`]. Empty when no visible edges of different colours meet. +/// [`trap_paint`]. Empty when no visible edges of different colours meet, or when there are more +/// than [`MAX_NEAR_PAIRS`] candidate pairs or the pair scan is too long (see [`try_traps`] to tell +/// those apart). pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { + try_traps(sources, o).unwrap_or_default() +} + +/// [`traps`], but `None` when there are more than [`MAX_NEAR_PAIRS`] candidate pairs or the scan for +/// them is too long (`MAX_NEAR_SCAN` pairs examined), as opposed to +/// `Some` of an empty list when no edges meet. +pub fn try_traps(sources: &[TrapSource], o: &TrapOptions) -> Option> { let colors: Vec> = sources.iter().map(|s| trap_color(&s.fill)).collect(); if colors.iter().flatten().count() < 2 { - return vec![]; + return Some(vec![]); } let (w, r) = (o.thickness, o.height_width); if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) { - return vec![]; // `trap_band` cuts nothing with these. + return Some(vec![]); // `trap_band` cuts nothing with these. } let vis = visible(sources); // Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects @@ -257,7 +289,8 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs())); let hair = 1e-9 * (1.0 + extent + w * r.max(1.0)); let mut out = vec![]; - for (i, j) in near_pairs(&rects, w + hair, w * r + hair) { + let pairs = near_pairs(&rects, w + hair, w * r + hair)?; + for (i, j) in pairs { let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue }; if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) { continue; @@ -276,7 +309,7 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { let Some(color) = colors[from] else { continue }; out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into }); } - out + Some(out) } /// A trap's appearance: one overprinting fill of `paint`, no stroke. @@ -379,10 +412,19 @@ mod tests { assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0)); } + #[test] + fn the_pair_scan_is_bounded_even_when_no_pair_is_near() { + // Eleven rects share an x range but lie far apart in y: 55 pairs are examined, none stored. + let rects: Vec> = (0..11).map(|k| Some(Rect::new(0.0, k as f64 * 1000.0, 10.0, k as f64 * 1000.0 + 10.0))).collect(); + assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 100), Some(vec![])); + assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 54), None); + } + #[test] fn no_trap_without_adjoining_edges_of_different_colours() { let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())]; assert!(traps(&apart, &TrapOptions::default()).is_empty()); + assert_eq!(try_traps(&apart, &TrapOptions::default()).map(|t| t.len()), Some(0), "no edges meet: empty, not over the cap"); let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())]; assert!(traps(&same, &TrapOptions::default()).is_empty()); let mut unfilled = abutting(); @@ -431,10 +473,13 @@ mod tests { fn only_shapes_within_reach_are_paired() { let s = many_apart_and_one_abutting(); let rects: Vec> = s.iter().map(|t| t.path.bounds()).collect(); - assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired"); + assert_eq!(near_pairs(&rects, 1.0, 1.0), Some(vec![(0, 2000)]), "the squares 10 pt apart are never paired"); // Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do. let row: Vec> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect(); - assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]); + assert_eq!(near_pairs(&row, 10.0, 10.0), Some(vec![(0, 3), (1, 2), (1, 3)])); + // Past the allowance the pair list stops growing instead of being built. + let packed = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0)); 1500]; + assert_eq!(near_pairs(&packed, 1.0, 1.0), None); let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() }); assert_eq!(t.len(), 1, "only the abutting pair traps"); assert_eq!((t[0].from, t[0].into), (2000, 0)); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 7c1c9eea4..72e33b899 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -70,6 +70,7 @@ pub mod pdfcmds; pub mod perspgrid; pub mod place; pub mod plugin; +pub(crate) mod plugin_call; pub mod prefscmds; pub mod print; pub(crate) mod printadvanced; @@ -518,17 +519,23 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str } /// The objects a command acts on: `ids` when given (every one checked, and those inside another -/// given one dropped), else the selected objects. +/// given one dropped), else (`ids` left out or null) the selected objects. pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result> { - if p.get("ids").is_some() { Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) } else { edit::selected_roots(s) } + if p.get("ids").is_some_and(|v| !v.is_null()) { + Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) + } else { + edit::selected_roots(s) + } } pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; - if s.doc()?.doc.node(id).is_none() { - return Err(bad(command, format!("no such object id {value}"))); + match s.doc()?.doc.node(id) { + None => Err(bad(command, format!("no such object id {value}"))), + // The guide commands take ruler guides by index. + Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), + Some(_) => Ok(id), } - Ok(id) } /// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an @@ -688,10 +695,11 @@ pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { let compound = |id: NodeId| d.parent_of(id).filter(|p| d.node(*p).is_some_and(owns)); let mut out = vec![]; for id in ids { - let Some(n) = d.node(*id) else { continue }; + // A ruler guide has no appearance to paint, given or in a layer. + let Some(n) = d.node(*id).filter(|n| !n.is_ruler_guide()) else { continue }; match &n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } if !n.is_compound_shape() => n.walk(&mut |c| { - if !c.is_container() || owns(c) { + if (!c.is_container() && !c.is_ruler_guide()) || owns(c) { out.push(c.id) } }), @@ -741,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 use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index dfb450fae..4fd1f1ce9 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -185,7 +185,7 @@ fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> { } fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) { - if !n.visible || n.locked || out.len() >= MAX_OBJECTS { + if !n.visible || n.locked || n.is_ruler_guide() || out.len() >= MAX_OBJECTS { return; } let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false }; @@ -216,7 +216,7 @@ fn is_generated(n: &Node) -> bool { } fn is_guide_or_clip(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) + n.is_guide() || matches!(n.kind, NodeKind::Path { clipping: true, .. }) } fn is_shape(n: &Node) -> bool { @@ -484,7 +484,7 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result std::result::Result { for (k, w) in off.windows(2).enumerate() { let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue }; if delta.abs() > 1e-9 { - edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0); + // Thousandths of the em as the Horizontal Scale draws it (`spacing_scale`). + let vertical = m.vertical; + edit::style_range(&mut m.runs, s, e, |st| { + st.tracking += delta / (st.size * vectorcraft_text::spacing_scale(st, vertical)).max(1e-6) * 1000.0; + }); if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) { return Err(k + 1); } @@ -802,7 +806,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { }; // The characters a Type tool has selected (after Select All in threaded text, all the text of // every frame of the story), else all the text of the selected type. - let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let editing = if p.get("ids").is_some_and(|v| !v.is_null()) { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; let story = if editing.is_some() { editing_story(s) } else { vec![] }; let d = &s.doc()?.doc; // Each type object with the breaks that go from it. diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index c75446b10..0af24be36 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -125,6 +125,30 @@ fn break_area_type_into_lines_paragraphs_and_glyphs() { } } +/// Tracked area type broken into lines or paragraphs stays where it was, however it is aligned: +/// a wrap after a hyphen or at a space becomes a line break or a paragraph's end, and the line +/// leaves the tracking after its last character out of its width there as it did at the wrap. +/// (Words chosen without a kerning pair across the wrap, which the pieces don't keep.) +#[test] +fn break_tracked_area_type_keeps_its_aligned_lines_in_place() { + for justify in ["center", "right"] { + for text_of in ["Mode-Mode-Mode", "Wave Wave Wave"] { + for by in ["paragraphs", "lines"] { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 40, "text": text_of, "size": 20, "area": {"width": 90, "height": 200}})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 200})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": justify})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert!(lay.lines.len() > 1, "{text_of}: it wraps"); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap()); + assert_same_places(&was, &glyphs(&s, &pieces)); + } + } + } +} + #[test] fn break_text_refuses_what_it_cannot_break() { let mut s = session(); @@ -176,6 +200,24 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() { assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); } +/// Pieces in horizontally scaled type: the tracking that puts each back where it was is measured in +/// the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), as layout spaces it. +#[test] +fn combine_puts_horizontally_scaled_pieces_back_where_they_were() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 160, "y": 100, "text": "Right", "size": 20})); + for id in [a, b] { + s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 200})).unwrap(); + } + let was = glyphs(&s, &[a, b]); + select(&mut s, &[a, b]); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert_same_places(&was, &glyphs(&s, &[a])); +} + /// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs, /// pieces on a line left to right, each line's baseline where it was (its leading is the distance /// from the line above), in one undo step. A piece turned unlike the top-left one stays. @@ -246,6 +288,18 @@ fn remove_breaks_in_the_characters_a_type_tool_selected() { assert_eq!(obj(&s, id).plain_text(), "a b\nc"); } +/// `ids: null` is `ids` left out: the characters the Type tool selected lose their breaks, and +/// the rest of the text keeps them. +#[test] +fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": null})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs index 7a4cfee9c..a0dd332fe 100644 --- a/crates/engine/src/cmd/addons/color/print_fixes.rs +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -107,7 +107,7 @@ impl Fixes { let white = usize::from(self.remove_white_overprint && self.unset_white(n)); let blacks = self.overprint_black > 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_))); let mut counts = (white, usize::from(blacks && black.apply(n, spots))); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { let (w, b) = self.overprint(Arc::make_mut(c), black, spots); counts = (counts.0 + w, counts.1 + b); } diff --git a/crates/engine/src/cmd/addons/contour.rs b/crates/engine/src/cmd/addons/contour.rs index 46df381ed..34b944342 100644 --- a/crates/engine/src/cmd/addons/contour.rs +++ b/crates/engine/src/cmd/addons/contour.rs @@ -12,8 +12,8 @@ use vectorcraft_doc::NodeKind; use vectorcraft_render::effects; use super::super::edit::{copy_above, selected_roots}; -use super::super::pathops::{len_param, num_param}; -use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param}; +use super::super::pathops::{length_given, number_given}; +use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, text_param}; const C: &str = "addon.contour.set"; /// The live effect that does the offsetting (the one Effect › Path › Offset Path applies). @@ -44,12 +44,12 @@ fn joins_for(corners: Option<&str>) -> Result<&'static str> { } fn contour(s: &mut Session, p: &Value) -> Result { - let offset = len_param(p, "offset").ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?; + let offset = length_given(p, "offset", C)?.ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?; if offset.abs() > MAX_OFFSET { return Err(bad(C, "offset out of range")); } - let joins = joins_for(str_param(p, "corners"))?; - let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0); + let joins = joins_for(text_param(p, "corners", C)?)?; + let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0); let effect = effects::new_effect(EFFECT, &json!({"offset": offset, "joins": joins, "miterLimit": miter})) .ok_or_else(|| EngineError::Other("Contour: the Offset Path effect is not available".into()))?; let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/addons/contour/tests.rs b/crates/engine/src/cmd/addons/contour/tests.rs index 900d110d5..3983ed942 100644 --- a/crates/engine/src/cmd/addons/contour/tests.rs +++ b/crates/engine/src/cmd/addons/contour/tests.rs @@ -158,6 +158,39 @@ fn contour_refuses_what_it_cannot_offset() { assert!(err.contains("select paths or shapes"), "{err}"); } +/// A param of the wrong type is an error naming the param, and leaves the document, the selection +/// and the undo history as they were. Null keeps a param's default, and `offset` still takes a +/// string with a unit. +#[test] +fn contour_refuses_a_param_of_the_wrong_type() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.selection.objects.clone(), st.history.undo.len()) + }; + let before = state(&s); + for (p, want) in [ + (json!({"offset": true}), "`offset` must be a number, not true"), + (json!({"offset": "abc"}), "`offset` must be a number, not \"abc\""), + (json!({"offset": [5]}), "`offset` must be a number, not [5]"), + (json!({"offset": 5, "corners": 3}), "`corners` must be a string"), + (json!({"offset": 5, "miterLimit": "high"}), "`miterLimit` must be a number, not \"high\""), + (json!({"offset": 5, "miterLimit": false}), "`miterLimit` must be a number, not false"), + ] { + let r = s.execute("addon.contour.set", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{p}: {r:?}"); + assert!(state(&s) == before, "{p} changed the document, the selection or the undo history"); + } + // A null `offset` is a missing one. + let err = s.execute("addon.contour.set", &json!({"offset": null})).unwrap_err().to_string(); + assert!(err.contains("missing `offset`"), "{err}"); + // Null corners and miter limit keep sharp corners at 4; the offset takes a unit. + let r = s.execute("addon.contour.set", &json!({"offset": "1 in", "corners": null, "miterLimit": null})).unwrap(); + let fx = effects_of(&s, ids_of(&r, "created")[0]); + assert_eq!((fx[0].1["offset"].clone(), fx[0].1["joins"].clone(), fx[0].1["miterLimit"].clone()), (json!(72.0), json!("miter"), json!(4.0))); +} + /// The border-with-a-gap recipe: two Contours of one shape (the gap, then the border), baked, then /// the gap taken out of the border with Minus Front, leaves a ring between the two. #[test] diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index fafdfc07d..654138a1d 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -164,11 +164,11 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result { } /// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds -/// no layers). +/// no layers, and no ruler guides: a layer's are left out). fn without_layers(n: &Node) -> Node { let mut n = n.clone(); if let NodeKind::Layer { children, clip, .. } = &n.kind { - n.kind = NodeKind::Group { children: children.clone(), clip: *clip }; + n.kind = NodeKind::Group { children: children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(), clip: *clip }; } if let Some(children) = n.children_mut() { for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) { @@ -203,8 +203,10 @@ fn symbols_from_layers(s: &mut Session, p: &Value) -> Result { let (mut made, mut skipped) = (vec![], vec![]); for layer in layers.iter().filter_map(|id| d.layers.iter().find(|l| l.id == *id)) { let name = layer.display_name(); - let roots: Vec = layer.children().map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default(); - let art = match layer.children().map(Vec::as_slice).unwrap_or_default() { + // The layer's ruler guides stay where they are. + let children: Vec> = layer.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).cloned().collect(); + let roots: Vec = children.iter().map(|n| n.id).collect(); + let art = match children.as_slice() { [] => None, [one] => Some(without_layers(one)), many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))), diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 5d0e5723f..9ddcc4355 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result { continue; } let only = (segs.len() < sp.segment_count()).then_some(&segs); - let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)) - .ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?; + let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)).ok_or_else(|| { + bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer") + })?; made += out.len(); for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) { dashes += usize::from(kind == pt::Piece::Dash); @@ -825,7 +826,7 @@ fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec { fn expand_dashes(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.expandDashes"; - let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer"); + let too_many = || bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer"); let roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; // Each dashed path and its dashes, as the canvas draws them. diff --git a/crates/engine/src/cmd/addons/pathtools/shape.rs b/crates/engine/src/cmd/addons/pathtools/shape.rs index 4c1ba7e2d..fc8844299 100644 --- a/crates/engine/src/cmd/addons/pathtools/shape.rs +++ b/crates/engine/src/cmd/addons/pathtools/shape.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Edit Parametric Shape", [], None, - "{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}", + "{id, kind?, center?, width?, height?, rotation?: degrees clockwise (0: upright), count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}", has_doc, set ), diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index c40173390..24d0e4c3a 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Rectangle", [], None, - "{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rectangle ), @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Ellipse", [], None, - "{x, y, width, height, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, ellipse ), @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Polygon", [], None, - "{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}", has_doc, polygon ), @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Star", [], None, - "{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}", has_doc, star ), @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "3D Box", [], None, - "{x, y, width, height, angleX=30, angleY=30, angleZ=0, parallelX=false, parallelY=true, parallelZ=false} → {id}", + "{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}", has_doc, box3d ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Configure Shape", [], None, - "{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?, angleZ?, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}", + "{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}", has_selection, configure ), diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 45b23a0cb..2dfeb497d 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -939,7 +939,10 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; - s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); + // The rows removed from the active row's object, which need not come first in `ids`. + let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); + let gone = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx); + s.remap_appearance_item(&ids, |a| (!gone.contains(&a)).then(|| a - gone.iter().filter(|i| **i < a).count())); ok() } @@ -1023,10 +1026,12 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { // Rows at or above each copy move up by one (by the block, for copies placed at `to`). let m = idx.len(); let landed = active_at.or(first_at); + // The rows copied in the active row's object, which need not come first in `ids`. + let copied = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx); s.remap_appearance_item(&ids, |a| { Some(match to { Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 }, - None => a + idx.iter().filter(|i| **i < a).count(), + None => a + copied.iter().filter(|i| **i < a).count(), }) }); ok() @@ -1184,7 +1189,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { .iter() .filter_map(|id| d.node(*id)) .filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) - .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)) + .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked && !c.is_ruler_guide()).map(|c| c.id)) .collect(); if ids.is_empty() { return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members")); diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 31639519f..1ebde5c02 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -116,13 +116,15 @@ pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut V let Some(n) = doc.node(id) else { return }; if n.is_layer() { if let Some(kids) = n.children() { - for c in kids { + for c in kids.iter().filter(|c| !c.is_ruler_guide()) { art_under(doc, c.id, out); } } return; } - out.push(id); + if !n.is_ruler_guide() { + out.push(id); + } } fn add(s: &mut Session, p: &Value) -> Result { @@ -213,7 +215,7 @@ fn add_3d(s: &mut Session, p: &Value) -> Result { ids.dedup(); for id in &ids { let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?; - if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) { + if let Err(e) = vectorcraft_render::effects::three_d_meshes_in(Some(d), n) { let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0)); return Err(bad(C, format!("{what} can't be exported in 3D: {e}"))); } @@ -281,7 +283,8 @@ fn export_3d(s: &mut Session, p: &Value) -> Result { a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::>>()?; // The asset's objects share one budget of triangles. let what = &a.name; - let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; + let parts = + vectorcraft_render::effects::three_d_meshes_of_in(Some(d), nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); let mut stem = base.clone(); let mut k = 1; diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 8f36e16f6..8de9dd5c8 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -165,13 +165,14 @@ pub struct BuiltinBrushLibrary { make: fn() -> BrushLibrary, } -/// The built-in brush libraries, in menu order: original brushes generated in code, one library -/// per kind of brush. +/// The built-in brush libraries, in menu order: original brushes generated in code. pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, + BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, + BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, ]; /// Built-in library `id`, made once. @@ -378,6 +379,73 @@ fn scatter_shapes() -> BrushLibrary { built("Scatter Shapes", doc, brushes) } +/// Game Trails: art brushes for motion lines and impact streaks (original game HUD / VFX art). +fn game_trails() -> BrushLibrary { + let speed = { + let n = 32; + let half = |x: f64| 2.2 * (1.0 - x / 100.0).powf(1.2).max(0.08); + let xs = |i: usize| 100.0 * i as f64 / n as f64; + let mut pts: Vec = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect(); + pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x)))); + PathData::single(SubPath::polyline(&pts, true)) + }; + let dash = group(vec![ + black(shapes::rounded_rectangle(Rect::new(0.0, -1.0, 14.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(22.0, -1.0, 36.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(44.0, -1.0, 58.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(66.0, -1.0, 80.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(88.0, -1.0, 100.0, 1.0), 1.0)), + ]); + let burst = { + // A wedge that flares from a tip at x=0 to a wide end at x=100. + let tip = Point::new(0.0, 0.0); + let top = Point::new(100.0, -6.0); + let bot = Point::new(100.0, 6.0); + PathData::single(SubPath::polyline(&[tip, top, bot], true)) + }; + let zigzag = band(100.0, 1.8, 40, |x| { + let t = (x / 12.5).floor(); + let local = (x / 12.5) - t; + if (t as i32) % 2 == 0 { -3.0 + 6.0 * local } else { 3.0 - 6.0 * local } + }); + let brushes = vec![ + art("Speed Line", black(speed), Colorization::Tints), + art("Dash Trail", dash, Colorization::Tints), + art("Burst Ray", black(burst), Colorization::Tints), + art("Zigzag", black(zigzag), Colorization::Tints), + ]; + built("Game Trails", Document::new(100.0, 100.0), brushes) +} + +/// Energy Beams: art brushes for laser, plasma and charge strokes (original game VFX art). +fn energy_beams() -> BrushLibrary { + let laser = { + let n = 32; + let half = |x: f64| 1.6 * (1.0 - (x / 100.0 - 0.5).abs() * 1.6).max(0.12); + let xs = |i: usize| 100.0 * i as f64 / n as f64; + let mut pts: Vec = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect(); + pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x)))); + PathData::single(SubPath::polyline(&pts, true)) + }; + let plasma = band(100.0, 2.2, 48, |x| 4.0 * (x / 100.0 * std::f64::consts::PI).sin()); + let charge = band(100.0, 1.6, 56, |x| 3.5 * (x / 100.0 * std::f64::consts::TAU).sin()); + let spark = group(vec![ + black(shapes::rounded_rectangle(Rect::new(0.0, -1.2, 10.0, 1.2), 0.8)), + black(shapes::rounded_rectangle(Rect::new(18.0, -0.9, 28.0, 0.9), 0.7)), + black(shapes::rounded_rectangle(Rect::new(36.0, -1.4, 48.0, 1.4), 0.9)), + black(shapes::rounded_rectangle(Rect::new(56.0, -0.8, 66.0, 0.8), 0.6)), + black(shapes::rounded_rectangle(Rect::new(74.0, -1.1, 86.0, 1.1), 0.8)), + black(shapes::rounded_rectangle(Rect::new(92.0, -0.7, 100.0, 0.7), 0.5)), + ]); + let brushes = vec![ + art("Laser Bolt", black(laser), Colorization::Tints), + art("Plasma Arc", black(plasma), Colorization::Tints), + art("Charge Wave", black(charge), Colorization::Tints), + art("Spark Trail", spark, Colorization::Tints), + ]; + built("Energy Beams", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { @@ -458,7 +526,7 @@ fn add(s: &mut Session, p: &Value) -> Result { // nothing to apply is no edit, and a click that only applies brushes the document has is // named for that. let mut probe = (*st.doc).clone(); - let imported = clip.import_into(&mut probe, &choices, false); + let imported = clip.import_into(&mut probe, &choices, false)?; let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] }; let paint = stroke_paint(s); diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 13b6ab881..1677d27f9 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), @@ -208,7 +208,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}", has_selection, symbol_break_link ), diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 32edc44f8..888e45008 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -137,7 +137,7 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { /// stroke has a Convert to Shape or Outline Object effect. fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { Some(match &n.kind { - NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => bad(cmd, format!("{key} cannot be set on a guide")), NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, NodeKind::Image(_) => { let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 60353921e..4c5cd89ed 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -390,10 +390,11 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { most.saturating_add(1).saturating_add(mask) } -/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the -/// art's ids. Fails before any edit when the instances together would make more than +/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). A hidden or +/// locked instance's art stays hidden or locked. Returns each replaced instance's id with its art's +/// id. Fails before any edit when the instances together would make more than /// [`MAX_EXPAND_NODES`] objects. -fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { +fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own))); if total > MAX_EXPAND_NODES as u64 { return Err(EngineError::Other("Too many pieces to make at once".into())); @@ -401,12 +402,14 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result Result { if ids.is_empty() { return Err(EngineError::Other("no symbol instances selected".into())); } + let iso = s.doc()?.isolation; let out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids, true)?; - sel.set(out.iter().copied()); - Ok(out) + let fresh = d.new_art.get(); + let made = break_links(d, &ids, true)?; + // Art that is hidden or locked, or in a hidden or locked group or layer, is left out of the + // selection, as is art on a locked artboard unless the instance it replaces was new art. + let locks = d.locked_artboards(); + sel.set(made.iter().filter_map(|(inst, art)| { + let keep = if fresh.contains(inst) { + crate::cmd::select::is_selectable_on_any_artboard(d, iso, *art) + } else { + crate::cmd::select::is_selectable(d, iso, &locks, *art) + }; + keep.then_some(*art) + })); + Ok(made.into_iter().map(|(_, art)| art).collect::>()) })?; Ok(json!({ "ids": ids_json(&out) })) } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 92d01bb7e..9e52454de 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -210,6 +210,24 @@ pub(crate) fn extract_direct_selection(node: &Node, selection: &Selection) -> Op if pieces.is_empty() { None } else { Some(pieces) } } +/// Does Copy take `node` unchanged under direct selection, as [`extract_direct_selection`] +/// returning `None` means: no anchors or segments of it are picked, it is not a path, or the +/// picked ones cover every segment? +pub(crate) fn copied_whole(node: &Node, selection: &Selection) -> bool { + let seg_refs = selection.segments.get(&node.id); + let anchor_refs = selection.anchors.get(&node.id); + if seg_refs.is_none() && anchor_refs.is_none() { + return true; + } + let Some(path) = node.path_data() else { return true }; + let (mut total_segs, mut total_selected) = (0, 0); + for (si, sp) in path.subpaths.iter().enumerate() { + total_segs += sp.segment_count(); + total_selected += subpath_selected_segments(sp, si, seg_refs, anchor_refs).len(); + } + total_selected == 0 || total_selected == total_segs +} + impl Clipboard { pub fn is_empty(&self) -> bool { self.nodes.is_empty() && self.artboards.is_empty() && self.guides.is_empty() @@ -234,15 +252,47 @@ impl Clipboard { let d = &*st.doc; let mut nodes = vec![]; let mut source_layers = vec![]; + // Each compound's bounds, measured once however many of its members are copied. + let mut compound_bounds: std::collections::HashMap> = std::collections::HashMap::new(); for id in roots { let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue }; let layer_name = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()); let compound_parent = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|p| matches!(p.kind, NodeKind::Compound { .. }))); + // Gradients fitted to the compound's bounds are pinned there, as a split pins them. + let gradient_box = compound_parent + .filter(|p| p.appearance.has_unplaced_gradient()) + .and_then(|p| *compound_bounds.entry(p.id).or_insert_with(|| p.geometric_bounds())); + // A compound's member or piece copied on its own takes the compound's look (its paint, + // transparency, opacity mask, pixel alignment and fill rule), so it pastes as it looked. + // It is linked to the compound's graphic style while it still has that style's look + // (a gradient pinned to the compound's bounds does not), else to none. let inherit_appearance = |mut piece: Node| { if let Some(parent) = compound_parent - && piece.appearance.items.is_empty() + && let (NodeKind::Compound { rule: compound_rule, .. }, NodeKind::Path { rule, .. }) = (&parent.kind, &mut piece.kind) { + *rule = *compound_rule; piece.appearance = parent.appearance.clone(); + if let Some(bounds) = gradient_box { + piece.appearance.pin_gradients(bounds); + } + piece.opacity = parent.opacity; + piece.blend = parent.blend; + piece.isolate = parent.isolate; + piece.knockout = parent.knockout; + piece.knockout_shape = parent.knockout_shape; + let (mask, pixel_aligned) = (parent.extras().mask.clone(), parent.extras().pixel_aligned); + { + let mut extras = piece.extras_mut(); + extras.mask = mask; + extras.pixel_aligned = pixel_aligned; + } + let link = parent + .extras() + .graphic_style + .and_then(|g| d.graphic_style_by_id(g)) + .filter(|g| super::style::in_sync(&piece, g)) + .map(|g| g.id); + piece.extras_mut().graphic_style = link; } piece }; @@ -272,7 +322,18 @@ impl Clipboard { /// Every object of `src` (each top-level layer's contents) with its resources, for Place and /// pasting outside SVG. Their layers don't count for Paste Remembers Layers. pub fn from_document(src: &Document) -> Self { - let nodes = src.layers.iter().flat_map(|l| l.children().into_iter().flatten()).map(|n| (**n).clone()).collect(); + // Ruler guides are not objects: the ones of `src`'s layers and sublayers stay behind. + let nodes = src + .layers + .iter() + .flat_map(|l| l.children().into_iter().flatten()) + .filter(|n| !n.is_ruler_guide()) + .map(|n| { + let mut n = (**n).clone(); + n.strip_ruler_guides(); + n + }) + .collect(); Self::gather(src, nodes) } diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 4488b4746..b1377d2fe 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -21,14 +21,14 @@ use serde_json::{Map, Value, json}; use vectorcraft_brush::Brush; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, - TextStyleDef, + Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, + SymbolRegistration, TextStyleDef, }; use super::super::brushsym::{set_natural_size, stored_natural_size}; use super::super::patterncmds::add_pattern; -use super::super::{Result, bad, unique_name}; +use super::super::{EngineError, Result, bad, unique_name}; use super::Clipboard; /// A resource kind objects refer to (graphic styles by id, the others by name). @@ -140,6 +140,48 @@ fn node_refs(n: &Node, f: Visit) { }); } +/// The `(name, xml)` of `e` when it is an SVG filter effect. +fn filter_of(e: &Effect) -> Option<(String, String)> { + let text = |k: &str| e.params.get(k).and_then(Value::as_str).map(str::to_string); + if e.id != Appearance::SVG_FILTER { + return None; + } + Some((text("name")?, text("xml")?)) +} + +/// The SVG filter effects of appearance `a` (its own and its fills' and strokes'). +fn appearance_filters(a: &Appearance, out: &mut BTreeSet<(String, String)>) { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + out.extend(a.effects.iter().chain(items).filter_map(filter_of)); +} + +/// The SVG filter effects in the subtree of `n` (opacity-mask art included). +fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) { + n.walk(&mut |m| { + appearance_filters(&m.appearance, out); + if let Some(mask) = &m.extras().mask { + node_filters(&mask.art, out); + } + }); +} + +/// The names the SVG filter effects of appearance `a` give (its own and its fills' and strokes'). +fn appearance_filter_names(a: &Appearance, out: &mut BTreeSet) { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + let named = a.effects.iter().chain(items).filter(|e| e.id == Appearance::SVG_FILTER); + out.extend(named.filter_map(|e| e.params.get("name").and_then(Value::as_str)).map(str::to_string)); +} + +/// The names the SVG filter effects in the subtree of `n` give (opacity-mask art included). +fn node_filter_names(n: &Node, out: &mut BTreeSet) { + n.walk(&mut |m| { + appearance_filter_names(&m.appearance, out); + if let Some(mask) = &m.extras().mask { + node_filter_names(&mask.art, out); + } + }); +} + /// A global or spot colour of its own (not a tint of another swatch): what colours link to. fn is_base(w: &Swatch) -> bool { (w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. }) @@ -527,6 +569,9 @@ impl SwatchChoices { #[derive(Clone, Debug, Default)] struct Relink { renames: BTreeMap>, + /// An SVG filter effect's `(name, xml)` on the clipboard → its `(name, xml)` in the document + /// ([`Clipboard::filter_links`]). + filters: BTreeMap<(String, String), (String, String)>, /// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped. styles: BTreeMap, /// Swatch (its name in the document) → the colour linked colours show at 100 %. @@ -566,6 +611,15 @@ impl Relink { }); } fn appearance(&self, a: &mut Appearance) { + if !self.filters.is_empty() { + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items) { + if let Some((name, xml)) = filter_of(e).and_then(|k| self.filters.get(&k)) { + e.params["name"] = json!(name); + e.params["xml"] = json!(xml); + } + } + } for it in &mut a.items { self.paint(it.paint_mut()); if let AppearanceItem::Stroke(l) = it @@ -1001,8 +1055,9 @@ impl Clipboard { } /// Add the resources the objects use to `d` (inside the paste's undo step) and work out how - /// pasted copies relink to them. - pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported { + /// pasted copies relink to them. An error, with `d` unchanged, when a pasted SVG filter finds no + /// free name ([`Self::filter_links`]). + pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Result { self.import_used(d, &self.used(), choices, same_doc, false) } @@ -1011,8 +1066,9 @@ impl Clipboard { /// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses /// ([`use_order`]), so that it is compared and relinked with their names here, and each loop /// of definitions that use each other is matched on its own ([`loop_groups`]). - fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { + fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Result { let mut out = Imported::default(); + out.relink.filters = self.filter_links(d, same_doc)?; self.import_images(d, used, same_doc, &mut out); self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art @@ -1170,8 +1226,89 @@ impl Clipboard { } vectorcraft_brush::store(d, &lib); } - out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty()); - out + out.relinks = !(out.relink.renames.is_empty() + && out.relink.filters.is_empty() + && out.relink.styles.is_empty() + && out.relink.colors.is_empty() + && used.styles.is_empty()); + Ok(out) + } + + /// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's + /// filter of its name; one that draws another definition than that filter takes the document's + /// filter when pasted back into the document it came from, and elsewhere keeps its own under a + /// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would + /// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else + /// a free letter, digit or other printable ASCII character; the filter's id is always its name. An error when no free name fits. + fn filter_links(&self, d: &Document, same_doc: bool) -> Result> { + let mut found = BTreeSet::new(); + for n in &self.nodes { + node_filters(n, &mut found); + } + for s in &self.symbols { + node_filters(&s.art, &mut found); + } + for a in self.patterns.iter().flat_map(|p| p.art.iter()) { + node_filters(a, &mut found); + } + for a in self.brushes.iter().flat_map(Brush::art) { + node_filters(a, &mut found); + } + for g in &self.graphic_styles { + appearance_filters(&g.appearance, &mut found); + } + let mut taken: BTreeSet = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect(); + let mut effects_read = false; + let mut links = BTreeMap::new(); + for (name, xml) in found { + let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue }; + let to = if same_doc { + (name.clone(), held.xml.clone()) + } else { + // The names the document's effects give are taken too, those whose filter was + // deleted included: defining a filter of that name later changes every effect that + // names it. They are read at the first rename only. + if !effects_read { + effects_read = true; + for root in d.art_roots() { + node_filter_names(root, &mut taken); + } + for a in vectorcraft_brush::library(d).iter().flat_map(Brush::art) { + node_filter_names(a, &mut taken); + } + for g in &d.graphic_styles { + appearance_filter_names(&g.appearance, &mut taken); + } + } + // Free names made from the starts of the name, longest first, then free letters and + // digits, then other single printable ASCII characters: the first under which the + // filter still draws. + let mut ends: Vec = name.char_indices().map(|(i, _)| i).skip(1).collect(); + ends.push(name.len()); + let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty()); + let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n))); + let single = ('a'..='z') + .chain('0'..='9') + .chain('A'..='Z') + .chain(('!'..='~').filter(|c| !c.is_ascii_alphanumeric())) + .map(String::from) + .filter(|n| !taken.contains(n)); + let renamed = shorter.chain(single).find_map(|new| { + let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) + .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; + Some((new, drawn)) + }); + let Some((new, drawn)) = renamed else { + return Err(EngineError::Other(format!( + "the pasted SVG filter {name:?} draws differently from the document's filter of that name, and no free name fits it" + ))); + }; + taken.insert(new.clone()); + (new, drawn) + }; + links.insert((name, xml), to); + } + Ok(links) } /// Image blobs: a different blob under a key the document uses gets a new key. @@ -1286,13 +1423,13 @@ pub(crate) struct LoadedStyle { /// a pattern added under a free name and a pattern follows a style renamed on the way in. A style /// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is /// used; any other is added under a free name. → each style of `src` placed, in order, character -/// styles first. -pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec { +/// styles first. An error, as [`Clipboard::import_into`] gives. +pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Result> { let library = OnceCell::new(); let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras); let clip = Clipboard::with_used(src, &used, &library, vec![]); let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::>()); - let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true); + let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true)?; let mut placed: Vec = vec![]; for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] { for s in list { @@ -1302,7 +1439,7 @@ pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, placed.push(LoadedStyle { para, name, added }); } } - placed + Ok(placed) } /// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index ac082d7a0..65f105689 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -462,7 +462,7 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - pub(crate) fn left_out(d: &Document, iso: Option, roots: &[NodeId]) -> Vec<(NodeId, bool)> { fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) { for c in n.children().into_iter().flatten() { - if c.is_template() { + if c.is_template() || c.is_ruler_guide() { continue; } let (hidden, locked) = (hidden || !c.visible, locked || c.locked); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index d9b5784b6..da7dc42ec 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Color Settings…", ["Edit"], Some("Cmd+Shift+K"), - "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}", + "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", always, color_settings ), @@ -192,6 +192,8 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { st.bpc = switch_or(p, "bpc", st.bpc, C)?; if st != before { cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; + // Saved for the next start when the app attached a settings file (#6335). + s.store_color_settings(&st); touch_all(s); } Ok(settings_json(&st)) @@ -580,7 +582,7 @@ fn map_more_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proo } } if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { + for c in ch.iter_mut().filter(|c| !c.is_ruler_guide()) { map_more_colors(Arc::make_mut(c), f); } } diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index f51f066df..7997ca8f6 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// Threaded type read from a file flows through its frames in the file's own composite fonts, when +/// no open document defines them. +#[test] +fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4); + let a = s + .execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let first = text(&s, a).plain_text(); + assert!(!first.is_empty() && first.len() < story.len()); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + // A new front document without the font: the font database no longer holds it. + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none()); + let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + assert_eq!(text(&s, a).plain_text(), first); +} + /// A document replaced while in the back (File › Revert) is measured in its own composite fonts /// when it comes to the front, not in the front document's. #[test] diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 407b20a1a..973c82af5 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -17,19 +17,43 @@ pub fn specs() -> Vec { "Rectangle", [], None, - "{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}", + "{x, y, width, height, radius?: pt, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rectangle ), - cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse), - cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon), - cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star), + cmd!( + "shape.ellipse", + "Ellipse", + [], + None, + "{x, y, width, height, rotation?: deg clockwise about the centre (0: upright)} → {id}", + has_doc, + ellipse + ), + cmd!( + "shape.polygon", + "Polygon", + [], + None, + "{cx, cy, radius, sides=6, rotation?: deg clockwise (0: a corner straight up)} → {id}", + has_doc, + polygon + ), + cmd!( + "shape.star", + "Star", + [], + None, + "{cx, cy, radius1, radius2, points=5, rotation?: deg clockwise (0: a point straight up)} → {id}", + has_doc, + star + ), cmd!( "shape.flare", "Flare", [], None, - "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}", + "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}", has_doc, flare ), @@ -49,7 +73,7 @@ pub fn specs() -> Vec { "Rectangular Grid", [], None, - "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", + "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rect_grid ), @@ -58,7 +82,7 @@ pub fn specs() -> Vec { "Polar Grid", [], None, - "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", + "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, polar_grid ), @@ -85,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg (about x, y), 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, 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", has_doc, text_create ), diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index f39aba2b1..e1b88d5d9 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -3,7 +3,9 @@ use super::{CommandSpec, EngineError, Result, Session, cmd, has_doc}; use serde_json::{Value, json}; use std::sync::Arc; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition}; +use vectorcraft_doc::dimension::{ + ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, Orientation, TextAlignment, TextPosition, +}; use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, ColorMode, Dash, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2}; @@ -14,7 +16,7 @@ pub fn specs() -> Vec { "Create Dimension", [], None, - "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits", + "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}, freeA?, freeB?: a point that is not an anchor, {kind: onSegment, edge: {node,subpath,segment}, t: 0..1 (curve parameter)}|{kind: anchors, node, anchors: [{subpath,anchor,part?: point|in|out (the anchor, default, or its in or out handle),weight}] (2 or 3 points of one path whose weights add up to 1), across?: (2 anchors only: times the first-to-second anchor vector (x,y) turned to (-y,x), which points down when the vector points right)} (an end is an anchor or a free point, not both)}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits", has_doc, create ), @@ -23,7 +25,7 @@ pub fn specs() -> Vec { "Update Dimensions", [], None, - "{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}", + "{ids?: [dimension ids] (default selection), options?: dimension options, orientation?: aligned|horizontal|vertical (linear dimensions only; the line stays on the side of the points it was on), offset?: pt (as in dimension.create)} restyle, turn or move existing dimensions → {ids}", has_doc, update ), @@ -92,11 +94,36 @@ fn recipe(s: &Session, p: &Value) -> Result { } else { Point::ZERO }; - let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None }; + let orientation = orientation(p, mode)?.unwrap_or_default(); + let mut d = Dimension { + mode, + a, + b, + vertex, + offset: distance(p, 30.0)?, + length: a.distance(b), + options: options(s, p)?, + source, + orientation, + moved: None, + }; + if mode == DimensionMode::Linear { + d.length = d.measured(); + } d.resolve(&s.doc()?.doc).map_err(error)?; Ok(d) } +/// The `orientation` param, if given: only a linear dimension is horizontal or vertical. +fn orientation(p: &Value, mode: DimensionMode) -> Result> { + let Some(v) = p.get("orientation") else { return Ok(None) }; + let orientation: Orientation = serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension orientation"))?; + if orientation != Orientation::Aligned && mode != DimensionMode::Linear { + return Err(error("Only linear dimensions are horizontal or vertical")); + } + Ok(Some(orientation)) +} + fn insert(doc: &mut Document, parent: Option, dim: Dimension) -> Result { let id = doc.alloc_id(); let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false }); @@ -134,21 +161,38 @@ fn update(s: &mut Session, p: &Value) -> Result { return Err(error("Select dimensions to update")); } let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None }; - s.edit("Update Dimensions", |doc, _| { + // Every dimension is checked before anything changes, and an update that changes none of + // them records no undo step. + let mut next = Vec::with_capacity(ids.len()); + { + let doc = &s.doc()?.doc; for id in &ids { if !doc.is_editable(*id) { return Err(error("Dimension is locked or hidden")); } - let mut dim = dimension_of(doc, *id)?; + let old = dimension_of(doc, *id)?; + let mut dim = old.clone(); if let Some(o) = &o { dim.options = o.clone(); } + if let Some(orientation) = orientation(p, dim.mode)? { + dim.set_orientation(orientation); + } dim.offset = distance(p, dim.offset)?; dim.resolve(doc).map_err(error)?; - replace(doc, *id, dim)?; + if dim != old { + next.push((*id, dim)); + } } - Ok(()) - })?; + } + if !next.is_empty() { + s.edit("Update Dimensions", |doc, _| { + for (id, dim) in next { + replace(doc, id, dim)?; + } + Ok(()) + })?; + } Ok(json!({"ids":ids})) } @@ -187,9 +231,10 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result { fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> { let old: Vec = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default(); let children = artwork(doc, &dim, &old)?; + let name = format!("Dimension · {}", dim.label().map_err(error)?); if let Some(n) = doc.node_mut(id) { n.kind = NodeKind::Group { children, clip: false }; - n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?)); + n.name = Some(name); n.extras_mut().dimension = Some(Box::new(dim)); } Ok(()) @@ -227,8 +272,9 @@ impl EndedLink { } /// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it -/// measured. A command that replaces art calls this before it changes anything, since replacing -/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`]. +/// measured. A command that replaces art calls this before it changes anything and +/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a +/// plain group. pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec { let mut ended = Vec::new(); for id in reached(doc, roots) { @@ -241,18 +287,156 @@ pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec ended } -/// The dimensions in `ended` whose art is as it was are live again. -pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec) { +/// The dimensions in `ended` whose own art is as it was are live again. One whose measured art the +/// command changed from `before` so that the art no longer fits its link stays where it was, no +/// longer linked, as [`refresh`] leaves a dimension whose art was deleted. +pub(crate) fn restore_unchanged(before: &Document, doc: &mut Document, ended: Vec) { for e in ended { - if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) - && let Some(n) = doc.node_mut(e.id) - { - n.extras_mut().dimension = Some(Box::new(e.recipe)); + if !doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) { + continue; + } + let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) }; + if let Some(n) = doc.node_mut(e.id) { + n.extras_mut().dimension = Some(Box::new(recipe)); doc.dimensions = true; } } } +/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when +/// the moved dimension is valid. +fn unlinked(old: &Dimension) -> Dimension { + let mut unlinked = old.clone(); + unlinked.source = None; + unlinked.moved = None; + if let Some(v) = old.moved { + let mut shifted = unlinked.clone(); + (shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v); + if shifted.validate().is_ok() { + unlinked = shifted; + } + } + unlinked +} + +/// The whole dimensions under `roots` that are selected together with art they measure (art under +/// `roots`), each one a root or held by groups without fills, strokes or effects of their own. +/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their +/// lines and label as they are. A group with an appearance of its own changes everything it holds +/// when it expands, its dimensions included. +pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec { + if !doc.dimensions { + return Vec::new(); + } + let mut found = Vec::new(); + for root in roots { + let Some(n) = doc.node(*root) else { continue }; + n.walk(&mut |c| { + if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) { + found.push((*root, c.id, source.nodes())); + } + }); + } + let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a))); + found + .into_iter() + .filter(|(root, id, _)| { + let Some(ancestry) = doc.ancestry(*id) else { return false }; + let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len()); + ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group)) + }) + .filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m))) + .map(|(_, id, _)| id) + .collect() +} + +/// A group or layer without fills, strokes or effects of its own. +fn plain_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty() +} + +/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or +/// its parts (with their places in the asset list) and its parts' variable bindings. +pub(crate) struct Lifted { + parent: Option, + index: usize, + node: Arc, + assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>, + bindings: Vec<(NodeId, std::collections::BTreeSet)>, +} + +impl Lifted { + pub(crate) fn id(&self) -> NodeId { + self.node.id + } +} + +/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces +/// art does not reach them; [`put_back`] puts them back. +pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec { + let mut lifted = Vec::new(); + for id in with_their_art(doc, roots) { + let Some((parent, index, _)) = doc.position(id) else { continue }; + let mut parts = std::collections::BTreeSet::new(); + if let Some(n) = doc.node(id) { + n.walk(&mut |c| { + parts.insert(c.id); + }); + } + let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect(); + let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect(); + if let Ok(node) = doc.remove(id) { + lifted.push(Lifted { parent, index, node, assets, bindings }); + } + } + lifted +} + +/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in +/// the export assets that listed them and their variable bindings back: edits made while it was out +/// let go of them. A dimension whose group is gone goes on top of the first layer. +pub(crate) fn put_back(doc: &mut Document, lifted: Vec) { + for l in lifted.into_iter().rev() { + let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) }; + let Ok(ch) = doc.children_mut(parent) else { continue }; + let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() }; + ch.insert(i, l.node); + doc.dimensions = true; + for (at, saved) in l.assets { + let there: Vec = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect(); + if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) { + for (k, n) in there.iter().enumerate() { + if !a.nodes.contains(n) { + let k = k.min(a.nodes.len()); + a.nodes.insert(k, *n); + } + } + } else if !there.is_empty() { + let at = at.min(doc.assets.len()); + doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved }); + } + } + for (n, b) in l.bindings { + doc.variables.bindings.entry(n).or_insert(b); + } + } +} + +/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves +/// them alone would change: `doc` itself when there are none. +pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> { + let ids = with_their_art(doc, roots); + if ids.is_empty() { + return std::borrow::Cow::Borrowed(doc); + } + let mut view = doc.clone(); + for id in ids { + // A dimension already gone (inside another one removed) needs nothing more. + let _ = view.remove(id); + } + std::borrow::Cow::Owned(view) +} + /// Called inside every edit, so dimensions and their undo history stay in step with the art they /// measure. Returns at once in documents without dimensions. pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { @@ -271,17 +455,27 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { let mut next = old.clone(); let moved = next.moved.take(); if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() { - // A deleted/degenerate source has no valid measurement. Remove its annotation in - // this same undo step instead of keeping a silently wrong number. - doc.remove(id)?; + // The art it measured was deleted, or rebuilt with other anchors, or no longer fits + // (an ellipse that was a circle): it stays where it was, no longer linked, in this + // same undo step, rather than attaching to a different edge. + if replace(doc, id, unlinked(&old)).is_err() + && let Some(n) = doc.node_mut(id) + { + // Its recipe cannot be drawn again: its art stays as it is, as a plain group. + n.extras_mut().dimension = None; + } changed = true; continue; } + next.keep_gap(&old); + if let Some(xf) = next.source.as_ref().and_then(|s| s.reflection(before, doc)) { + next.mirror_line(&old, xf * old.handle()); + } // Moved on its own (the art it measures stayed put): it keeps its new place. if let Some(v) = moved && (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex) { - next.place(next.handle() + v); + next.place(old.handle() + v); if next.resolve(doc).is_err() { next = old.clone(); next.moved = None; @@ -424,17 +618,19 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result (d.a, d.b), DimensionMode::Diameter => (d.a - delta, d.b), - _ => (d.a + n * d.offset, d.b + n * d.offset), + _ => d.line_ends(), }; - // The side of the line facing away from the object. + // The side of the line facing away from the object: the side the line is on, seen from + // the middle of the measured points. let outward = match d.mode { DimensionMode::Linear => { - if d.offset < 0.0 { + if (a.midpoint(b) - d.a.midpoint(d.b)).dot(n) < 0.0 { -n } else { n @@ -443,8 +639,16 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result n, }; if d.mode == DimensionMode::Linear && !o.hide_extensions { + // Each extension line runs from its point toward the dimension line, which can lie on + // different sides of the two points of a horizontal or vertical dimension. for (p, q) in [(d.a, a), (d.b, b)] { - paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, mode, true, false))); + let side = (q - p).dot(n); + let toward = match side { + s if s < 0.0 => -n, + s if s > 0.0 => n, + _ => outward, + }; + paths.push((line(p + toward * o.extension_offset, q + toward * o.extension_length), appearance(o, mode, true, false))); } } angle = u.atan2(); diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index bcaa59426..17638477a 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Liquify", [], None, - "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", + "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle? (deg clockwise: the brush's width axis from level), intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °, positive turns clockwise), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", has_doc, liquify ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Puppet Warp", [], None, - "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", + "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, clockwise, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", has_doc, puppet_warp ), @@ -89,7 +89,7 @@ pub fn specs() -> Vec { "Puppet Warp Pins", [], None, - "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", + "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg clockwise, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", has_doc, puppet_pins ), diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 8c3525449..62b72a8e0 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -263,6 +263,22 @@ fn record_again(s: &mut Session, again: Value) -> Result<()> { Ok(()) } +/// `d` with each of `ids` that is not in perspective attached to `fallback` as [`persp_edit`] attaches +/// it; `d` itself when every one is in perspective already, there is no `fallback`, or one of them +/// cannot attach. +fn with_fallback<'a>(d: &'a Document, ids: &[NodeId], fallback: Option) -> std::borrow::Cow<'a, Document> { + let mut g = grid_of(d); + let loose: Vec = ids.iter().copied().filter(|id| g.attachment_of(d, *id).is_none()).collect(); + let Some(plane) = fallback.filter(|_| !loose.is_empty()) else { return std::borrow::Cow::Borrowed(d) }; + let mut out = d.clone(); + for id in loose { + if attach_in(&mut out, &mut g, id, plane, false, None).is_err() { + return std::borrow::Cow::Borrowed(d); + } + } + std::borrow::Cow::Owned(out) +} + fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } @@ -285,9 +301,11 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let constrain = switch_or(p, "constrain", false, C)?; // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. check_switches(p, &["snap"], C)?; + // Shift and Snap to Grid read the objects as they are once those not in perspective are attached. + let doc = if perp { std::borrow::Cow::Borrowed(&*s.doc()?.doc) } else { with_fallback(&s.doc()?.doc, &ids, fallback) }; // Shift: the constrained end point, read on the first object's plane and depth. let snap_to = if constrain && !perp { - let doc = &s.doc()?.doc; + let doc = &*doc; let g = grid_of(doc); g.plane_bounds(doc, &ids) .and_then(|(plane, depth, _)| { @@ -305,10 +323,10 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let fix = if perp { None } else { - let doc = &s.doc()?.doc; - crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to)) - .and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction))) + crate::cmd::perspgrid::snap_fix(&doc, &ids, p, (from, snap_to)) + .and_then(|(plane, depth, correction)| Some((plane, grid_of(&doc).plane_delta(plane, depth, from, to)?, correction))) }; + drop(doc); let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| { let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1); let (direction, dv) = diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index e6e75c1f3..cb1bb86fb 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -370,7 +370,8 @@ fn info(s: &mut Session, p: &Value) -> Result { return; } let k = match &n.kind { - NodeKind::Layer { .. } => None, + // The Guides row counts paths made into guides; ruler guides are not objects. + NodeKind::Layer { .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Group { clip: true, .. } => Some("clipGroups"), NodeKind::Group { .. } => Some("groups"), NodeKind::Path { guide: true, .. } => Some("guides"), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5f8508f4e..db792ab1c 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -666,7 +666,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { if (index.is_some() || ids.is_some()) && s.menu.guides_locked { return Err(EngineError::Other("guides are locked".into())); } - let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { + let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { let rulers = match index { Some(v) => vec![editable_guide_index(s, Some(v), C)?], None => vec![], @@ -689,10 +689,10 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let all = guide_paths(&st.doc); let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); - let rulers: Vec = if s.menu.guides_locked { + let rulers: Vec = if s.menu.guides_locked { vec![] } else { - st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect() + st.selection.guides.iter().copied().filter(|id| st.doc.ruler_guide(*id).is_some_and(|g| st.doc.guide_editable(&g))).collect() }; let editable = st.doc.editable(); if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) } @@ -702,8 +702,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)); let mut lines = Vec::with_capacity(rulers.len()); for &i in &rulers { - let Some(g) = st.doc.guides.get(i) else { continue }; - let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); + let Some(g) = st.doc.ruler_guide(i) else { continue }; + let span = st.doc.guide_span(&g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); let Some((a, b)) = span else { continue }; let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) }; let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) { @@ -729,7 +729,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?; made.push(id); } - d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i)); + d.retain_guides(sel, |g| !lines.iter().any(|l| Some(l.0) == g.id)); sel.set(paths.iter().copied().chain(made)); Ok(()) })?; @@ -745,12 +745,12 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result { fn clear_guides(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let paths = guide_paths(&st.doc); - let n = paths.len() + st.doc.guides.len(); + let n = paths.len() + st.doc.ruler_guides().len(); if n == 0 { return Ok(json!({ "count": 0 })); } - s.edit("Clear Guides", |d, _| { - d.guides.clear(); + s.edit("Clear Guides", |d, sel| { + d.retain_guides(sel, |_| false); let mut gone = Beside::replacing(d, &paths); for id in &paths { if d.node(*id).is_some() { @@ -780,17 +780,29 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { // Where new art goes (refused when no layer is shown and unlocked), or that group's layer. None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }), }; - let i = s.edit("New Guide", |d, _| { - d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); - Ok(d.guides.len() - 1) - })?; + let id = s.edit("New Guide", |d, _| Ok(d.add_ruler_guide(&Guide { artboard, layer, ..Guide::new(vertical, pos) })))?; + let i = guide_indexes(&s.doc()?.doc, &[id]).first().copied(); Ok(json!({ "index": i })) } +/// The ids of the active document's ruler guides by index: the order of `guide.list` (ascending +/// id). +pub(crate) fn guide_ids(d: &vectorcraft_doc::Document) -> Vec { + d.ruler_guides().into_iter().filter_map(|g| g.id).collect() +} + +/// The `guide.list` indexes of ruler guides `ids`, in the order of `ids` (an id that is not a ruler +/// guide is left out). +pub fn guide_indexes(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let all = guide_ids(d); + ids.iter().filter_map(|id| all.binary_search(id).ok()).collect() +} + fn guide_list(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let row = |(i, g): (usize, &Guide)| { - let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) }); + let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id)); + let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected }); if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } @@ -801,31 +813,38 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { r["editable"] = json!(st.doc.guide_editable(g)); r }; - Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) + Ok(Value::Array(st.doc.ruler_guides().iter().enumerate().map(row).collect())) } -/// Ruler guide `index` of the active document (`index` validated). -fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { +/// The id of ruler guide `index` in `ids` ([`guide_ids`]; `index` validated). +fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result { let i = v.and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "an index must be a whole number"))?; - if i >= s.doc()?.doc.guides.len() { - return Err(bad(cmd, format!("no guide {i}"))); + ids.get(i).copied().ok_or_else(|| bad(cmd, format!("no guide {i}"))) +} + +/// The id of ruler guide `index` of the active document (`index` validated). +fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd) +} + +/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer? +fn check_editable_guide(s: &Session, id: NodeId) -> Result { + let d = &s.doc()?.doc; + if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) { + return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); } - Ok(i) + Ok(id) } /// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. -fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { - let i = guide_index(s, v, cmd)?; - let d = &s.doc()?.doc; - if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) { - return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); - } - Ok(i) +fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + check_editable_guide(s, guide_index(s, v, cmd)?) } 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 picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; + 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) { // Added to (or taken from) what is selected, the art included. s.select(|_, sel| { @@ -836,12 +855,13 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { } else { s.select(|_, sel| sel.set_guides(picked))?; } - Ok(json!({ "selected": s.doc()?.selection.guides })) + let st = s.doc()?; + Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) })) } /// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { - let gone: Vec = match p.get("index") { + let gone: Vec = match p.get("index") { Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], None => s.doc()?.selection.guides.clone(), }; @@ -850,7 +870,7 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { } let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" }; s.edit(label, |d, sel| { - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); Ok(()) })?; Ok(json!({ "count": gone.len() })) @@ -866,18 +886,22 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result { if super::layer::locked_within(d, layer) { return Err(EngineError::Other("the layer or group is locked".into())); } - let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { + let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], - (None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::>>()?, + (None, Some(list)) => { + let ids = guide_ids(&s.doc()?.doc); + list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? + } (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { return Err(bad(C, "no guide selected (or give an index)")); } s.edit("Move Guide to Layer", |d, _| { - for i in &moving { - if let Some(g) = d.guides.get_mut(*i) { - g.layer = Some(layer); + // Each keeps its id (and so its index), at the top of the layer. + for id in &moving { + if d.parent_of(*id) != Some(layer) && d.ruler_guide(*id).is_some() { + d.move_node(*id, Some(layer), usize::MAX)?; } } Ok(()) @@ -891,9 +915,7 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { let i = editable_guide_index(s, Some(v), "guide.move")?; let pos = f64_req(p, "pos", "guide.move")?; s.edit("Move Guide", |d, _| { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); Ok(()) })?; return ok(); @@ -917,18 +939,14 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| { let mut copies = vec![]; for &i in &moving { - let Some(g) = d.guides.get_mut(i) else { continue }; - let moved = g.moved(Vec2::new(dx, dy)); if copy { - copies.push(moved); + copies.extend(d.ruler_guide(i).map(|g| d.add_ruler_guide(&g.moved(Vec2::new(dx, dy))))); } else { - *g = moved; + d.edit_ruler_guide(i, |g| *g = g.moved(Vec2::new(dx, dy))); } } if copy { - let first = d.guides.len(); - d.guides.extend(copies); - sel.set_guides(first..d.guides.len()); + sel.set_guides(copies); } Ok(()) })?; diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 0358acddf..8946aa435 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { "Split Path", [], None, - "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}", + "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two; one of the two new end points is left selected → {ids}", has_doc, split ), @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", has_doc, erase_region ), diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index aa49606cb..126fa57a1 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -200,9 +200,16 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result { _ => return Err(EngineError::Other("no such segment".into())), }; let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k])); + // The cut leaves one of its two coincident end points selected, as `path.cutAtAnchors` + // does, so a Direct Selection drag pulls the path apart there. + let pick = |sel: &mut vectorcraft_doc::Selection, id: NodeId, si: usize| { + sel.clear(); + sel.add(id); + sel.anchors.entry(id).or_default().insert((si, 0)); + }; if sp.closed { path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?; - sel.set([id]); + pick(sel, id, si); return Ok(vec![id]); } let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?; @@ -211,12 +218,12 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result { let node = sibling_with(d, id, PathData::single(right))?; let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; let nid = d.insert(parent, idx + 1, node)?; - sel.set([id, nid]); + pick(sel, nid, 0); Ok(vec![id, nid]) } else { path.subpaths[si] = left; path.subpaths.insert(si + 1, right); - sel.set([id]); + pick(sel, id, si + 1); Ok(vec![id]) } })?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 80574d2d5..7a8a91f0a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected", + "{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) removes the objects it copies, in one undo step: a compound-path member selected on its own leaves the compound's other members in the compound (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected", has_selection_guides_or_artboard_tool, cut ), @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", + "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses (a compound-path member selected on its own, whole or in part, with Direct or Group Selection, is copied as an ordinary path with the compound's paint, transparency, opacity mask, fill rule and graphic style link (while it has that style's look), a gradient keeping the compound's fit; the compound is copied when it is selected, or when every member is selected whole outside isolation mode), and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -220,7 +220,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected", + "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides; a compound-path member selected on its own is deleted alone, and a compound left with no members goes too); without ids, refused while an object of a symbol instance's art is selected", has_selection_or_guides, clear ), @@ -931,7 +931,11 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option>)>) -> i }) } -/// The direct-selection flag and resolved root nodes for Copy and Cut operations. +/// The direct-selection flag and resolved root nodes for Copy and Cut operations. A compound-path +/// member selected on its own (whole, with Direct or Group Selection, or by some of its anchors or +/// segments) is taken as itself, so Copy copies it as an ordinary path and Cut removes it alone. The +/// compound is taken whole when it is selected, or when every one of its members is selected whole +/// outside isolation mode; in an isolated compound every member is taken as itself. fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let st = s.doc()?; // The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes @@ -940,9 +944,19 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked; let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty()); let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))); - let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| { - in_isolated_compound || (direct && (st.selection.partial(member).is_some() || st.selection.segments.contains_key(&member))) - }); + // A member counts as selected whole when Copy would take it unchanged: no anchors or segments + // of it are picked, the picked ones cover every segment, or a drawing tool's own anchor is + // all that is picked. + let selected: std::collections::HashSet = st.selection.objects.iter().copied().collect(); + let whole = |m: &vectorcraft_doc::Node| selected.contains(&m.id) && (!direct || super::clipboard::copied_whole(m, &st.selection)); + // Worked out once per compound: every selected member asks about its compound. + let checked = std::cell::RefCell::new(std::collections::HashMap::new()); + let all_members_whole = |compound: NodeId| { + *checked.borrow_mut().entry(compound).or_insert_with(|| { + st.doc.node(compound).and_then(|n| n.children()).is_some_and(|members| !members.is_empty() && members.iter().all(|m| whole(m))) + }) + }; + let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |compound, _member| in_isolated_compound || !all_members_whole(compound)); Ok((direct, roots)) } @@ -977,8 +991,8 @@ fn copy(s: &mut Session, p: &Value) -> Result { .selection .guides .iter() - .filter_map(|i| st.doc.guides.get(*i)) - .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, ..g.clone() }) + .filter_map(|i| st.doc.ruler_guide(*i)) + .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, id: None, ..g }) .collect(); let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { targets }; let mut clip = Clipboard::copy(st, &roots, direct); @@ -990,7 +1004,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { // Guides alone: the artboard the first one belongs to, or crosses, is where in-place pastes // measure from, as the objects' artboard is. if clip.nodes.is_empty() - && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.guides.get(*i)) + && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.ruler_guide(*i)) { let board = g.artboard.and_then(|id| st.doc.artboards.iter().find(|a| a.id == id)).or_else(|| { st.doc @@ -1016,8 +1030,8 @@ fn cut(s: &mut Session, p: &Value) -> Result { copy(s, p)?; return clear(s, &json!({})); } - // Copy promotes a compound member to its compound. Cut must remove the same objects it - // copied, while Clear on its own deletes exactly the selected members. + // Cut removes the same objects Copy takes: a compound member selected on its own, or the + // whole compound when it is selected or every member is selected whole. let (direct, roots) = copy_cut_targets(s)?; let res = copy(s, p)?; if res.get("copied").and_then(Value::as_u64) == Some(0) { @@ -1039,18 +1053,14 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { s.edit("Cut", |d, selection| { // The selected ruler guides were copied too (#3226): they go in the same undo step. let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); let mut survivors = vec![]; let mut pieces = super::pathops::Beside::replacing(d, roots); for id in roots { let Some(node) = d.node(*id) else { continue }; let has_direct = sel.anchors.contains_key(id) || sel.segments.contains_key(id); - if !has_direct { - d.remove(*id)?; - continue; - } - if node.path_data().is_none() { - d.remove(*id)?; + if !has_direct || node.path_data().is_none() { + remove_leaving_no_empty_compound(d, *id)?; continue; } let original = super::path::deletion_source(d, *id)?; @@ -1073,13 +1083,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { } subpaths.retain(|sp| sp.anchors.len() >= 2); if subpaths.is_empty() { - let parent = d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))); - d.remove(*id)?; - if let Some(p) = parent - && d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty())) - { - d.remove(p)?; - } + remove_leaving_no_empty_compound(d, *id)?; } else { survivors.extend(super::path::replace_deleted_pieces(d, &mut pieces, original, subpaths)?); } @@ -1093,6 +1097,19 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { ok() } +/// Remove object `id`, and the compound path that held it when it was the compound's last member. +/// A compound left with one member stays a compound path, as after Clear. +fn remove_leaving_no_empty_compound(d: &mut Document, id: NodeId) -> Result<()> { + let parent = d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))); + d.remove(id)?; + if let Some(p) = parent + && d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty())) + { + d.remove(p)?; + } + Ok(()) +} + fn clear(s: &mut Session, p: &Value) -> Result { let guides = switch_or(p, "guides", false, "edit.clear")?; let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None }; @@ -1136,7 +1153,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { } if with_guides { let gone = sel.guides.clone(); - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); } if is_explicit { for id in &ids { @@ -1315,7 +1332,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho vec![Affine::IDENTITY] }; let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| { - let imported = clip.import_into(d, choices, same_doc); + let imported = clip.import_into(d, choices, same_doc)?; // Draw Inside: the clip group of the object drawn into, made on the first paste. let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?; let artboards: Vec = boards @@ -1421,8 +1438,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for g in &clip.guides { let pos = g.pos + if g.vertical { dv.x } else { dv.y }; if pos.is_finite() { - sel.guides.push(d.guides.len()); - d.guides.push(vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() }); + sel.guides.push(d.add_ruler_guide(&vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() })); } } } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 9d396edc1..30d927a96 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it 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. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -109,9 +109,155 @@ pub fn specs() -> Vec { has_doc, move_effect ), + cmd!( + query "effect.deferredStatus", + "Deferred Effects", + [], + None, + "{} the live-effect plug-in stages the active document left undrawn: while a document is drawn on screen, the plug-in effects of its first view share a 2,000 ms budget (each run also keeps to its own 5,000 ms limit); a stage left out for want of time, or one that times out, fails or has no plug-in, draws its input art unchanged and stays listed until it gives a result (Effect › Refresh Deferred Effects, an edit to its input or parameters, or an export, which runs every stage under the per-call limit), or until its object is deleted or its effect is removed, moved or hidden (or its fill or stroke is hidden) → {budgetMs: 2000, effects: [{objectId (also an object in the art of a symbol, pattern, brush or opacity mask), effectIndex (in the object's effects, or in the fill's or stroke's), item? (the fill or stroke whose effect it is), pluginId, reason: budget|timeout|missing|failed|safeMode}]} (empty: none)", + has_doc, + deferred_status + ), + cmd!( + "effect.refreshDeferred", + "Refresh Deferred Effects", + ["Effect"], + None, + "{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", + has_deferred, + refresh_deferred + ), ] } +/// The active document has deferred live-effect plug-in stages ([`deferred_status`]). +fn has_deferred(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if deferred_effects(&st.doc, &st.plugin_effects).is_empty() { Err("no plug-in effect is deferred".into()) } else { Ok(()) } +} + +/// The objects the deferred stages of `fx` name, by id, each with whether it is in `d`'s layers. +/// An object that is not is looked for in the art `d` draws from elsewhere, where live-effect +/// plug-ins run too: the art of its symbols, patterns and brushes, and opacity masks. +fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap, bool)> { + let mut out = std::collections::HashMap::new(); + let mut rest = Vec::new(); + for object in fx.list().into_iter().map(|x| x.stage.object) { + match d.node_arc(NodeId(object)) { + Some(n) => { + out.insert(object, (n.clone(), true)); + } + None => rest.push(object), + } + } + if rest.is_empty() { + return out; + } + let brushes = vectorcraft_brush::library(d); + let symbols = d.symbols.iter().map(|s| &*s.art); + let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a)); + let layers = d.layers.iter().map(|a| &**a); + let mut look = |n: &Node| { + if rest.contains(&n.id.0) { + out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false)); + } + }; + for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) { + vectorcraft_doc::swatches::walk_with_masks(a, &mut look); + } + out +} + +/// Is plug-in `plugin`'s effect at `stage`'s place in `n` (`None`: no such object), and visible, +/// on a visible fill or stroke when it is one's? +fn stage_holds(n: Option<&Node>, stage: vectorcraft_plugins::deferred::Stage, plugin: &str) -> bool { + let Some(n) = n else { return false }; + let list = match stage.item { + None => Some(n.appearance.effects.as_slice()), + Some(i) => n.appearance.items.get(i).filter(|it| it.visible()).map(|it| it.effects().as_slice()), + }; + list.and_then(|l| l.get(stage.effect)).is_some_and(|e| e.visible && e.id == vectorcraft_plugins::effect::effect_id(plugin)) +} + +/// Drops the records of `fx`'s stages whose object or plug-in effect is no longer at their place +/// in `d` ([`stage_holds`]), and gives the objects of the others ([`stage_objects`]). +fn prune(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap, bool)> { + let objects = stage_objects(d, fx); + fx.retain(|stage, plugin| stage_holds(objects.get(&stage.object).map(|(n, _)| &**n), stage, plugin)); + objects +} + +/// The deferred stages of `d` as `effect.deferredStatus` lists them. The records of stages whose +/// object or plug-in effect is no longer at their place in `d` are dropped first. +fn deferred_effects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> Vec { + prune(d, fx); + fx.list() + .into_iter() + .map(|x| { + let mut v = json!({"objectId": x.stage.object, "effectIndex": x.stage.effect, "pluginId": x.plugin, "reason": x.reason.name()}); + if let Some(i) = x.stage.item { + v["item"] = json!(i); + } + v + }) + .collect() +} + +/// `effect.deferredStatus`. +fn deferred_status(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + Ok(json!({"budgetMs": vectorcraft_plugins::deferred::OPENING_BUDGET_MS, "effects": deferred_effects(&st.doc, &st.plugin_effects)})) +} + +/// Draws `n` alone, small, as the canvas would (its live-effect plug-ins run inside the view that +/// applies): what matters is that it is drawn, not its pixels. Is every part of it drawn, none left +/// out for its size? +fn draw_small(renderer: &mut vectorcraft_render::Renderer, doc: &Document, n: &Arc) -> bool { + let Some(b) = n.geometric_bounds() else { return false }; + let scale = 64.0 / b.width().max(b.height()).max(1.0); + let view = vectorcraft_geom::Affine::scale(scale) * vectorcraft_geom::Affine::translate((1.0 - b.x0, 1.0 - b.y0)); + renderer.stats = Default::default(); + renderer.render_node(doc, n, 66, 66, view); + renderer.stats.culled == 0 +} + +/// `effect.refreshDeferred`: each object of the document's layers is drawn again alone, small, +/// inside the document's view, so its stages are run with the input they were deferred with; a +/// hidden object, or one in a hidden layer or group, is not drawn. An object in the art of a +/// symbol, pattern, brush or opacity mask runs where that art is used: the visible layers are +/// drawn then. A stage input left out of a small drawing for its size stays deferred. +fn refresh_deferred(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone()); + let ids = super::named_ids(p)?; + let objects = prune(&doc, &fx); + if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) { + return Err(EngineError::NoNode(*id)); + } + let ids: Option> = ids.map(|ids| ids.iter().map(|id| id.0).collect()); + let counts = fx.refresh(ids.as_deref(), |targets| { + let mut renderer = vectorcraft_render::Renderer::new(); + let mut whole = Vec::new(); + fx.view(|| { + let mut elsewhere = false; + for id in targets { + match objects.get(id) { + Some((n, true)) if doc.is_visible(NodeId(*id)) && draw_small(&mut renderer, &doc, n) => whole.push(*id), + Some((_, false)) => elsewhere = true, + _ => {} + } + } + if elsewhere { + for layer in doc.layers.iter().filter(|l| l.visible) { + draw_small(&mut renderer, &doc, layer); + } + } + }); + whole + }); + Ok(json!({"refreshed": counts.refreshed, "stillDeferred": counts.still_deferred, "failed": counts.failed})) +} + fn ids_json(ids: &[NodeId]) -> Value { json!(ids.iter().map(|i| i.0).collect::>()) } @@ -282,11 +428,12 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if effects::is_three_d(id) { let targets = appearance_targets(s, p)?; for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { - let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?; + let d = &s.doc()?.doc; + let n = d.node(target).ok_or(EngineError::NoNode(target))?; // A new effect goes after the item's others. let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); - effects::validate_three_d_at(n, id, &effect.params, on).map_err(|e| bad(C, e))?; + effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; if id == effects::REVOLVE && effects::has_revolve(n) { return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } @@ -575,7 +722,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result { params["name"] = json!(svg_filter_name(s, xml, C)?); } for &(id, target, index) in &targets { - let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + let d = &s.doc()?.doc; + let n = d.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) { return Err(bad(C, "warpStyle can only edit an applied Warp effect")); @@ -593,7 +741,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { cur.extend(new); } if visible != Some(false) { - effects::validate_three_d_at(n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; + effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; } else { // Hidden, the effect draws nothing, so its object need not fit. The values given are // checked, and after a change of 3D type so is every value it keeps. @@ -672,11 +820,19 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// fills, strokes or effects of its own or such members, other objects with effects, or type with /// fills or strokes of its own? pub(crate) fn expandable(n: &Node) -> bool { + expandable_except(n, &[]) +} + +/// [`expandable`], leaving out the objects `skip` lists and what they hold. +pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { + if skip.contains(&n.id) { + return false; + } match &n.kind { NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false, NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n), NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c)) + !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), @@ -686,9 +842,11 @@ pub(crate) fn expandable(n: &Node) -> bool { fn can_expand(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - let d = &s.doc().map_err(|e| e.to_string())?.doc; let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?; - if ids.iter().filter_map(|id| d.node(*id)).any(expandable) { + let d = &s.doc().map_err(|e| e.to_string())?.doc; + // A dimension selected with the art it measures is left as it is. + let kept = super::dimension::with_their_art(d, &ids); + if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) { Ok(()) } else { Err("select (or target) objects whose appearance isn't basic".into()) @@ -731,6 +889,10 @@ fn expand_pieces( let mut seen = std::collections::HashSet::from([id]); let mut expanded = std::collections::HashMap::new(); for c in m.children_mut().into_iter().flatten() { + // A layer's ruler guides are not art: they keep their ids and expand to nothing. + if c.is_ruler_guide() { + continue; + } let c = Arc::make_mut(c); let mut listed = None; if all || c.id == id { @@ -812,8 +974,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br let mut changed = false; // Is `n` in `out` yet? let mut listed_n = false; + // A symbol instance's fill tints its art before the adjustment, as the canvas draws it. + let symbol_art = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if effects::has_adjustment(&n) => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n)) + } + _ => None, + }; if effects::has_adjustment(&n) - && let Some(m) = effects::adjust_in_document(d, &n) + && let Some(m) = effects::adjust_in_document(d, &n, symbol_art) { out.push((id, 0)); listed_n = true; @@ -919,6 +1088,11 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // The place of each member in the walk of `n`: after `n` and the members before it. let mut at = 1; for c in n.children_mut().into_iter().flatten() { + if c.is_ruler_guide() { + // A layer's ruler guide expands to nothing, but the walk of `n` still visits it. + at += 1; + continue; + } let from = out.len(); match members.remove(&c.id) { Some(listed) => out.extend(listed), @@ -932,7 +1106,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br for (_, place) in &mut out[from..] { *place += at; } - at += c.count(); + // Every node the walk visits, ruler guides in a sublayer too (`Node::count` leaves them out). + c.walk(&mut |_| at += 1); } } changed.then_some(n) @@ -966,7 +1141,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the // geometry it hasn't changed. if vectorcraft_plugins::safe_mode::active() { - let d = &s.doc()?.doc; + // A dimension selected with the art it measures is left as it is. + let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots); let mut seen = std::collections::HashSet::new(); if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); @@ -976,20 +1152,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); - let mut ended = super::dimension::end_links(d, &roots); + // A dimension selected with the art it measures keeps its own lines and label: it is out + // of the document while the art expands. + let lifted = super::dimension::lift(d, &roots); + let kept = !lifted.is_empty(); + let ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut out = vec![]; let threaded = super::threads::frames(d); for id in &roots { expand_node(d, *id, &brushes, &mut out); } - // A dimension whose source was replaced stays plain: its link no longer fits that art. - ended.retain(|e| e.source_kept(&before, d)); - super::dimension::restore_unchanged(d, ended); + super::dimension::put_back(d, lifted); + // A dimension whose source was replaced stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); // Outlined frames of threaded text leave their threads with the text they show; type that // stays type under a new id takes its frame's place. super::threads::leave_threads(d, &threaded); - if out.is_empty() { + if out.is_empty() || (kept && d.layers == before.layers) { return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into())); } super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?; @@ -1006,6 +1186,9 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod lighting_presets_tests; +#[cfg(test)] +mod three_d_source_tests; + #[cfg(test)] mod three_d_type_tests; diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 6604c99a2..e4c8d7945 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -130,7 +130,8 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() { } /// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis; -/// the members stay editable, and a group holding what can't become a solid is refused. +/// the members stay editable. A group holding a clipping group builds from the art it shows, and a +/// group holding what can't become a solid is refused. #[test] fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { for effect in effects::SOLIDS { @@ -151,17 +152,59 @@ fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded"); } - // A clipping group in the group: refused, nothing changed. + // A clipping group in the group: each effect builds from the art it shows. + for effect in effects::SOLIDS { + let (mut s, _, g) = group_with_a_clipping_group(); + s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap_or_else(|e| panic!("{effect}: {e}")); + let n = node(&s, g); + assert_eq!(n.children().unwrap().len(), 2, "{effect}: the members stay as they were"); + let art = effects::solid_art_in(Some(&s.doc().unwrap().doc), &n); + assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{effect}: draws its solid"); + } + // An image in the group: refused, nothing changed. + let (mut s, _, g) = group_with_an_image(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("Image Trace"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); +} + +/// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session, +/// the clipping group and the group. +fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) { let (mut s, a) = session_with_rect(); let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); - let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); + let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); + s.execute("select.set", &json!({"ids": [clip.0, c]})).unwrap(); let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string(); - assert!(err.contains("clipping groups"), "{err}"); - assert!(node(&s, g).appearance.effects.is_empty()); + (s, clip, g) +} + +/// A group of a rectangle and an image: the session, the image and the group. +fn group_with_an_image() -> (Session, NodeId, NodeId) { + let (mut s, a) = session_with_rect(); + let image = s + .edit("Place", |d, _| { + let id = d.alloc_id(); + let img = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::translate((250.0, 250.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let (parent, index, _) = d.position(a).unwrap(); + d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?; + Ok(id) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, image.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + (s, image, g) } /// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays @@ -339,21 +382,25 @@ fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() { assert!(effects::has_revolve(&node(&s, g))); } -/// A group holding what can't be swept is refused as a whole, and a clipping group is named. +/// A group holding what can't be swept is refused as a whole, and an image is named. A clipping +/// group, alone or in a group, sweeps the art it shows. #[test] fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() { - let (mut s, a) = session_with_rect(); - let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); - let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); - let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string(); - assert!(err.contains("path, compound path, type or group"), "{err}"); - let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); - let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); - assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}"); - assert!(node(&s, g).appearance.effects.is_empty()); + let (mut s, image, g) = group_with_an_image(); + let before = s.doc().unwrap().doc.clone(); + for target in [image, g] { + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("Revolve") && err.contains("Image Trace"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); + } + for clipping_group_itself in [true, false] { + let (mut s, clip, g) = group_with_a_clipping_group(); + let target = if clipping_group_itself { clip } else { g }; + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null})) + .unwrap_or_else(|e| panic!("{clipping_group_itself}: {e}")); + let art = effects::revolve_art_in(Some(&s.doc().unwrap().doc), &node(&s, target)); + assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{clipping_group_itself}: draws its surface"); + } } /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is diff --git a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs new file mode 100644 index 000000000..122d937b9 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs @@ -0,0 +1,98 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"]; + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +/// A clipping group, a blend and a symbol instance, each made through its command. +fn sources(s: &mut Session) -> Vec<(&'static str, NodeId)> { + let art = rect(s, 20.0, 20.0, 60.0, 60.0); + let clip = rect(s, 40.0, 10.0, 20.0, 80.0); + select(s, &[art, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let clipped = s.doc().unwrap().selection.objects[0]; + let a = rect(s, 120.0, 20.0, 20.0, 20.0); + let b = rect(s, 200.0, 60.0, 30.0, 30.0); + select(s, &[a, b]); + let blend = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); + let art = rect(s, 20.0, 200.0, 50.0, 40.0); + let instance = id_of(&s.execute("symbol.new", &json!({"name": "Box", "ids": [art.0]})).unwrap()); + vec![("clipping group", clipped), ("blend", blend), ("symbol instance", instance)] +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +#[test] +fn every_three_d_type_applies_to_clipping_groups_blends_and_symbol_instances_in_one_undo_step() { + for id in TYPES { + let mut s = session(); + for (what, target) in sources(&mut s) { + select(&mut s, &[target]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_or_else(|e| panic!("{id} on a {what}: {e}")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{id} on a {what}"); + let n = s.doc().unwrap().doc.node(target).unwrap().clone(); + assert_eq!(n.appearance.effects.iter().filter(|e| e.id == id).count(), 1, "{id} on a {what}"); + // The object stays what it is, and the effect draws its solid. + assert!(!matches!(n.kind, NodeKind::Path { .. }), "{id} on a {what}"); + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::revolve_art_in(Some(d), &n).or_else(|| vectorcraft_render::effects::solid_art_in(Some(d), &n)); + assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{id} on a {what} draws its solid"); + // Edited through the dialog's command too. + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"rotationY": 20.0}})) + .unwrap_or_else(|e| panic!("{id} on a {what}: {e}")); + } + } +} + +#[test] +fn a_group_holding_an_image_refuses_every_three_d_type_and_names_image_trace() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0); + let image = s + .edit("Place", |d, _| { + let id = d.alloc_id(); + let img = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let (parent, index, _) = d.position(a).unwrap(); + d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?; + Ok(id) + }) + .unwrap(); + select(&mut s, &[a, image]); + s.execute("object.group", &json!({})).unwrap(); + for target in [image, s.doc().unwrap().selection.objects[0]] { + select(&mut s, &[target]); + for id in TYPES { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_err().to_string(); + assert!(err.contains("Object › Image Trace › Make"), "{id}: {err}"); + assert_eq!(s.doc().unwrap().doc, before, "{id}: nothing changed"); + } + } +} diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 8834e6a74..8dce25b11 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -5,7 +5,8 @@ //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip //! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art -//! Expand changes becomes a plain group of that art. +//! Expand changes becomes a plain group of that art, except one selected with art it measures, +//! which Expand leaves as it is. use std::collections::BTreeSet; use std::sync::Arc; @@ -25,7 +26,7 @@ use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_selection, str_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param}; const C: &str = "object.expand"; /// Expand Gradient To → Specify … Objects: the default and the most. @@ -43,7 +44,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, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {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) | \"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 ), @@ -55,7 +56,8 @@ pub fn specs() -> Vec { "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", has_selection, |s, _| { - let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + let roots = selected_roots(s)?; + let e = Expandable::of(&super::dimension::without_kept(&s.doc()?.doc, &roots), &roots); let mut info = json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)}); if e.symbols { @@ -74,7 +76,9 @@ pub fn specs() -> Vec { /// (#3089); `object.expand` itself still bakes them. pub fn has_appearance(s: &Session) -> bool { let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false }; - roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable) + // A dimension selected with the art it measures is left as it is. + let kept = super::dimension::with_their_art(&st.doc, &roots); + roots.iter().filter_map(|r| st.doc.node(*r)).any(|n| super::effectcmd::expandable_except(n, &kept)) } /// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it @@ -101,11 +105,11 @@ enum GradientTo { impl GradientTo { fn parse(p: &Value) -> Result { - let steps = f64_or(p, "steps", DEFAULT_STEPS as f64).round(); + let steps = number_or(p, "steps", DEFAULT_STEPS as f64, C)?.round(); if !(1.0..=MAX_STEPS as f64).contains(&steps) { return Err(bad(C, format!("steps must be 1..{MAX_STEPS}"))); } - match str_param(p, "gradient").unwrap_or("objects") { + match text_param(p, "gradient", C)?.unwrap_or("objects") { "objects" => Ok(Self::Objects(steps as u32)), "mesh" => Ok(Self::Mesh), _ => Err(bad(C, "gradient must be \"objects\" or \"mesh\"")), @@ -236,14 +240,23 @@ fn expand(s: &mut Session, p: &Value) -> Result { } fn expand_steps(s: &mut Session, p: &Value) -> Result { - let object = bool_or(p, "object", true); - let fill = bool_or(p, "fill", true); - let stroke = bool_or(p, "stroke", true); + let object = switch_or(p, "object", true, C)?; + let fill = switch_or(p, "fill", true, C)?; + let stroke = switch_or(p, "stroke", true, C)?; let to = GradientTo::parse(p)?; let from = s.doc()?.history.undo.len(); - let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); - // Before anything expands: outlining the art a dimension measures would remove the dimension. + // Before anything expands: outlining the art a dimension measures would leave the dimension unlinked. let before = s.doc()?.doc.clone(); + let selected = s.doc()?.selection.objects.clone(); + // A dimension selected with the art it measures keeps its own lines and label: it is out of the + // document while the art expands, so no step below reaches it. + let roots = selected_roots(s)?; + let mut lifted = vec![]; + if !super::dimension::with_their_art(&before, &roots).is_empty() { + lifted = s.edit("Expand", |d, _| Ok(super::dimension::lift(d, &roots)))?; + } + let kept: Vec = lifted.iter().map(super::dimension::Lifted::id).collect(); + let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); let mut ended = vec![]; end_dimension_links(s, &mut ended)?; let rev0 = s.doc()?.revision; @@ -339,14 +352,25 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { if s.doc()?.revision == rev0 { return Err(EngineError::Other("Expand: nothing to expand".into())); } - if !ended.is_empty() { - // A dimension whose source Expand replaced stays plain: its link no longer fits that art. - let after = s.doc()?.doc.clone(); - ended.retain(|e| e.source_kept(&before, &after)); - s.edit("Expand", |d, _| { - super::dimension::restore_unchanged(d, std::mem::take(&mut ended)); + if !ended.is_empty() || !lifted.is_empty() { + s.edit("Expand", |d, sel| { + if !lifted.is_empty() { + super::dimension::put_back(d, lifted); + // The dimensions put back follow what Expand made of the art they measure: a + // dimension whose art no longer fits its link stays where it was, no longer linked. + super::dimension::refresh(&before, d)?; + let mut ids = sel.objects.clone(); + let again: Vec = kept.iter().copied().filter(|id| selected.contains(id) && !ids.contains(id)).collect(); + ids.extend(again); + sel.set(ids); + } + // A dimension whose source Expand replaced stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); Ok(()) })?; + if !kept.is_empty() && s.doc()?.doc.layers == before.layers { + return Err(EngineError::Other("Expand: nothing to expand".into())); + } } squash(s, from, "Expand"); Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::>() })) @@ -510,7 +534,7 @@ fn expand_piece_leaves( } return; } - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand); } } diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index caa08cfda..74d9185ea 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -1018,6 +1018,65 @@ mod tests { } } + /// Source file names that differ only in case (`a.svg` and `sub/A.svg`) get Export for Screens + /// folders of their own: names are compared without case, so the second folder is numbered, + /// since Windows and macOS would make `a` and `A` one folder. + #[test] + fn batch_override_export_numbers_folders_that_differ_only_in_case() { + let src = tmp("screens-case-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "A.svg", "#0f0"); + let dest = tmp("screens-case-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["A-2", "a"], "{r}"); + for (folder, fill) in [("a", "#ff0000"), ("A-2", "#00ff00")] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap(); + assert!(svg.contains(fill), "{folder}: {svg}"); + } + } + + /// A source file with nothing left of its name without its extension (` .svg`) gets the folder + /// `file`, numbered before a source file named `file.svg` takes it. + #[test] + fn batch_override_export_names_the_folder_of_an_empty_name_file() { + let src = tmp("screens-empty-src"); + write_svg(&src, " .svg", "#f00"); + write_svg(&src, "file.svg", "#0f0"); + let dest = tmp("screens-empty-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["file", "file-2"], "{r}"); + for (folder, fill) in [("file", "#ff0000"), ("file-2", "#00ff00")] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap(); + assert!(svg.contains(fill), "{folder}: {svg}"); + } + } + /// A source file whose name without its extension ends in a period (`a..svg`) gets the folder /// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder /// name, so `a.` and `a` would be one folder there. diff --git a/crates/engine/src/cmd/fileio/affinity/document.rs b/crates/engine/src/cmd/fileio/affinity/document.rs index e87e9ac0f..9114e9a40 100644 --- a/crates/engine/src/cmd/fileio/affinity/document.rs +++ b/crates/engine/src/cmd/fileio/affinity/document.rs @@ -88,6 +88,7 @@ impl Builder { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }) .collect(); if self.doc.artboards.is_empty() { diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs index 8b6d4c4a8..262f688b1 100644 --- a/crates/engine/src/cmd/fileio/affinity/layout.rs +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -157,7 +157,8 @@ pub(super) fn align_lines(obj: &mut TextObject) { return; } let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; - let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + // Each line as wide as VectorCraft aligns it: without the tracking after its last character. + let w = lines.iter().map(|l| l.x1 - l.trailing - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); if !(w > 0.0 && w < 1e6) { return; } diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 60f8be329..dec69c9ee 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -92,6 +92,9 @@ fn run_steps( if id == "command.batch" { return Err(bad("command.batch", "batches cannot nest")); } + if id == "prefs.import" { + return Err(bad("command.batch", "`prefs.import` runs on its own, not as a batch step")); + } if s.active().is_some() { s.begin_interaction(label)?; } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 1108d3e2c..84188c313 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -373,6 +373,7 @@ pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> D show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }]; d } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 9bf7a311a..a197363b2 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -270,7 +270,9 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { let pt = im.xf.determinant().abs().sqrt(); let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI }; let area = (bounds.width() * bounds.height()).max(1.0); - let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()); + // No side longer than the renderer draws (rounded up to whole pixels below). + let longest = (f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0) / bounds.width().max(bounds.height()).max(1.0); + let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()).min(longest); if !(scale.is_finite() && scale > 0.0) { return Ok(None); } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fd40139cf..58fff3cad 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -43,10 +43,27 @@ impl Loaded { } self } + + /// The file in the units of Preferences › Units › General when it records none of its own: a + /// PDF, an EPS or `.ai` without ruler units, a raster image, a metafile, an Affinity file or + /// what an importer plug-in reads, as the reference app opens such files (#5820). A native + /// document (also one that an SVG, PDF, `.ai` or EPS file carries, `restored`), an SVG (the + /// units of its root's size, pixels when it gives none), a DXF drawing (the unit it was read + /// in) and a `.ai` or EPS with ruler units keep theirs. Only the units the document shows + /// change, not its art. + pub(crate) fn with_unit_prefs(mut self, prefs: &Prefs) -> Self { + let records_units = + self.restored || self.doc.units_from_file || matches!(self.format.id, "vectorcraft" | "template" | "svg" | "svgz" | "dxf"); + if !records_units { + self.doc.units = crate::units::pref_unit(&prefs.units_general); + } + self + } } /// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG /// (what browsers, PDF and SVG viewers show). +#[derive(Clone)] pub struct RasterImage { /// Content key for `Document::images` (identical files share one blob). pub key: String, @@ -289,10 +306,10 @@ pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: (doc, warnings) } -/// Does `doc` hold no art: only layers, with nothing in them? +/// Does `doc` hold no art: only layers, with nothing in them but ruler guides? fn draws_nothing(doc: &Document) -> bool { let mut art = false; - doc.walk(|n| art |= !n.is_layer()); + doc.walk(|n| art |= !n.is_layer() && !n.is_ruler_guide()); !art } @@ -490,7 +507,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // document keeps its text as saved), and every frame of a thread takes its first frame's // Optical Margin Alignment setting and writing direction. When that changes the direction of a // frame of a restored document, the story flows through that frame's thread again. Threaded - // frames keep their size: Auto Size is off for them. + // frames keep their size: Auto Size is off for them. The story is laid out in the document's + // own composite fonts. + let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() }; + let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts); if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { @@ -501,6 +521,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // paragraph's first-line indent and list marker. crate::cmd::threads::mark_continued(&mut doc); } + drop(lent); if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } @@ -610,7 +631,8 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// Make a loaded file the new active document; `links` is what became of its linked files, and /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { - let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs); + let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = + loaded.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing // fallbacks must ask before overwriting the richer source they could not fully read. let losses: Vec = warnings diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 2387088b3..365edbedb 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -95,6 +95,12 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "false", description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)", }, + 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", + }, FormatOption { name: "createLayers", ty: "boolean", @@ -122,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; 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), 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; 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", has_doc, export_pdf ), @@ -334,6 +340,8 @@ fn encode_carrying_at( let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?; let doc = &*filled; + // A page the file can't hold fails the export before the thumbnails and the flattening. + vectorcraft_pdf::check_page_sizes(doc, &opts).map_err(|e| pdf_error(cmd, e))?; if opts.settings.thumbnails { opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; } @@ -358,9 +366,9 @@ fn encode_carrying_at( if set.pdf13() { vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency"); } - let flat = flatten_for(cmd, doc, set, p)?; + let (flat, flattened) = flatten_for(cmd, doc, set, p)?; vectorcraft_doc::cancel::check()?; - if flat.is_some() { + if flattened { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); } @@ -377,11 +385,19 @@ pub const DEFAULT_FLATTENER: &str = "High Resolution"; /// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener` /// options over it; images with see-through pixels and colorized images, including those in /// pattern tiles, count as transparency. Rasterized areas are clipped to their regions, and -/// discarded overprints are dropped. `None` when nothing changes (or the file keeps transparency). -fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result> { +/// discarded overprints are dropped. Layers the pages leave out (non-printing ones, unless asked +/// for) are left out of the flattening, hidden. → the document to write (`None`: `doc` as it is) +/// and whether its transparency was flattened. +fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<(Option, bool)> { if !set.pdf13() { - return Ok(None); + return Ok((None, false)); } + let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) }; + let shown = printed.map(|layers| { + let mut d = doc.clone(); + d.layers = layers; + d + }); let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER); let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?; o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve; @@ -389,7 +405,10 @@ fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Resul // Without soft masks a rasterized area can't be a rectangle with see-through pixels around // its art: it is clipped to its regions (opaque inside them). o.clip_complex_regions = true; - flatten_document(doc, &o) + Ok(match flatten_document(shown.as_ref().unwrap_or(doc), &o)? { + Some(flat) => (Some(flat), true), + None => (shown, false), + }) } /// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side: diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 6cc93ede1..9f4e5b057 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1051,7 +1051,7 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c523284ba..6236d281e 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -914,3 +914,34 @@ fn read_path_for_a_named_importer_stops_at_the_limit() { assert!(e.to_string().contains("larger than") && e.to_string().contains("drawing.tuv"), "{e}"); let _ = std::fs::remove_dir_all(&dir); } + +/// #3084, #2096: Export for Screens writes JPGs a strict reader opens, in every colour model and at +/// the qualities and scales the reports used. A four-component (CMYK) file has no JFIF segment, +/// which describes only one- and three-component images (#3140); RGB and gray files keep it. +#[test] +fn export_for_screens_jpgs_open_in_every_colour_model() { + let mut s = session(100.0, 80.0, 1); + s.execute("paint.setFill", &json!({"color": "#cc3300"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap(); + for (model, components) in [("rgb", 3), ("cmyk", 4), ("gray", 1)] { + for quality in [100, 80, 50] { + let r = s + .execute( + "document.exportForScreens", + &json!({"formats": [{"format": "jpg"}, {"format": "jpg", "scale": 2}], "settings": {"jpg": {"colorModel": model, "quality": quality}}}), + ) + .unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 2, "{model} {quality}: {r}"); + for (f, width) in files.iter().zip([100, 200]) { + let bytes = b64(f); + let what = format!("{model} q{quality} {}", f["name"]); + assert_eq!(bytes.get(..2), Some(&[0xFF, 0xD8][..]), "{what}: starts with SOI"); + let jfif = bytes.get(2..4) == Some(&[0xFF, 0xE0][..]); + assert_eq!(jfif, components != 4, "{what}: JFIF only on one- and three-component files"); + let image = image::load_from_memory(&bytes).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(image.width(), width, "{what}"); + } + } + } +} diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index af918d33e..8e2c1d429 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -170,6 +170,28 @@ fn transparency_is_flattened_with_the_flattener_preset() { assert!(e.contains("preset"), "{e}"); } +/// A picture cropped by a clipping path, in a clip group at 80%, over a square that only the +/// hidden part of the picture covers: the file prints the square, and its image is as large as +/// the crop, with the default preset (a rectangle of image) and with High Resolution (an image +/// clipped to its regions). +#[test] +fn flattening_cropped_art_keeps_the_art_under_its_hidden_part() { + use super::tests_pdfx::{Art, CROP, cropped, drawn, lost, shown_image_sizes}; + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + for preset in ["medium", "high"] { + let (mut s, _) = cropped("rgb", 400.0, Art::Image { see_through: false }); + s.execute("transparency.set", &json!({"opacity": 80})).unwrap(); + let canvas = drawn(&s.doc().unwrap().doc); + let opts = json!({"artboards": [0], "flattenerPreset": preset, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.exportEps", &opts).unwrap(); + // What the file prints, not the document it carries. + let back = vectorcraft_eps::import_with(&b64(&r), false).unwrap().document; + assert_eq!(lost(&canvas, &drawn(&back)), 0, "{preset}: art went missing"); + let sizes = shown_image_sizes(&back); + assert!(!sizes.is_empty() && sizes.iter().all(|(w, h)| *w <= CROP[2] + 1.0 && *h <= CROP[3] + 1.0), "{preset}: the images show {sizes:?}"); + } +} + #[test] fn overprints_are_kept_or_discarded() { let mut s = session(100.0, 100.0, 1); diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 4a6f67143..2970238e3 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -187,6 +187,26 @@ fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() { assert_eq!(size(&s), (20_000.0, 10_000.0)); } +/// A page that a PDF 1.3 file can't hold (a side past 32,767 pt that isn't a whole number of +/// points) fails the export before the thumbnails, the editing data and the flattening: the editing data is never asked for, and the flattener options, which the +/// flattening would refuse, are never read. +#[test] +fn a_page_too_long_for_pdf_1_3_fails_before_the_flattening() { + let doc = vectorcraft_doc::Document::new(40_000.5, 300.0); + let p = json!({"compatibility": "1.3", "thumbnails": true, "preserveEditing": true, "flattener": {"balance": 500}}); + let asked = std::cell::Cell::new(false); + let native = || { + asked.set(true); + Ok(vec![]) + }; + let e = super::pdf::encode_carrying("document.exportPdf", &doc, &p, native, || Ok(None)).unwrap_err(); + assert!( + e.to_string().contains("PDF 1.3 files hold real numbers up to 32,767, and the page of artboard 1, 40001 pt long, needs larger ones"), + "{e}" + ); + assert!(!asked.get(), "the editing data is not made"); +} + #[test] fn ait_templates_open_their_document_untitled() { let mut s = rich(); diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index abd1bbe4a..68df66bd9 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -186,3 +186,516 @@ fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() { let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels"); assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}"); } + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// The art a clip group crops in [`cropped`]. +#[derive(Clone, Copy)] +pub(super) enum Art { + /// A 240 × 160 image, with see-through pixels or without. + Image { see_through: bool }, + /// A rectangle with a gradient fill. + Gradient, + /// A rectangle with a drop shadow. + Shadow, +} + +/// Where [`cropped`] puts the cropped art, [x, y, width, height]. +const FRAME: [f64; 4] = [40.0, 40.0, 240.0, 160.0]; +/// The part of [`FRAME`] the clipping path of [`cropped`] shows. +pub(super) const CROP: [f64; 4] = [50.0, 50.0, 80.0, 60.0]; + +/// A `mode` document whose first artboard is `width` wide, with a dark square and, over it, `art` +/// filling [`FRAME`] in a clip group that shows [`CROP`] of it. The square lies inside the frame +/// and outside the crop: only the hidden part of the art covers it. → the session with the clip +/// group selected, and the group's id. +pub(super) fn cropped(mode: &str, width: f64, art: Art) -> (Session, u64) { + cropped_by(mode, width, art, false) +} + +/// [`cropped`], with the art and its clipping path in a clipping layer above the square's layer +/// when `layer`. → the session and the clip group's or the layer's id. +fn cropped_by(mode: &str, width: f64, art: Art, layer: bool) -> (Session, u64) { + let [x, y, w, h] = FRAME; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": width, "height": 300, "colorMode": mode})); + run(&mut s, "shape.rectangle", json!({"x": 180, "y": 90, "width": 60, "height": 60})); + run(&mut s, "paint.setFill", json!({"color": "#203040"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let layer = layer.then(|| run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); + let art = match art { + Art::Image { see_through } => { + let mut png = vec![]; + image::RgbaImage::from_fn(240, 160, |x, y| { + image::Rgba([200, (x % 256) as u8, (y % 256) as u8, if see_through && x < 60 { 128 } else { 255 }]) + }) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let placed = + run(&mut s, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME})); + placed["ids"][0].clone() + } + Art::Gradient | Art::Shadow => { + let id = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + run(&mut s, "paint.setStroke", json!({"none": true})); + if matches!(art, Art::Gradient) { + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}})); + } else { + run(&mut s, "paint.setFill", json!({"color": "#c04020"})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]})); + } + id + } + }; + let [x, y, w, h] = CROP; + let clip = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + if let Some(layer) = layer { + run(&mut s, "layer.clippingMask.toggle", json!({"id": layer})); + run(&mut s, "select.set", json!({"ids": [art]})); + return (s, layer); + } + run(&mut s, "select.set", json!({"ids": [art, clip]})); + let group = run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap(); + (s, group) +} + +/// `document.exportPdf` with `p`, read back. +fn read_back(s: &mut Session, p: &Value) -> vectorcraft_doc::Document { + let v = run(s, "document.exportPdf", p.clone()); + vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap() +} + +/// `doc`'s artboards drawn on white at 72 ppi. +pub(super) fn drawn(doc: &vectorcraft_doc::Document) -> vectorcraft_render::Rendered { + let pages = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap(); + vectorcraft_render::Renderer::new().render_region(doc, pages, 1.0, true) +} + +/// How many pixels show art in `reference` and are blank in `variant`. +pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcraft_render::Rendered) -> usize { + assert_eq!((reference.width, reference.height), (variant.width, variant.height)); + let (reference, variant) = (reference.pixels.as_chunks::<4>().0, variant.pixels.as_chunks::<4>().0); + reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count() +} + +/// How many pixels of `a` and `b` differ by more than 24 in a channel. +fn differ(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> usize { + assert_eq!((a.width, a.height), (b.width, b.height)); + let (a, b) = (a.pixels.as_chunks::<4>().0, b.pixels.as_chunks::<4>().0); + a.iter().zip(b).filter(|(p, q)| p[..3].iter().zip(&q[..3]).any(|(x, y)| x.abs_diff(*y) > 24)).count() +} + +/// The width and height of what each of `doc`'s images shows: its frame inside the clipping +/// paths around it. +pub(super) fn shown_image_sizes(doc: &vectorcraft_doc::Document) -> Vec<(f64, f64)> { + use vectorcraft_geom::Rect; + fn visit(n: &vectorcraft_doc::Node, clip: Option, out: &mut Vec<(f64, f64)>) { + let within = |r: Rect| clip.map_or(r, |c| c.intersect(r)); + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + let frame = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)); + let shown = within(frame); + out.push((shown.width(), shown.height())); + } + let clip = if n.clips() { n.visual_bounds().map(within) } else { clip }; + for c in n.children().into_iter().flatten() { + visit(c, clip, out); + } + } + let mut out = vec![]; + for l in &doc.layers { + visit(l, None, &mut out); + } + out +} + +/// Checks that the PDF/X-1a, PDF/X-3 and PDF 1.3 files of `s` (with `extra` options) show all the +/// art the file with transparency shows, and that no image in them shows more than [`CROP`]. Adds +/// what is wrong to `wrong`. +fn check_flat_files(s: &mut Session, extra: &Value, what: &str, wrong: &mut Vec) { + let reference = drawn(&read_back(s, extra)); + for flat in [json!({"standard": "pdfX1a"}), json!({"standard": "pdfX3"}), json!({"compatibility": "1.3", "preserveEditing": false})] { + let mut p = flat.clone(); + super::pdf::merge(&mut p, extra); + let back = read_back(s, &p); + let lost = lost(&reference, &drawn(&back)); + if lost > 0 { + wrong.push(format!("{what}, {flat}: {lost} pixels of art went missing")); + } + let sizes = shown_image_sizes(&back); + // An image reaches a pixel past its regions, and a group rasterized whole a point past + // its visible bounds. + if sizes.is_empty() || sizes.iter().any(|(w, h)| *w > CROP[2] + 3.0 || *h > CROP[3] + 3.0) { + wrong.push(format!("{what}, {flat}: the images show {sizes:?}")); + } + } +} + +/// Art cropped by a clipping path, in a PDF file without transparency: the part of the art the +/// clipping path hides is not rasterized, so art that lies under the hidden part alone still +/// shows. Each case is one way the cropped art comes to be flattened. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let opaque = Art::Image { see_through: false }; + type Change = fn(&mut Session, u64); + let cases: [(&str, Art, Change); 9] = [ + ("an image with see-through pixels", Art::Image { see_through: true }, |_, _| {}), + ("a clip group with opacity", opaque, |s, _| { + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("a clip group with a blend mode", opaque, |s, _| { + run(s, "transparency.set", json!({"blendMode": "multiply"})); + }), + ("a see-through object over the crop", opaque, |s, _| { + run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 40, "height": 30})); + run(s, "paint.setFill", json!({"color": "#ffffff"})); + run(s, "transparency.set", json!({"opacity": 60})); + }), + ("a gradient", Art::Gradient, |s, _| { + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("a raster effect", Art::Shadow, |_, _| {}), + ("a clip group inside a clip group", Art::Image { see_through: true }, |s, group| { + let outer = run(s, "shape.rectangle", json!({"x": 45, "y": 45, "width": 85, "height": 65}))["id"].clone(); + run(s, "select.set", json!({"ids": [group, outer]})); + run(s, "object.clippingMask.make", json!({})); + }), + ("a clip group inside a group with opacity", opaque, |s, _| { + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("an even-odd compound clipping path", opaque, |s, group| { + run(s, "transparency.set", json!({"opacity": 80})); + let doc = s.doc_mut().unwrap(); + let mut d = (*doc.doc).clone(); + let clip = d.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id; + let mut ring = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 50.0, 130.0, 110.0)); + ring.subpaths.extend(vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(70.0, 65.0, 110.0, 95.0)).subpaths); + if let vectorcraft_doc::NodeKind::Path { path, rule, .. } = &mut d.node_mut(clip).unwrap().kind { + (*path, *rule) = (ring, vectorcraft_geom::FillRule::EvenOdd); + } + doc.doc = d.into(); + }), + ]; + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for (what, art, change) in cases { + let (mut s, group) = cropped(mode, 400.0, art); + change(&mut s, group); + check_flat_files(&mut s, &json!({}), &format!("{mode}: {what}"), &mut wrong); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// The same with the Medium and Low Resolution presets, and with a balance of 0, which rasterizes +/// the whole group of the cropped art over the group's visible bounds. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part_with_each_preset() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for extra in [json!({"flattenerPreset": "medium"}), json!({"flattenerPreset": "low"}), json!({"flattener": {"balance": 0}})] { + for (art, name) in [(Art::Image { see_through: true }, "image"), (Art::Gradient, "gradient")] { + let (mut s, _) = cropped("cmyk", 400.0, art); + run(&mut s, "transparency.set", json!({"opacity": 80})); + check_flat_files(&mut s, &extra, &format!("{name}, {extra}"), &mut wrong); + } + } + // A cropped rectangle with a solid fill, rasterized whole. + let (mut s, group) = cropped("cmyk", 400.0, Art::Gradient); + let art = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[1].id.0; + run(&mut s, "paint.setFill", json!({"ids": [art], "color": "#c04020"})); + run(&mut s, "transparency.set", json!({"ids": [group], "opacity": 80})); + check_flat_files(&mut s, &json!({"flattener": {"balance": 0}}), "solid fill, balance 0", &mut wrong); + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// The hidden part of cropped art on one artboard lies over the next artboard: that page keeps +/// its art. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_next_page_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + // The crop is on the first artboard, and the square on the second. + let (mut s, _) = cropped("cmyk", 150.0, Art::Image { see_through: true }); + run(&mut s, "artboard.new", json!({"x": 170, "y": 0, "width": 230, "height": 300})); + assert_eq!(read_back(&mut s, &json!({"standard": "pdfX1a"})).artboards.len(), 2); + let mut wrong = vec![]; + check_flat_files(&mut s, &json!({}), "two artboards", &mut wrong); + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Art cropped by a clipping layer, over a square in the layer below: the square keeps showing, +/// also with a second square in that layer under both the crop and the hidden part, or with a +/// see-through rectangle over both in a layer above. +#[test] +fn flattening_art_of_a_clipping_layer_for_pdfx_keeps_the_art_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + type Change = fn(&mut Session, u64); + let cases: [(&str, Change); 3] = [ + ("alone", |_, _| {}), + ("over a square under the crop's edge", |s, _| { + let below = s.doc().unwrap().doc.layers[0].id.0; + run(s, "layer.setCurrent", json!({"id": below})); + run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30})); + run(s, "paint.setFill", json!({"color": "#406020"})); + }), + ("under a see-through rectangle over the crop's edge", |s, _| { + run(s, "layer.new", json!({})); + run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30})); + run(s, "paint.setFill", json!({"color": "#406020"})); + run(s, "transparency.set", json!({"opacity": 60})); + }), + ]; + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for art in [Art::Image { see_through: true }, Art::Gradient] { + for (what, change) in cases { + let (mut s, layer) = cropped_by(mode, 400.0, art, true); + run(&mut s, "transparency.set", json!({"opacity": 80})); + change(&mut s, layer); + check_flat_files(&mut s, &json!({}), &format!("{mode}: a clipping layer {what}"), &mut wrong); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Documents in which no clip group hides art export the same bytes as they did when the +/// flattener rasterized hidden art too. +#[test] +fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + use crate::cmd::flatten::with_clips_ignored as before; + // Written at one time, so two files differ only by what they draw. + let bytes = |s: &mut Session, p: &Value| super::pdf::encode_at("document.exportPdf", &s.doc().unwrap().doc, p, Some(1_700_000_000)).unwrap().0; + let files = [ + json!({"standard": "pdfX1a"}), + json!({"standard": "pdfX3"}), + json!({"compatibility": "1.3", "preserveEditing": false}), + json!({"standard": "pdfX1a", "flattener": {"balance": 0}}), + json!({"standard": "pdfX1a", "flattenerPreset": "low"}), + ]; + // Transparency without a clip group. + let mut plain = session(); + // A clip group at 80% whose clipping path is as large as its art, over the edge of a square. + let mut fitted = Session::new(); + run(&mut fitted, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"})); + run(&mut fitted, "shape.rectangle", json!({"x": 180, "y": 90, "width": 160, "height": 160})); + run(&mut fitted, "paint.setFill", json!({"color": "#203040"})); + let mut png = vec![]; + image::RgbaImage::from_fn(240, 160, |x, y| image::Rgba([200, x as u8, y as u8, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let photo = + run(&mut fitted, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}))["ids"][0] + .clone(); + let [x, y, w, h] = FRAME; + let clip = run(&mut fitted, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + run(&mut fitted, "select.set", json!({"ids": [photo, clip]})); + run(&mut fitted, "object.clippingMask.make", json!({})); + run(&mut fitted, "transparency.set", json!({"opacity": 80})); + // Cropped art that is not flattened: nothing on it is see-through. + let (mut opaque, _) = cropped("cmyk", 400.0, Art::Image { see_through: false }); + for (what, s) in [("no clip group", &mut plain), ("a clipping path as large as its art", &mut fitted), ("opaque cropped art", &mut opaque)] { + for p in &files { + assert!(bytes(s, p) == before(|| bytes(s, p)), "{what}, {p}"); + } + } + // Art that a clip group hides is what changes. + let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true }); + assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0]))); +} + +/// The options of the PDF files without transparency. +const FLAT_FILES: [&str; 3] = [r#"{"standard": "pdfX1a"}"#, r#"{"standard": "pdfX3"}"#, r#"{"compatibility": "1.3", "preserveEditing": false}"#]; + +/// [`cropped_by`]'s clipping layer of a gradient at 80%, with a rectangle in the layer below that +/// crosses the crop's right edge. → the session and the clipping layer's id. +fn clipping_layer_over_a_rectangle(mode: &str) -> (Session, u64) { + let (mut s, layer) = cropped_by(mode, 400.0, Art::Gradient, true); + run(&mut s, "transparency.set", json!({"opacity": 80})); + let below = s.doc().unwrap().doc.layers[0].id.0; + run(&mut s, "layer.setCurrent", json!({"id": below})); + run(&mut s, "shape.rectangle", json!({"x": 100, "y": 60, "width": 120, "height": 40})); + run(&mut s, "paint.setFill", json!({"color": "#406020"})); + (s, layer) +} + +/// A clipping layer over a layer that doesn't print, and a clipping layer that doesn't print +/// over one that does: the files without transparency print what the file with transparency +/// prints, and nothing of the layer that doesn't print. +#[test] +fn flattening_for_pdfx_leaves_out_the_layers_that_do_not_print() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for clipping_layer_prints in [true, false] { + let (mut s, layer) = clipping_layer_over_a_rectangle(mode); + let below = s.doc().unwrap().doc.layers[0].id.0; + let off = if clipping_layer_prints { below } else { layer }; + run(&mut s, "layer.setProps", json!({"id": off, "printable": false})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap())); + let (lost, gained) = (lost(&reference, &back), lost(&back, &reference)); + if lost + gained > 0 { + wrong.push(format!("{mode}, clipping layer prints: {clipping_layer_prints}, {flat}: {lost} pixels lost, {gained} gained")); + } + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// A clipping layer whose clipping path has a fill: the files without transparency show the art +/// over the fill, not the fill over the art. +#[test] +fn flattening_for_pdfx_keeps_the_art_of_a_clipping_layer_over_its_clipping_path_fill() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + let (mut s, layer) = clipping_layer_over_a_rectangle(mode); + let clip = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(layer)).unwrap().children().unwrap()[0].id.0; + run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#ffdd00"})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap())); + // PDF/X-1a converts an RGB document's colors to CMYK. + let converted = mode == "rgb" && flat.contains("pdfX1a"); + let (lost, differ) = (lost(&reference, &back), if converted { 0 } else { differ(&reference, &back) }); + if lost > 0 || differ > 100 { + wrong.push(format!("{mode}, {flat}: {lost} pixels of art lost, {differ} pixels differ")); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Transparent art in a clipping layer whose clipping path is area type holding only a space, +/// which hides it: the files without transparency are written, without the art. +#[test] +fn flattening_for_pdfx_removes_transparent_art_that_a_clipping_layer_hides_entirely() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let [x, y, w, h] = FRAME; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"})); + let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}})); + run(&mut s, "transparency.set", json!({"opacity": 80})); + run(&mut s, "text.create", json!({"x": 50, "y": 50, "text": " ", "area": {"width": 80, "height": 60}})); + run(&mut s, "layer.clippingMask.toggle", json!({"id": layer})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let p: Value = serde_json::from_str(flat).unwrap(); + let back = match s.execute("document.exportPdf", &p) { + Ok(v) => vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap(), + Err(e) => panic!("{flat}: {e}"), + }; + assert_eq!(lost(&drawn(&back), &reference), 0, "{flat}: the hidden art prints"); + } +} + +/// Object › Flatten Transparency of art in a clipping layer and art in the layer below that +/// reaches past the clipping path: when the layer below doesn't print, the clipping layer has +/// opacity, or the clipping path has a fill (see-through too, flattened keeping alpha, in a layer +/// with opacity or Multiply mode, or +/// over a layer with opacity, overlapping art of a layer with opacity or art an opacity mask +/// hides), the group stays in the clipping layer, the art below stays in its layer, and the page +/// looks the same. +#[test] +fn flattening_keeps_the_group_in_the_clipping_layer_when_the_layer_below_differs() { + let mut wrong = vec![]; + let cases = [ + "the layer below doesn't print", + "the clipping path has a fill", + "the clipping path has a see-through fill, keeping alpha", + "the clipping path has a see-through fill, in a layer with opacity", + "the clipping path has a see-through fill, in a layer in Multiply mode", + "the clipping path has a see-through fill, over a layer with opacity", + "the clipping path has a see-through fill, over art that an opacity mask hides", + "the clipping path has a see-through fill, over overlapping art of a layer at 50%", + "the clipping path has no fill, in a layer with opacity", + ]; + for case in cases { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "rgb"})); + let below = s.doc().unwrap().doc.layers[0].id.0; + let orange = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#ff8000"})); + let upper = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap(); + let blue = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#2040c0"})); + run(&mut s, "transparency.set", json!({"opacity": 50})); + let clip = run(&mut s, "shape.rectangle", json!({"x": 60, "y": 40, "width": 300, "height": 220}))["id"].clone(); + run(&mut s, "layer.clippingMask.toggle", json!({"id": upper})); + if case.starts_with("the layer below") { + run(&mut s, "layer.setProps", json!({"id": below, "printable": false})); + } else if !case.contains("no fill") { + run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#88ffcc"})); + } + let keeping_alpha = case.ends_with("keeping alpha"); + if case.contains("see-through fill") { + run(&mut s, "transparency.set", json!({"ids": [clip], "opacity": 50})); + } + if case.ends_with("in a layer with opacity") { + run(&mut s, "transparency.set", json!({"ids": [upper], "opacity": 80})); + } + if case.ends_with("over a layer with opacity") { + run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50})); + } + if case.ends_with("an opacity mask hides") { + run(&mut s, "layer.setCurrent", json!({"id": below})); + run(&mut s, "select.set", json!({"ids": [orange]})); + let group = run(&mut s, "object.group", json!({}))["id"].clone(); + let mask = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#000000"})); + run(&mut s, "select.set", json!({"ids": [group, mask]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + } + if case.ends_with("Multiply mode") { + run(&mut s, "transparency.set", json!({"ids": [upper], "blendMode": "multiply"})); + } + let mut art = vec![orange.clone()]; + if case.ends_with("a layer at 50%") { + run(&mut s, "layer.setCurrent", json!({"id": below})); + art.push(run(&mut s, "shape.rectangle", json!({"x": 80, "y": 60, "width": 120, "height": 120}))["id"].clone()); + run(&mut s, "paint.setFill", json!({"color": "#20a040"})); + run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50})); + } + art.push(blue.clone()); + // The page as it prints. + let printed = |d: &vectorcraft_doc::Document| { + let mut d = d.clone(); + if case.starts_with("the layer below") { + let mut layer = (*d.layers[0]).clone(); + layer.visible = false; + d.layers[0] = layer.into(); + } + drawn(&d) + }; + let before = printed(&s.doc().unwrap().doc); + run(&mut s, "select.set", json!({"ids": art})); + run(&mut s, "object.flattenTransparency", json!({"preserveAlpha": keeping_alpha})); + let d = &s.doc().unwrap().doc; + let layer_of = |id: &Value| id.as_u64().and_then(|id| d.ancestry(vectorcraft_doc::NodeId(id))).map(|a| a[0].0); + if layer_of(&orange) != Some(below) { + wrong.push(format!("{case}: the art below is in layer {:?}", layer_of(&orange))); + } + let upper_art = d.node(vectorcraft_doc::NodeId(upper)).and_then(|l| l.children()).map_or(0, Vec::len); + if d.node(vectorcraft_doc::NodeId(blue.as_u64().unwrap())).is_some() || upper_art != 2 { + wrong.push(format!("{case}: the clipping layer holds {upper_art} objects")); + } + let after = printed(d); + // Flat regions differ from see-through art along their antialiased edges. + let (lost, differ) = (lost(&before, &after), differ(&before, &after)); + if lost > 0 || differ > 1200 { + wrong.push(format!("{case}: {lost} pixels of art lost, {differ} pixels differ")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 2a12eb9c5..f950f8c1d 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -49,9 +49,9 @@ mod presets; mod report; pub(crate) use analysis::{outlined_text, see_through_images}; -#[cfg(test)] -pub(crate) use apply::with_every_region; pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; +#[cfg(test)] +pub(crate) use apply::{with_clips_ignored, with_every_region}; pub use commands::specs; pub use options::FlattenOptions; pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT}; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c70f4dca6..c1366dba3 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, outlined_text, overlapping, reach}; +use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ @@ -107,27 +107,83 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let art: Vec = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect(); let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); - let reaches: Vec> = plain.iter().map(reach).collect(); + // Each object as its clip groups and clipping layers show it. + let clipped: Vec = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n, None)).collect(); + let reaches: Vec> = plain.iter().zip(&clipped).map(|(n, c)| reach(n).zip(reach(c)).map(|(a, b)| a.intersect(b))).collect(); let see_through: Vec = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; 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()?; - for g in overlapping(&reaches)? { + let mut queue = overlapping(&reaches)?; + queue.reverse(); + while let Some(g) = queue.pop() { vectorcraft_doc::cancel::check()?; + // A group that stays inside the clip groups and clipping layers of its topmost object + // replaces only the objects inside them, over the others, which are flattened on their own. + let (g, under) = match inside_and_under(src, roots, &g) { + Some((inside, under)) => { + queue.push(under.clone()); + (inside, under) + } + None => (g, vec![]), + }; + // Composited over white, the group covers what it is drawn over, so it is composited over + // the fills of the clipping paths around it and the objects under it in the same kind of + // layer. Composited over nothing, it keeps its alpha and they show through. + let backdrop = !o.preserve_alpha; + let state = g.last().map(|&i| layer_state(src, roots[i])); + let top = g.last().map(|&i| roots[i]); + // A blend mode of a layer or group around the group alone would blend the art under it + // twice. + let blends = |i: usize| top.is_some_and(|t| own_layers(src, t, Some(roots[i])).iter().any(|a| a.blend != BlendMode::Normal)); + // Art under an opacity mask, effects or a knockout group is left out: the group can't draw it + // as those do. + let drawn_plainly = |i: usize| { + own_layers(src, roots[i], top).iter().all(|a| !masked(a) && !has_effects(a) && !matches!(a.knockout, vectorcraft_doc::Knockout::On)) + }; + let under_shown = under.iter().copied().filter(|&i| backdrop && Some(layer_state(src, roots[i])) == state && !blends(i) && drawn_plainly(i)); + let composited: Vec = under_shown.chain(g.iter().copied()).collect(); + let below = composited.len() - g.len(); + let extent = composited.iter().filter_map(|&i| reaches[i]).reduce(|a, b| a.union(b)).unwrap_or_default(); + let shown = |nodes: &[Node]| -> Vec { + let mut seen = vec![]; + let (mut under, mut out) = (vec![], vec![]); + for (k, &i) in composited.iter().enumerate() { + let n = clipped_by_ancestors(src, roots[i], &nodes[i], backdrop.then_some((&mut seen, extent))); + if k < below { + let groups = own_layers(src, roots[i], top).into_iter().filter(|a| !draws_as_is(a)).collect(); + under.push((groups, n)); + } else { + out.push(n); + } + } + let mut all = in_groups(under, 0); + all.extend(out); + all + }; let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let group = g.iter().any(|&i| see_through[i]).then(|| { - let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); - flatten_group(&plain, o, cmyk) + let plain = shown(&plain); + flatten_group(&plain.iter().collect::>(), o, cmyk) }); let Some((regions, area)) = group.flatten() else { - out.kept.extend(ids); + // Transparent art that its clip groups or clipping layers hide entirely draws nothing: + // it is removed. + let hides = |i: usize| see_through[i] && reach(&plain[i]).is_some() && reach(&clipped[i]).is_none(); + let (hidden, left): (Vec, Vec) = g.iter().partition(|&&i| hides(i)); + if !hidden.is_empty() { + let roots = hidden.iter().map(|&i| roots[i]).collect(); + out.flat.push(Flat { roots, regions: vec![], area: None, raster: None, lines: None }); + } + out.kept.extend(left.iter().map(|&i| roots[i])); continue; }; let raster = match area.as_ref().filter(|_| render) { Some(a) => { - let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); - Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?) + // Drawn as planned: the image can land outside the clip groups and clipping layers + // around its art. + Some(render_raster(&baked, &shown(&art), a.rect, a.ppi, a.clip.clone(), o)?) } None => None, }; @@ -140,6 +196,134 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: 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), +/// the fill of each clipping path not in them yet goes behind the art, inside the area, and the +/// path is added to them. +fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node, mut fills: Option<(&mut Vec, Rect)>) -> Node { + let mut out = plain.clone(); + if !clips_count() { + return out; + } + let ancestry = src.ancestry(id).unwrap_or_default(); + for pair in ancestry.windows(2).rev() { + let Some(above) = src.node(pair[0]).filter(|n| n.clips()) else { continue }; + let Some(clip) = above.children().and_then(|c| c.first()) else { continue }; + // The clipping path itself is not clipped art. + if clip.id == pair[1] { + continue; + } + // Its outline, unpainted: the group or layer around the object still paints the path. + // Nothing to clip by hides the clipped art, as in the compositor. + let Some((outline, rule)) = effects::clip_outline(clip) else { + out.visible = false; + return out; + }; + let mut path = Node::path(clip.id, PathData::from_bezpath(&outline), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut path.kind { + *r = rule; + } + let mut children = vec![Arc::new(path)]; + // The renderer paints the fill behind the clipped art. + if let Some((seen, r)) = fills.as_mut() + && !seen.contains(&clip.id) + && let Some(fill) = clip.clip_paint().fill + { + let r = *r; + seen.push(clip.id); + let mut at = Node::group(above.id, vec![Arc::new(Node::path(clip.id, shapes::rectangle(r), Appearance::default())), Arc::new(fill)]); + at.set_clips(true); + children.push(Arc::new(at)); + } + children.push(Arc::new(out)); + let mut g = Node::group(above.id, children); + g.set_clips(true); + out = g; + } + out +} + +/// Whether layer or group `n` draws what it holds as it is: opaque, in Normal mode, not isolated. +fn draws_as_is(n: &Node) -> bool { + n.opacity >= 1.0 && n.blend == BlendMode::Normal && !n.isolate +} + +/// `items` (in paint order, each an object and the layers and groups around it, outermost first, +/// that don't draw it as it is) with the objects of each such layer or group in one group with its +/// opacity, blend mode and isolation, from the `depth`th around them on: they then composite +/// together as that layer or group draws them. +fn in_groups(items: Vec<(Vec<&Node>, Node)>, depth: usize) -> Vec { + let mut out = vec![]; + let mut items = items.into_iter().peekable(); + while let Some((around, n)) = items.next() { + let Some(a) = around.get(depth).copied() else { + out.push(n); + continue; + }; + let mut run = vec![(around, n)]; + while let Some(next) = items.next_if(|(around, _)| around.get(depth).is_some_and(|b| b.id == a.id)) { + run.push(next); + } + let mut g = Node::group(a.id, in_groups(run, depth + 1).into_iter().map(Arc::new).collect()); + (g.opacity, g.blend, g.isolate) = (a.opacity, a.blend, a.isolate); + out.push(g); + } + out +} + +/// The layers and groups around object `id` of `src` that are not also around `other`, outermost +/// first. +fn own_layers(src: &Document, id: NodeId, other: Option) -> Vec<&Node> { + let mine = src.ancestry(id).unwrap_or_default(); + let theirs = other.and_then(|t| src.ancestry(t)).unwrap_or_default(); + let shared = mine.iter().zip(&theirs).take_while(|(a, b)| a == b).count(); + let own = mine.get(shared..mine.len().saturating_sub(1)).unwrap_or_default(); + own.iter().filter_map(|a| src.node(*a)).collect() +} + +/// The clip groups and clipping layers around object `id` of `d`, innermost first. +fn clip_ancestors(d: &Document, id: NodeId) -> Vec { + let mut a = d.ancestry(id).unwrap_or_default(); + a.pop(); + a.into_iter().rev().filter(|a| d.node(*a).is_some_and(Node::clips)).collect() +} + +/// Whether the layers around object `id` of `d` all print, all show, and whether one is a +/// template. +fn layer_state(d: &Document, id: NodeId) -> (bool, bool, bool) { + let mut a = d.ancestry(id).unwrap_or_default(); + a.pop(); + a.iter().filter_map(|a| d.node(*a)).fold((true, true, false), |(print, show, template), n| match &n.kind { + NodeKind::Layer { printable, template: t, .. } => (print && *printable, show && n.visible, template || *t), + _ => (print, show, template), + }) +} + +/// For group `g` (indices of `roots` of `src`, in paint order) whose flattened group can't leave +/// the clip groups and clipping layers of its topmost object ([`place_of`]): the objects inside +/// them and the objects under them. `None` when the group can leave them, or every object is +/// inside them. +fn inside_and_under(src: &Document, roots: &[NodeId], g: &[usize]) -> Option<(Vec, Vec)> { + if !clips_count() || g.len() < 2 { + return None; + } + let ids: Vec = g.iter().map(|&i| roots[i]).collect(); + let top = clip_ancestors(src, *ids.last()?); + if top.is_empty() { + return None; + } + let inside = |id: NodeId| { + let mine = clip_ancestors(src, id); + top.iter().all(|c| mine.contains(c)) + }; + if !inside(place_of(src, &ids)?) { + return None; + } + let (inside, under): (Vec, Vec) = g.iter().partition(|&&i| inside(roots[i])); + (!under.is_empty()).then_some((inside, under)) +} + /// The regions and the image area replacing one group of overlapping objects (`plain`: the art as /// composited; `cmyk`: in a CMYK document); `None` when it paints nothing. fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option)> { @@ -163,8 +347,10 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg let mut complex: Vec = vec![]; let mut gradients_only = !all; for (k, r) in regions.iter().enumerate() { - if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) { - gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient); + // Art outside the clipping path of its clip group draws nothing there. + let shown = |i: &&usize| b.kinds[**i] == Kind::Vector || !(clips_count() && b.hidden(**i, &r.sources)); + if all || r.sources.iter().filter(shown).any(|&i| b.kinds[i] != Kind::Vector) { + gradients_only &= r.sources.iter().filter(shown).all(|&i| b.kinds[i] == Kind::Gradient); complex.push(k); } else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) { vector.push((k, fill)); @@ -227,6 +413,35 @@ pub(crate) fn with_every_region(f: impl FnOnce() -> T) -> T { out } +#[cfg(test)] +thread_local! { + /// Groups flattened on this thread rasterize art where its clip group or clipping layer hides + /// it too ([`with_clips_ignored`]). + static CLIPS_IGNORED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether a region outside the clipping path of a clip group or clipping layer leaves out the +/// art it hides there. +#[cfg(test)] +fn clips_count() -> bool { + !CLIPS_IGNORED.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn clips_count() -> bool { + true +} + +/// `f` with every group it flattens rasterizing art where its clip group or clipping layer hides +/// it too. +#[cfg(test)] +pub(crate) fn with_clips_ignored(f: impl FnOnce() -> T) -> T { + CLIPS_IGNORED.with(|e| e.set(true)); + let out = f(); + CLIPS_IGNORED.with(|e| e.set(false)); + out +} + /// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where /// nothing paints it. fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { @@ -269,6 +484,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool 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 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)); @@ -403,7 +621,7 @@ pub(crate) fn flatten_expanded(doc: &Document, o: &FlattenOptions) -> Result>()); + // A sublayer that becomes flat art gives its ruler guides to the layer that held it. + let guides = held_guides(d, plan.flat.iter().flat_map(|f| f.roots.iter().copied())); for f in plan.flat { // A group always has objects. - let Some(&top) = f.roots.last() else { continue }; + let Some(top) = place_of(d, &f.roots) else { continue }; + // Art hidden entirely is removed. + if f.regions.is_empty() && f.area.is_none() { + if one_pass { + flat.extend(f.roots.iter().map(|r| (*r, vec![]))); + } else { + for r in &f.roots { + d.remove(*r)?; + } + } + continue; + } let mut children = vec![]; // The image goes under the regions, reaching a pixel past its own: their edges then fall on // matching pixels instead of on what is behind the group. @@ -474,6 +705,9 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan if !flat.is_empty() { d.insert_beside(flat, true)?; } + for (layer, guide) in guides { + d.insert(Some(layer), usize::MAX, guide)?; + } let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] }; for mut id in plan.kept { if o.text_to_outlines { @@ -494,6 +728,47 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan Ok((ids, vector, rasterized)) } +/// The object of `roots` (in paint order) whose place their flattened group takes: the topmost one +/// that no clip group or clipping layer clips unless it clips every object of `roots`, else the +/// topmost one. The group stays with the topmost object rather than move to a layer that prints, +/// shows or is a template where the topmost object's layer differs, out from a clip group or +/// clipping layer whose clipping path has a fill, which would then cover it, or out from a layer +/// or group with opacity, a blend mode or isolation. +fn place_of(d: &Document, roots: &[NodeId]) -> Option { + let top = roots.last().copied()?; + if !clips_count() || roots.len() < 2 { + return Some(top); + } + let fills = |clip: &NodeId| d.node(*clip).and_then(|n| n.children()?.first().map(|c| c.clip_paint().fill.is_some())).unwrap_or(false); + let all: Vec> = roots.iter().map(|r| clip_ancestors(d, *r)).collect(); + let above = all.last().map_or(&[][..], Vec::as_slice); + let shared = |mine: &Vec| mine.iter().all(|c| all.iter().all(|theirs| theirs.contains(c))); + let leaves_fill = |mine: &Vec| above.iter().any(|c| !mine.contains(c) && fills(c)); + // Out of a layer or group with opacity, a blend mode or isolation, the group would lose it. + let leaves_compositing = |r: NodeId| own_layers(d, top, Some(r)).iter().any(|a| !draws_as_is(a)); + let state = layer_state(d, top); + let place = + roots.iter().zip(&all).rev().find(|(r, mine)| shared(mine) && !leaves_fill(mine) && layer_state(d, **r) == state && !leaves_compositing(**r)); + Some(place.map_or(top, |(r, _)| *r)) +} + +/// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer +/// that holds it, in order. Other objects hold none. +fn held_guides(d: &Document, roots: impl IntoIterator) -> Vec<(NodeId, Node)> { + let mut out = vec![]; + for id in roots { + let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue }; + if n.holds_ruler_guides() { + n.walk(&mut |c| { + if c.is_ruler_guide() { + out.push((parent, c.clone())); + } + }); + } + } + out +} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); @@ -576,3 +851,30 @@ fn drop_overprints(n: &mut Node) { } } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + #[test] + fn a_flattened_sublayer_gives_its_ruler_guides_to_its_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = NodeId(s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()["id"].as_u64().unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("transparency.set", &json!({"ids": [r], "opacity": 50})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let g = d.insert(Some(sub), usize::MAX, g).unwrap(); + let flat = flatten_document(&s.doc().unwrap().doc, &FlattenOptions::default()).unwrap().unwrap(); + assert!(flat.node(sub).is_none(), "the sublayer became flat art"); + assert!(flat.node(g).is_some_and(Node::is_ruler_guide), "its guide is kept"); + 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)); + } +} diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index a2edde1f0..4053dcac7 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -10,7 +10,7 @@ pub fn specs() -> Vec { "Flatten Transparency…", ["Object"], None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach; an image larger than 64 Mpx or 32,768 px a side is made at a lower resolution), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_doc, flatten ), diff --git a/crates/engine/src/cmd/flatten/compose.rs b/crates/engine/src/cmd/flatten/compose.rs index 059890534..fef23f86a 100644 --- a/crates/engine/src/cmd/flatten/compose.rs +++ b/crates/engine/src/cmd/flatten/compose.rs @@ -53,6 +53,11 @@ enum Body { pub(super) struct Builder { pub(super) shapes: Vec, pub(super) kinds: Vec, + /// Per shape, the clipping path of the innermost clip group it is in (a clipping path's own + /// entry is the clip group around its group). + pub(super) clip_of: Vec>, + /// The clipping path of the clip group being built. + pub(super) clip: Option, /// Building a CMYK document's art. pub(super) cmyk: bool, } @@ -72,9 +77,24 @@ impl Builder { path.bounds()?; self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64)); self.kinds.push(kind); + self.clip_of.push(self.clip); Some(self.shapes.len() - 1) } + /// Whether a clip group hides shape `shape` in the region `sources` cover (ascending): the + /// region lies outside one of the clipping paths around the shape. + pub(super) fn hidden(&self, shape: usize, sources: &[usize]) -> bool { + let around = |shape: usize| self.clip_of.get(shape).copied().flatten(); + let mut clip = around(shape); + while let Some(c) = clip { + if sources.binary_search(&c).is_err() { + return true; + } + clip = around(c); + } + false + } + pub(super) fn elem(&mut self, n: &Node) -> Option { if !n.visible { return None; @@ -93,7 +113,11 @@ impl Builder { } else { (None, &children[..]) }; - Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() } + let outer = self.clip; + self.clip = clip.or(outer); + let children = rest.iter().filter_map(|c| self.elem(c)).collect(); + self.clip = outer; + Body::Group { clip, children } } NodeKind::Path { guide: true, .. } => return None, NodeKind::Path { .. } | NodeKind::Compound { .. } => { diff --git a/crates/engine/src/cmd/flatten/report.rs b/crates/engine/src/cmd/flatten/report.rs index 3cac1ffe8..41b08b7a0 100644 --- a/crates/engine/src/cmd/flatten/report.rs +++ b/crates/engine/src/cmd/flatten/report.rs @@ -12,7 +12,9 @@ pub(super) fn target_roots(s: &Session, p: &Value) -> Result> { let mut ids = vec![]; for id in targets(s, p)? { match d.node(id) { - Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)), + // Ruler guides are not art: they stay where they are. + Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).map(|c| c.id)), + Some(n) if n.is_ruler_guide() => {} Some(_) => ids.push(id), None => {} } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index c73a23d6c..c7926ddf8 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, 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|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", has_doc, edit_gradient ), @@ -80,19 +80,28 @@ fn select_stop(s: &mut Session, p: &Value) -> Result { } /// Whose gradient a selected stop belongs to: the active document, its first selected object -/// (None: the default paint), the proxy in front and the Appearance panel's active item. -/// Selecting other art, toggling the proxy or picking another item leaves no stop selected. +/// (None: the default paint), whether a part of a symbol instance's art is picked, the proxy in +/// front and the Appearance panel's active item. Selecting other art, picking a part of the +/// instance, toggling the proxy or picking another item leaves no stop selected. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct StopOwner { doc: Option, object: Option, + part: bool, fill: bool, item: Option, } impl StopOwner { pub(crate) fn of(s: &Session) -> Self { - Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() } + let st = s.active(); + Self { + doc: s.active, + object: st.and_then(|d| d.selection.objects.first().copied()), + part: st.is_some_and(|d| d.selection.symbol_part.is_some()), + fill: s.fill_active, + item: s.appearance_item(), + } } } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 8c838d421..f4b6c90c1 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -43,8 +43,8 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, - series_object_opacity_differs, shown_paint, + capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, + capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/data.rs b/crates/engine/src/cmd/graph/data.rs index 3e2122349..f4eb3ef83 100644 --- a/crates/engine/src/cmd/graph/data.rs +++ b/crates/engine/src/cmd/graph/data.rs @@ -50,6 +50,10 @@ pub(super) fn parse_csv(csv: &str) -> (Vec, Vec, Vec 1 { tabs } else { split_unquoted(l, ',') }; parts.into_iter().map(cell).collect() diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index d1a5ddde0..a789304b2 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -2,7 +2,8 @@ use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, rada use super::columns::{Allowance, ColumnArt, column_art, column_designs}; use super::data::fmt_value; use super::labels::{ - Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of, + Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_extra_height, label_shift, label_size, label_width, polyline, + recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::{series_marks, with_series_opacity}; @@ -901,18 +902,21 @@ impl<'a> Plot<'a> { fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc { let ls = self.ls; + let (dx, dy) = label_shift(&self.g.label_styles, label, self.b.d.setup.system_layout); + let (x, y) = (x + dx, y + dy); self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false) } /// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row /// just above it. fn legend_across_top(&mut self) -> Vec> { - let (g, r, ls) = (self.g, self.r, self.ls); + let (g, r, ls, system_layout) = (self.g, self.r, self.ls, self.b.d.setup.system_layout); let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect(); - let mut rows: Vec> = vec![vec![]]; + let mut rows: Vec> = vec![vec![]]; let mut used = 0.0; for (s, label) in entries { - let w = 12.0 + label_width(&g.label_styles, &label) + 12.0; + let w = 12.0 + label_width(&g.label_styles, &label, system_layout) + 12.0; + let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label, system_layout); // The gap after an entry may run past the plot's edge. if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) { rows.push(vec![]); @@ -920,10 +924,10 @@ impl<'a> Plot<'a> { } used += w; if let Some(row) = rows.last_mut() { - row.push((s, label, w)); + row.push((s, label, w, h)); } } - let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64; + let height = |row: &[(usize, String, f64, f64)]| row.iter().map(|(.., h)| *h).fold(ls * 1.8, f64::max); // Above the plot and anything drawn over it (radar and stacked-pie labels). let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); let top = self.bars_reach.map_or(top, |b| top.min(b.y0)); @@ -931,7 +935,7 @@ impl<'a> Plot<'a> { let mut labels = vec![]; for row in &rows { let mut x = r.x0; - for (s, label, w) in row { + for (s, label, w, _) in row { self.legend_swatch(*s, (x, y)); labels.push(self.legend_label((x, y), label)); x += w; @@ -954,7 +958,7 @@ impl<'a> Plot<'a> { y += ls * 1.8; continue; } - y += ls * 1.8 * label.split('|').count() as f64; + y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label, self.b.d.setup.system_layout); self.legend_swatch(s, (x, row_y)); labels.push(self.legend_label((x, row_y), &label)); } diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index fdd640a95..b11571425 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -10,7 +10,7 @@ use super::{ pub(super) struct Gen<'a> { pub(super) d: &'a mut Document, pub(super) out: Vec>, - /// The labels' kept character styles. + /// The labels' kept character styles and paragraph attributes. pub(super) styles: &'a vectorcraft_doc::LabelStyles, /// What the column designs may still add. pub(super) columns: Allowance, @@ -45,6 +45,34 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> st } +/// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the +/// alignment. +pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle { + let mut para = kept_para(styles, role).cloned().unwrap_or_default(); + // A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label). + para.keep_spacing_in_range(); + para +} + +/// The paragraph attributes labels of `role` keep, if any. +fn kept_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::ParaStyle> { + match role { + Role::Value => styles.values_para.as_ref(), + Role::Category => styles.categories_para.as_ref(), + Role::Legend => styles.legend_para.as_ref(), + } +} + +/// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp. +pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias { + let kept = match role { + Role::Value => styles.values_anti_alias, + Role::Category => styles.categories_anti_alias, + Role::Legend => styles.legend_anti_alias, + }; + kept.unwrap_or_default() +} + /// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { label_style(styles, role).size @@ -61,19 +89,39 @@ impl Gen<'_> { self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5) } pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { + let t = self.text_object(role, at, s, justify); + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + } + /// Label `s` of `role` set at `at` and laid out, not yet in the document. + fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject { let style = label_style(self.styles, role); let mut t = TextObject::point(at, s, style); + t.para = label_para(self.styles, role); + t.anti_alias = label_anti_alias(self.styles, role); t.para.justify = justify; t.system_layout = self.d.setup.system_layout; refresh_bounds(&mut t); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + t } /// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred /// on `at` (labels beside an axis or in a wedge) instead of running down from it. pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc { let lines = s.split('|').count(); - let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at }; - self.text(role, at, &s.replace('|', "\n"), justify) + // Default leading puts the first and last baselines this far above and below `at`. + let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0; + let start = if middle { Point::new(at.x, at.y - half) } else { at }; + let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify); + // Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An + // empty line has no baseline: an empty first or last line leaves the label where the default leading puts it. + if middle && kept_para(self.styles, role).is_some() { + let ys: Vec = t.baselines().map(|(a, _)| a.y).collect(); + let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty()); + let set = s.split('|').filter(|l| !l.is_empty()).count(); + if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) { + t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; + } + } + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) } pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); @@ -162,12 +210,47 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc { Arc::new(d.reid(&c)) } -/// How wide label `s` is set (its widest line), without adding it to a document. -pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { +/// The bounds of legend label `s` set from the origin with paragraph attributes `para` and the document's System +/// Layout setting `system_layout`, as [`Gen::text`] sets it, without adding it to a document. +fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle, system_layout: bool) -> Option { let style = label_style(styles, Role::Legend); let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style); + t.para = para; + t.para.justify = Justify::Left; + t.system_layout = system_layout; refresh_bounds(&mut t); - Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width()) + Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds() +} + +/// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right +/// by [`label_shift`], without adding it to a document. +pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 { + legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout) + .map_or(0.0, |b| (b.x1 + label_shift(styles, s, system_layout).0).max(0.0)) +} + +/// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones. +fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> Option<(Rect, Rect)> { + styles.legend_para.as_ref()?; + Some(( + legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)?, + legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default(), system_layout)?, + )) +} + +/// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the +/// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays +/// clear of its swatch and of the entries before it. +pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> (f64, f64) { + kept_and_default_bounds(styles, s, system_layout) + .map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0))) +} + +/// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more +/// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room +/// for it. +pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 { + kept_and_default_bounds(styles, s, system_layout).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0)) } /// A label with nothing to show (empty, or only `|` line breaks). diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 7d7d75a16..229a4d4b4 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,7 +1,7 @@ use vectorcraft_color::BlendMode; use super::axis::radar_axis; -use super::labels::{Role, filled_lines_on, label_style}; +use super::labels::{Role, filled_lines_on, label_para, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- @@ -406,23 +406,34 @@ fn graph_labels(doc: &Document) -> HashMap { out } -/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels -/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or -/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so -/// they undo together. Returns whether a kept style changed. -pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { - if ids.is_empty() { - return false; - } +/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as +/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed. +pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool { let labels = graph_labels(doc); - if labels.is_empty() { - return false; - } let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); kinds.dedup(); let mut changed = false; for (graph, role) in kinds { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let slot = match role { + Role::Value => &mut spec.label_styles.values_anti_alias, + Role::Category => &mut spec.label_styles.categories_anti_alias, + Role::Legend => &mut spec.label_styles.legend_anti_alias, + }; + changed |= *slot != Some(method); + *slot = Some(method); + } + changed +} + +/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels +/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or +/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so +/// they undo together. Returns whether a kept style changed. +pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { + let mut changed = false; + for (graph, role) in label_kinds(doc, ids) { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let mut style = label_style(&spec.label_styles, role); change(&mut style); @@ -437,6 +448,42 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: & changed } +/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels +/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character +/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes. +pub(crate) fn capture_label_paragraphs( + doc: &mut Document, + ids: &[NodeId], + change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>, +) -> Result { + let mut changed = false; + for (graph, role) in label_kinds(doc, ids) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let mut para = label_para(&spec.label_styles, role); + change(&mut para)?; + let slot = match role { + Role::Value => &mut spec.label_styles.values_para, + Role::Category => &mut spec.label_styles.categories_para, + Role::Legend => &mut spec.label_styles.legend_para, + }; + changed |= slot.as_ref() != Some(¶); + *slot = Some(para); + } + Ok(changed) +} + +/// The graphs and kinds of label among `ids`, each once. +fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { + if ids.is_empty() { + return vec![]; + } + let labels = graph_labels(doc); + let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); + kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); + kinds.dedup(); + kinds +} + /// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a /// selection made only of this graph's series (Group Selection). Empty: the whole graph. pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 650a4275a..efcd04320 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4055,6 +4055,45 @@ fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() { assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default()); } +/// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn +/// again, through save and open, and undoes with the change. +#[test] +fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() { + use vectorcraft_doc::text::TypeAntiAlias; + let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + out.push((t.plain_text(), t.anti_alias)); + } + } + } + out + }; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap(); + label_looks(&mut s, id); + let drawn = methods(&s, id); + let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap(); + assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}"); + assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}"); + // One undo takes the data change back, the next the method and the kept method together. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None); + s.execute("edit.redo", &json!({})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong)); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); +} + /// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it. #[test] fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() { @@ -4210,3 +4249,252 @@ fn character_formats_are_kept_and_bigger_labels_space_out() { fn csv_leading_byte_order_mark_is_ignored() { assert_eq!(super::parse_csv("\u{feff}1,2\n3,4"), super::parse_csv("1,2\n3,4")); } + +#[test] +fn csv_round_trip_keeps_blank_row_in_single_column_graph() { + let mut g = GraphSpec::default(); + g.set_cells(vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); + let (_, c, r) = super::parse_csv(&g.to_csv()); + assert!(c.is_empty()); + assert_eq!(r, g.cells()); +} + +/// Each generated label's text, paragraph attributes and alignment. +fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for name in ["Axes", "Legend"] { + for c in group(&n, name).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + out.push((t.plain_text(), t.para.clone())); + } + } + } + out +} + +#[test] +fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() { + use vectorcraft_doc::{Composer, ParaDirection, text::Kashida}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]})) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let paras = label_paras(&s, id); + assert!(!paras.is_empty()); + for (text, p) in ¶s { + assert_eq!(p.composer, Composer::SingleLine, "{text}"); + assert_eq!(p.kashida, Kashida::Long, "{text}"); + assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}"); + assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}"); + } + // The graph still aligns each label to its place. + assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::>(), aligned); + // One kind of label: only that kind keeps it, also through save and open. + let col = graph(&mut s, "column"); + let v = text_id(&s, col, "Axes", "4"); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let paras = label_paras(&s, col); + let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer; + assert_eq!(composer("0"), Composer::SingleLine, "every value axis number"); + assert_eq!(composer("Q1"), Composer::default(), "category labels as they were"); + assert_eq!(composer("2024"), Composer::default(), "legend labels as they were"); + // Part of a label's text: nothing kept. + let q = text_id(&s, col, "Axes", "Q2"); + s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); + s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap(); + s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default())); + // Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label. + let v = text_id(&s, col, "Axes", "4"); + s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); + s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap(); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err()); + let kept = spec(&s, col).label_styles.values_para.unwrap(); + assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]); +} + +#[test] +fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let l = text_id(&s, id, "Legend", "2024"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": 40})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap(); + let label = label.geometric_bounds().unwrap(); + let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}"); +} + +#[test] +fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let middle = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap(); + let b = label.geometric_bounds().unwrap(); + (b.y0 + b.y1) / 2.0 + }; + let before = middle(&s); + let l = text_id(&s, id, "Axes", "Q\n1"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let after = middle(&s); + assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}"); +} + +#[test] +fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let middle = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = + group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap(); + let b = label.geometric_bounds().unwrap(); + (b.y0 + b.y1) / 2.0 + }; + let before = middle(&s); + let l = text_id(&s, id, "Axes", "Q\n\n1"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let after = middle(&s); + assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}"); +} + +#[test] +fn a_legend_across_top_measures_its_labels_with_system_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + s.execute("type.systemLayout", &json!({"on": true})).unwrap(); + // Each `i` at 2 pt advances a whole point with System Layout, far more than its own width. + let name = "i".repeat(60); + let csv = format!(",{name},2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"); + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let l = text_id(&s, id, "Legend", &name); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 2})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": 1})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == name)).unwrap(); + let label = label.geometric_bounds().unwrap(); + let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(next.x0 >= label.x1, "the next swatch starts after the label set with System Layout: {label:?} {next:?}"); +} + +#[test] +fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() { + for across_top in [true, false] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": across_top})).unwrap(); + let l = text_id(&s, id, "Legend", "2025"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": -40})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = + group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, label) = (bounds("2024"), bounds("2025")); + let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}"); + if across_top { + assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}"); + } + } +} + +#[test] +fn a_legend_makes_room_for_the_kept_leading_of_its_labels() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let l = text_id(&s, id, "Legend", "20\n24"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, next) = (bounds("20\n24"), bounds("2025")); + assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}"); +} + +#[test] +fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let l = text_id(&s, id, "Legend", "20\n25"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, next) = (bounds("2024"), bounds("20\n25")); + assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}"); +} diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index bd6afd466..0c1d36d35 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Vector Halftone…", ["Object"], None, - "{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg clockwise (45; 0: level rows of dots; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_selection, halftone )] diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 25201db87..49b1015bf 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", + "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -155,7 +155,7 @@ pub fn specs() -> Vec { "Artboard Options…", ["Window", "Artboards"], None, - "{index | artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take, #rrggbbaa and #rgba refused: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center), showVideoSafeAreas?: bool (Show Video Safe Areas: the canvas draws the action-safe area 3.5% and the title-safe area 5% in from each edge, as EBU R 95 sets them; the three are screen-only, as the artboard's edges are: exports and print leave them out), videoRulerPixelAspectRatio?: number 0.1–10 (Video Ruler Pixel Aspect Ratio: the width of one video pixel over its height, which View › Rulers › Show Video Rulers counts the artboard in; default 1, square pixels), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}", + "{index | artboards: [{index, name?, color?, guideLabel?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take, #rrggbbaa and #rgba refused: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), guideLabel?: name | \"#rrggbb\" | null (the guide label: the canvas draws the artboard's own guides (guide.add with artboard) in this color instead of the guide color preference, selected ones still in the selection color; a layer color's name spelled exactly as Layer Options lists it (\"Light Blue\", \"Red\", …) or #rrggbb; null for none, which new artboards and older files have; screen-only, so exports and print leave it out; it can change on a locked artboard; an invalid value anywhere in the list changes nothing), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center), showVideoSafeAreas?: bool (Show Video Safe Areas: the canvas draws the action-safe area 3.5% and the title-safe area 5% in from each edge, as EBU R 95 sets them; the three are screen-only, as the artboard's edges are: exports and print leave them out), videoRulerPixelAspectRatio?: number 0.1–10 (Video Ruler Pixel Aspect Ratio: the width of one video pixel over its height, which View › Rulers › Show Video Rulers counts the artboard in; default 1, square pixels), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}; no undo step when nothing changes", has_doc, artboard_set ), @@ -243,7 +243,8 @@ pub(crate) fn accepts(d: &Document, parent: Option, n: &Node) -> bool { None => n.is_layer(), Some(p) => match d.node(p).map(|x| &x.kind) { Some(NodeKind::Layer { .. }) => true, - Some(NodeKind::Group { .. }) => !n.is_layer(), + // A ruler guide stays directly in a layer or sublayer. + Some(NodeKind::Group { .. }) => !n.is_layer() && !n.is_ruler_guide(), _ => false, }, } @@ -478,8 +479,12 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result { super::object::release_clip(d, id); return Ok(false); } - let top = - n.children().and_then(|c| c.last()).map(|c| c.id).ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; + // The topmost object: a layer's ruler guides above its art do not clip. + let top = n + .children() + .and_then(|c| c.iter().rev().find(|c| !c.is_ruler_guide())) + .map(|c| c.id) + .ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; super::object::make_clipping_path(d, top)?; d.move_node(top, Some(id), 0)?; if let Some(n) = d.node_mut(id) { @@ -556,9 +561,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("a document needs at least one layer".into())); } s.edit("Delete Selection", |d, sel| { - d.remove_many(&ids)?; // The guides on the deleted layers go with them. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&ids)?; sel.prune(d); Ok(()) })?; @@ -577,10 +581,10 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.duplicate", "no such rows")); } let copies = s.edit("Duplicate Selection", |d, _| { - super::edit::copy_above(d, &ids, |d, n, c| { + // A copied layer's ruler guides are copied with it. + super::edit::copy_above(d, &ids, |_, n, c| { if n.is_layer() { c.name = Some(format!("{} copy", n.display_name())); - d.copy_layer_guides(n, c); } }) })?; @@ -706,6 +710,9 @@ fn select_art(s: &mut Session, p: &Value) -> Result { vec![] } else if n.is_layer() { n.layer_art(true) + } else if n.is_ruler_guide() { + // A ruler guide row selects the guide through `guide.select`, not as art. + vec![] } else { vec![id] }; @@ -784,11 +791,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let mut nodes = vec![]; for id in &ids { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - let c = d.reid(&n); - if n.is_layer() { - d.copy_layer_guides(&n, &c); - } - nodes.push(Arc::new(c)); + // A copied layer's ruler guides are copied with it. + nodes.push(Arc::new(d.reid(&n))); } nodes } else { @@ -810,6 +814,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(parent)?; let index = index.min(ch.len()); ch.splice(index..index, nodes); + // A layer's ruler guides stay above its art, each in the order it now has. + ch.sort_by_key(|c| c.is_ruler_guide()); // Copied type whose layout caches `reid` dropped has them again. if copy { super::typecmd::refresh_bounds_of(d, &out); @@ -859,6 +865,22 @@ fn artboard_color(p: &Value, cmd: &str) -> Result Result>> { + match p.get("guideLabel") { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(v) => v + .as_str() + .and_then(LayerColor::from_label_text) + .map(|c| Some(Some(c))) + .ok_or_else(|| bad(cmd, "`guideLabel` is null, a layer color's name or #rrggbb")), + } +} + /// An artboard command's text param `key`: None when absent or null, else the string. Any other /// value, such as 5, is an error: "`{key}` must be a string". fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result> { @@ -898,6 +920,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1); let color = artboard_color(p, "artboard.new")?.flatten(); + let guide_label = artboard_guide_label(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; let r = match artboard_side(p, "artboard.new")? { @@ -932,6 +955,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { show_video_safe_areas, video_ruler_pixel_aspect_ratio, color, + guide_label, }); Ok(d.artboards.len() - 1) })?; @@ -954,7 +978,7 @@ fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indi for &i in indices.iter().rev() { let id = d.artboards.remove(i).id; d.retire_artboard_id(id); - d.retain_guides(sel, |_, g| g.artboard != Some(id)); + d.retain_guides(sel, |g| g.artboard != Some(id)); } Ok(()) } @@ -1001,6 +1025,7 @@ struct ArtboardEdit<'a> { now: Rect, name: Option<&'a str>, color: Option>, + guide_label: Option>, lock: Option, center_mark: Option, cross_hairs: Option, @@ -1013,6 +1038,23 @@ struct ArtboardEdit<'a> { art: Vec, } +impl ArtboardEdit<'_> { + /// Does the edit change artboard `a` (the one at its index) or the art and guides on it? + fn changes(&self, a: &Artboard) -> bool { + let name = self.name.map(|n| if n.is_empty() { format!("Artboard {}", self.index + 1) } else { n.to_string() }); + self.now != a.rect + || self.transform.is_some() + || name.is_some_and(|n| n != a.name) + || self.color.is_some_and(|c| c != a.color) + || self.guide_label.is_some_and(|c| c != a.guide_label) + || self.lock.is_some_and(|l| l != a.locked) + || self.center_mark.is_some_and(|m| m != a.show_center_mark) + || self.cross_hairs.is_some_and(|c| c != a.show_cross_hairs) + || self.safe_areas.is_some_and(|v| v != a.show_video_safe_areas) + || self.pixel_aspect.is_some_and(|r| r != a.video_ruler_pixel_aspect_ratio) + } +} + fn artboard_set(s: &mut Session, p: &Value) -> Result { // One artboard (`index` and its fields), or several (`artboards`, each with its own fields). let entries: Vec<&Value> = match p.get("artboards") { @@ -1044,6 +1086,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; // The background color: a color sets it, null clears it, absent leaves it as it is. let color = artboard_color(q, "artboard.setProps")?; + // The guide label likewise; a locked artboard's can change, as its name can. + let guide_label = artboard_guide_label(q, "artboard.setProps")?; let now = rect_from(q, was); let lock = q.get("locked").and_then(Value::as_bool); let (center_mark, cross_hairs) = (q.get("showCenterMark").and_then(Value::as_bool), q.get("showCrossHairs").and_then(Value::as_bool)); @@ -1070,6 +1114,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { now, name: artboard_str(q, "name", "artboard.setProps")?.map(str::trim), color, + guide_label, lock, center_mark, cross_hairs, @@ -1082,6 +1127,10 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { art, }); } + // Nothing to change: no undo step. + if !edits.iter().any(|e| d.artboards.get(e.index).is_none_or(|a| e.changes(a))) { + return if scale_art { Ok(json!({ "scaled": [] })) } else { ok() }; + } // Noted in the journal when art goes along, so a replay transforms the same objects whatever // the preference is then. if edits.iter().any(|e| e.transform.is_some()) { @@ -1102,6 +1151,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { if let Some(c) = e.color { a.color = c; } + if let Some(c) = e.guide_label { + a.guide_label = c; + } if let Some(l) = e.lock { a.locked = l; } diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 0a81a947e..95c152e95 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -255,9 +255,9 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { let mut all = std::mem::take(ch); all.extend(taken); all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id))); + // Ruler guides (the merged layers' among them) stay above the art, each kind in that order. + all.sort_by_key(|c| c.is_ruler_guide()); *ch = all; - // The merged layers' guides go to it too. - d.rehome_guides(into); Ok(()) } @@ -318,9 +318,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let others: Vec = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect(); let hidden: Vec = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect(); s.edit("Flatten Artwork", |d, sel| { - d.remove_many(&hidden)?; // Hidden layers are discarded with their guides. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&hidden)?; merge_into(d, into, &others)?; sel.prune(d); sel.deselect_uneditable(d); @@ -349,8 +348,8 @@ fn release(s: &mut Session, p: &Value) -> Result { NodeKind::Group { .. } => true, _ => return Err(bad(C, "release a layer or a group")), }; - // Its objects, bottom first (sublayers stay as they are). - let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect(); + // Its objects, bottom first (sublayers and ruler guides stay as they are). + let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer() && !c.is_ruler_guide()).map(|c| c.id).collect(); if objects.is_empty() { return Err(EngineError::Other("there are no objects to release".into())); } @@ -386,7 +385,8 @@ fn release(s: &mut Session, p: &Value) -> Result { } let new: Vec = layers.iter().map(|l| l.id).collect(); let ch = d.children_mut(Some(parent))?; - let at = index.min(ch.len()); + // Below the parent's ruler guides. + let at = index.min(Node::guide_run_start(ch)); ch.splice(at..at, layers); // Copied type whose layout caches `reid` dropped has them again. if build { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 48e2d1793..a6d2cbf51 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1303,7 +1303,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result { let choices = SwatchChoices::default(); // The pattern's name in the document, worked out on a copy: nothing to add is no edit. let mut probe = (*s.doc()?.doc).clone(); - let imported = clip.import_into(&mut probe, &choices, false); + let imported = clip.import_into(&mut probe, &choices, false)?; let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name.as_str()).and_then(|r| r["to"].as_str()); let target = renamed.unwrap_or(&name).to_string(); let paint = vectorcraft_doc::pattern::pattern_paint(&target); @@ -1315,7 +1315,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result { in_one_step(s, |s| { if added { s.edit("Add to Swatches", |d, _| { - clip.import_into(d, &choices, false); + clip.import_into(d, &choices, false)?; if !has_swatch(d) { let swatch = d.free_swatch_name(&target); d.swatches.push(vectorcraft_color::Swatch { name: swatch, paint: paint.clone(), global: false, spot: false }); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 22d61723e..020b483f3 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -521,10 +521,25 @@ fn linked_image(bytes: &[u8]) -> Result { /// The file read for a link: the link to it and its image. type FileRead = (LinkInfo, RasterImage); -/// The file at `f` read for a link like `base` (its image decoded). -fn read_image(f: &mut Found, base: &LinkInfo) -> Result { +/// The images read in one operation, by the path of the file and its content hash: groups whose +/// image keys differ but whose file is the same share one decoded image (and its encoded bytes) +/// instead of each keeping a copy. +type ImageReads = BTreeMap; + +/// The file at `f` read for a link like `base` (its image decoded, or the one `reads` holds for +/// the same file content). +fn read_image(f: &mut Found, base: &LinkInfo, reads: &mut ImageReads) -> Result { let bytes = f.read()?; - Ok((f.link(&bytes, base), linked_image(&bytes)?)) + let hash = hash_bytes(&bytes); + let img = match reads.get(&f.path) { + Some((h, img)) if *h == hash => img.clone(), + _ => { + let img = linked_image(&bytes)?; + reads.insert(f.path.clone(), (hash.clone(), img.clone())); + img + } + }; + Ok((f.link_hashed(hash, base), img)) } /// A linked file read for the objects that show it. @@ -536,8 +551,8 @@ enum Read { } /// The file at `f` read for group `g`: its image, or the artboard or page `g` shows. -fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { - let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) }; +fn read_for(f: &mut Found, g: &Group, cmd: &str, reads: &mut ImageReads) -> Result { + let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link, reads).map(Read::Image) }; let bytes = f.read()?; let page = g.link.page.unwrap_or(1); let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?; @@ -922,6 +937,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps // (the path as it was, the content shown, a placed document?, the link now) of each file found // away from its path or read again. let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; + let mut image_reads = ImageReads::new(); for g in groups(d, None) { let row = rows(std::iter::once(&g), base.doc); let shows_preview = match g.document { @@ -934,7 +950,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { let force = unread.then_some(Preserve::Bounds); - match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) { + match read_for(&mut f, &g, "document.open", &mut image_reads).and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) { Ok(()) if unread => {} Ok(()) => out.updated.extend(row), Err(_) => out.missing.extend(row), @@ -943,7 +959,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps (Status::Modified, _) => out.modified.extend(row), // A placed document keeps its file's bytes: only lost ones are read again. (Status::Ok, Some(mut f)) if g.document.is_some() => { - let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None }; + let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open", &mut image_reads)) } else { None }; match read { Some(Ok(read)) => { if install_read(d, &g.ids, read, Some(Preserve::Bounds), Some(&g)).is_err() { @@ -1196,6 +1212,7 @@ fn update(s: &mut Session, p: &Value) -> Result { fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; let (mut reads, mut missing) = (vec![], vec![]); + let mut image_reads = ImageReads::new(); let asked = ids.is_some() || path.is_some(); for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) { match probe(&g, LinkBase::of(st)) { @@ -1203,7 +1220,7 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - // Unchanged and showing the file: nothing to read unless asked for. (Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {} // Unreadable as an image (or a document) counts as missing. - (_, Some(mut f)) => match read_for(&mut f, &g, "links.update") { + (_, Some(mut f)) => match read_for(&mut f, &g, "links.update", &mut image_reads) { Ok(read) => reads.push((g, read)), Err(_) => missing.extend(ids_json(&g.ids)), }, @@ -1706,13 +1723,14 @@ fn embed(s: &mut Session, p: &Value) -> Result { let ids: Vec = ids.into_iter().filter(|id| matches!(kind(id), Some(NodeKind::Image(_)))).collect(); // Images showing their preview take their file's pixels first. let (mut done, mut reads) = (vec![], vec![]); + let mut image_reads = ImageReads::new(); for g in groups(&st.doc, Some(&ids)) { if !previewing(&st.doc, &g.key) { done.extend(g.ids); continue; } match probe(&g, LinkBase::of(st)) { - (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) { + (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link, &mut image_reads) { Ok(read) => { done.extend(&g.ids); reads.push((g.ids, read)); @@ -1921,3 +1939,26 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { set(&mut shown); Ok(json!({ "ids": ids_json(&ids), "placement": placement_json(shown) })) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::tests::links::{Folder, RED, png, write}; + + #[test] + fn groups_of_one_file_share_one_decoded_image() { + let dir = Folder::new("shared-read"); + let path = dir.file("pic.png"); + write(&path, &png(40, 20, RED)); + let (mut reads, base) = (ImageReads::new(), LinkInfo::new(path.clone())); + let mut read = || read_image(&mut found_at(path.clone()).unwrap(), &base, &mut reads).unwrap().1; + let (a, b) = (read(), read()); + assert!(Arc::ptr_eq(&a.blob.bytes, &b.blob.bytes), "one retained copy of the bytes"); + // A changed file is decoded again. + write(&path, &png(40, 20, [1, 2, 3])); + let c = read(); + assert!(!Arc::ptr_eq(&a.blob.bytes, &c.blob.bytes)); + } +} diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 87a7af357..70c2d9f34 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -49,10 +49,7 @@ pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result { match n.appearance.fill_paint() { // The points take the colours the gradient paints there. Paint::Gradient(g) => { - path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| { - m.highlight(app, highlight); - m - }) + path.bounds().and_then(|b| GradientMesh::for_path_shaded(&path, rows, cols, &*gradient_sampler(&g, b, model), app, highlight)) } p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight), } diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index f2d322a39..debd0db68 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -259,10 +259,12 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { /// mode when the object or the layer is gone (e.g. deleted). pub(crate) fn sync(d: &mut Document) { let Some(me) = d.mask_edit else { return }; - let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the mask. + let art: Option>> = + d.node(me.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else { d.mask_edit = None; - let _ = d.remove(me.layer); + let _ = d.remove_edit_layer(me.layer); return; }; let new_art = match art.as_slice() { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index c41e9ce29..8ea016db6 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -670,7 +670,11 @@ fn render_pixels( opts: &vectorcraft_render::RenderOptions, color: RasterColorModel, ) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> { - if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 { + // No side longer than the renderer draws: it would cut the image off there. The sides are + // measured in the renderer's whole pixels. + let side = vectorcraft_render::MAX_RASTER_SIDE; + let (w, h) = vectorcraft_render::region_pixels(region, scale); + if region.width() * region.height() * scale * scale > MAX_PIXELS || w > side || h > side { return Err(EngineError::Other("image would be too large; lower the resolution".into())); } let mut r = vectorcraft_render::Renderer::new(); @@ -953,6 +957,40 @@ fn polygon_path(pts: &[Point]) -> PathData { PathData::single(vectorcraft_geom::SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true)) } +/// The part of an image under `r` (in document space) copied out of its pixels, as PNG bytes, with its size and +/// placement. `None` when the image can't be cut that way (turned or skewed, CMYK, pixels that can't be read, or +/// `r` not on the pixel grid), so the caller draws it instead. +fn crop_pixels(doc: &vectorcraft_doc::Document, im: &ImageObject, r: Rect) -> Option<(Vec, u32, u32, Affine)> { + let blob = doc.images.get(&im.key)?; + let [a, b, c, d, _, _] = im.xf.as_coeffs(); + if blob.is_cmyk() || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-9 || d.abs() < 1e-9 { + return None; + } + let inv = im.xf.inverse(); + let (p0, p1) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); + // Only a rect on the pixel grid is cut from the pixels; one between pixels keeps its exact bounds by being drawn. + // Checked before decoding, so an off-grid crop of a large image doesn't decode it for nothing. + let on_grid = |v: f64| (v - v.round()).abs() < 1e-3; + if ![p0.x, p0.y, p1.x, p1.y].into_iter().all(on_grid) { + return None; + } + let px = decode_rgba("object.cropImage", blob).ok()?; + let (w, h) = px.dimensions(); + if w == 0 || h == 0 || (w, h) != (im.width, im.height) { + return None; + } + let lo = |v: f64, max: u32| v.round().clamp(0.0, f64::from(max)) as u32; + let (x0, x1) = (lo(p0.x.min(p1.x), w), lo(p0.x.max(p1.x), w)); + let (y0, y1) = (lo(p0.y.min(p1.y), h), lo(p0.y.max(p1.y), h)); + if x1 <= x0 || y1 <= y0 { + return None; + } + let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image(); + let mut png = Vec::new(); + cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some((png, x1 - x0, y1 - y0, im.xf * Affine::translate((f64::from(x0), f64::from(y0))))) +} + fn crop_image(s: &mut Session, p: &Value) -> Result { const C: &str = "object.cropImage"; let st = s.doc()?; @@ -993,6 +1031,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { if (r.x0 - ib.x0).abs() < 1e-6 && (r.y0 - ib.y0).abs() < 1e-6 && (r.x1 - ib.x1).abs() < 1e-6 && (r.y1 - ib.y1).abs() < 1e-6 { return Err(bad(C, "nothing to crop")); } + // A plain, axis-aligned raster is cut straight from its pixels, so nothing is resampled. + if let Some((png, cw, ch, xf)) = crop_pixels(&st.doc, im, r) { + put_pixels(s, id, png, cw, ch, xf)?; + return Ok(json!({ "id": id.0, "width": cw, "height": ch })); + } // Keep the image's pixel density. let scale = (im.width as f64 / ib.width().max(1e-9)).max(im.height as f64 / ib.height().max(1e-9)); let mut bare = node.clone(); @@ -1027,6 +1070,27 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The image's pixels as RGBA, held to the allowance opening a raster has. +fn decode_rgba(c: &str, blob: &vectorcraft_doc::ImageBlob) -> Result { + // Print rasters run to gigabytes decoded: the same allowance opening them has. + let reader = || { + image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .map_err(|_| bad(c, "the image's pixels can't be read")) + }; + // The image's size as RGBA, read from its header, is held to that allowance before anything + // is decoded (#1717). + let (w, h) = reader()?.into_dimensions().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?; + if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 { + return Err(bad(c, format!("an image of {w} × {h} pixels is too large to draw"))); + } + let mut reader = reader()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); + reader.limits(limits); + Ok(reader.decode().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?.to_rgba8()) +} + /// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the /// pixels copied as they are (no resampling) and placed where they were. fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { @@ -1037,23 +1101,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { if blob.is_cmyk() { return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false })); } - // Print rasters run to gigabytes decoded: the same allowance opening them has. - let reader = || { - image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) - .with_guessed_format() - .map_err(|_| bad(C, "the image's pixels can't be read")) - }; - // The image's size as RGBA, read from its header, is held to that allowance before anything - // is decoded (#1717). - let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; - if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 { - return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); - } - let mut reader = reader()?; - let mut limits = image::Limits::default(); - limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); - reader.limits(limits); - let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8(); + let px = decode_rgba(C, blob)?; let (w, h) = px.dimensions(); let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); for (x, y, p) in px.enumerate_pixels() { @@ -1072,23 +1120,22 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { let mut png = Vec::new(); cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?; let (cw, ch) = (x1 - x0, y1 - y0); + put_pixels(s, id, png, cw, ch, im.xf * Affine::translate((f64::from(x0), f64::from(y0))))?; + Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true })) +} + +/// Crop Image's edit for a cut made from the image's own pixels: `png` (`w` × `h` px) takes the +/// image's place, mapped into the document by `xf`. +fn put_pixels(s: &mut Session, id: NodeId, png: Vec, w: u32, h: u32, xf: Affine) -> Result<()> { s.edit("Crop Image", |d, _| { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(Box::new(ImageObject { - key, - width: cw, - height: ch, - xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), - link: None, - placement: Default::default(), - pixels: None, - })); + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })); } Ok(()) })?; - Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true })) + Ok(()) } /// The Control bar's Mask for an image: a clip group of the image and a clipping path on its @@ -1276,6 +1323,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); converted.push(id); } diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index d8f0681cc..20dc187ab 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -344,6 +344,7 @@ impl DocSettings { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }) .collect(); d diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index b32275d1b..c3a289857 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -102,15 +102,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], m: Measure) -> Vec<(NodeId, Rect)> ids.iter().filter_map(|id| Some((*id, m.of(d, *id)?))).collect() } -/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position) +/// Move each object of `moves` by its offset and each ruler guide of `guides` (id, position) /// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says /// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document /// and its undo history stay as they are. -fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result { +fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(NodeId, f64)]) -> Result { let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() }; - let guides: Vec<(usize, f64)> = { + let guides: Vec<(NodeId, f64)> = { let d = &s.doc()?.doc; - guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() + guides.iter().copied().filter(|&(i, pos)| d.ruler_guide(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() }; if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() { return ok(); @@ -124,9 +124,7 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & super::pixelgrid::keep_compound_aligned(d, *id, sc); } for &(i, pos) in &guides { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); } Ok(()) })?; @@ -143,7 +141,7 @@ fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { /// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical. fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> { let st = s.doc().ok()?; - let g = st.doc.guides.get(st.selection.key_guide?)?; + let g = st.doc.ruler_guide(st.selection.key_guide?)?; (align_to(s, p) == Some("key")).then_some((g.pos, g.vertical)) } @@ -164,7 +162,7 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], m: Measure, own: Opti /// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to /// Selection when no objects are selected. fn selected_guides_bounds(st: &crate::DocState) -> Option { - st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { + st.selection.guides.iter().filter_map(|&i| st.doc.ruler_guide(i)).fold(None, |acc: Option, g| { let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; Some(acc.map(|a| a.union(r)).unwrap_or(r)) }) @@ -264,7 +262,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result { /// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. /// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are /// selected; with objects in the selection they stay put. -fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { +fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { let st = s.doc()?; let to = align_to(s, p); let move_guides = match to { @@ -290,7 +288,7 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> .guides .iter() .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) - .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) + .filter_map(|&i| Some((i, at(st.doc.ruler_guide(i)?.vertical)?))) .collect()) } @@ -361,7 +359,7 @@ pub(super) fn can_distribute(s: &Session) -> std::result::Result<(), String> { #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Spaced { Object(NodeId), - Guide(usize), + Guide(NodeId), } /// The selected objects `ids` with their bounds (measured by `m`), and the selected @@ -371,7 +369,7 @@ fn spaced_items(s: &Session, ids: &[NodeId], m: Measure, horiz: bool) -> Result< let st = s.doc()?; let objects = items_bounds(&st.doc, ids, m).into_iter().map(|(id, r)| (Spaced::Object(id), r)); let guides = st.selection.guides.iter().filter_map(|&i| { - let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; + let g = st.doc.ruler_guide(i).filter(|g| g.vertical == horiz)?; Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) }); Ok(objects.chain(guides).collect()) @@ -404,7 +402,7 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: for &(it, delta) in deltas { match it { Spaced::Object(id) => moves.push((id, along(delta))), - Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), + Spaced::Guide(i) => guides.extend(d.ruler_guide(i).map(|g| (i, g.pos + delta))), } } apply_moves(s, label, p, ids, &moves, &guides) diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 7aef60fca..ff5b950d0 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -39,7 +39,9 @@ pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result { group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor)); } for (k, id) in group.iter().enumerate() { - let Some((_, idx, len)) = d.position(*id) else { continue }; + let Some((_, idx, _)) = d.position(*id) else { continue }; + // The parent's art, without the ruler guides that stay above it. + let len = d.children(par).map_or(0, |ch| Node::guide_run_start(ch)); if len <= floor { continue; } @@ -73,12 +75,16 @@ pub(super) fn send_to_current_layer(s: &mut Session, _: &Value) -> Result } // When the selected objects are already the topmost in the current layer, in paint order, the // document and its undo history stay as they are. - if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { + // (The layer's ruler guides stay above its art.) + let art = |c: &&std::sync::Arc| !c.is_ruler_guide(); + if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().filter(art).map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { return ok(); } s.edit("Send to Current Layer", |d, _| { let moved = d.remove_many(&ids)?; - d.children_mut(Some(layer))?.extend(moved); + let ch = d.children_mut(Some(layer))?; + let at = Node::guide_run_start(ch); + ch.splice(at..at, moved); Ok(()) })?; ok() @@ -176,7 +182,7 @@ fn selected_lock_hide_roots(s: &Session) -> Result> { fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { let mut v = vec![]; d.walk(|n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); @@ -198,7 +204,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> if let Some(node) = d.node(root) { for child in node.children().into_iter().flatten() { child.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { ids.push(n.id); } }); @@ -222,7 +228,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> } for c in d.node(*g).and_then(Node::children).into_iter().flatten() { c.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 2f0aa742d..eb019a88e 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Shear…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), angle: deg (positive turns the vertical edges clockwise with the horizontal axis, the top to the right; the horizontal edges clockwise with the vertical axis, the right side down), axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, shear ), @@ -297,7 +297,7 @@ pub fn specs() -> Vec { "Live Shape Properties", [], None, - "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", + "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counter-clockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counter-clockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", has_selection, set_live_shape ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 618486097..d10206894 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -145,16 +145,14 @@ fn apply_transform_moving_guides( } None if copy => { let guide_copies = if with_guides { - let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); (!copies.is_empty()).then_some(copies) } else { None }; let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; if let Some(copies) = guide_copies { - let first = d.guides.len(); - d.guides.extend(copies); - sel.guides = (first..d.guides.len()).collect(); + sel.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } new_ids } @@ -177,9 +175,7 @@ fn apply_transform_moving_guides( } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { - if let Some(g) = d.guides.get_mut(i) { - *g = g.moved(delta); - } + d.edit_ruler_guide(i, |g| *g = g.moved(delta)); } } Ok(targets) @@ -224,10 +220,8 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?; if has_guide_copies { let d = Arc::make_mut(&mut doc); - let first = d.guides.len(); - let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); - d.guides.extend(copies); - selection.guides = (first..d.guides.len()).collect(); + let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); + selection.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids }); it.copies = Some(k.clone()); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 9d81576b0..75d5852ea 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -14,7 +14,7 @@ use vectorcraft_doc::{Document, Knockout, Node, NodeId, NodeKind, OpacityMask, S use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing}; use super::pathops::{Beside, separate}; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, ids_param, ok, targets}; +use super::{CommandSpec, Result, Session, always, bad, cmd, has_doc, ids_param, ok, switch_or, targets}; use crate::EngineError; pub fn specs() -> Vec { @@ -246,8 +246,8 @@ fn make(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, why)); } - let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped); - let invert = bool_or(p, "invert", s.menu.new_masks_inverted); + let clip = switch_or(p, "clip", !s.menu.new_masks_unclipped, C)?; + let invert = switch_or(p, "invert", s.menu.new_masks_inverted, C)?; // Several objects: the top one is the mask. One: an empty mask, drawn in mask editing. let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) }; // Several objects go into a new group, and a layer can't: the group takes a layer's art. @@ -330,8 +330,13 @@ fn release(s: &mut Session, p: &Value) -> Result { /// Set the mask flags given in `p` on the masked objects among `target`'s targets. fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result { - let get = |k: &str| p.get(k).and_then(Value::as_bool); - let (clip, invert, disabled, linked) = (get("clip"), get("invert"), get("disabled"), get("linked")); + let get = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(_) => switch_or(p, k, false, "transparency.setOpacityMask").map(Some), + } + }; + let (clip, invert, disabled, linked) = (get("clip")?, get("invert")?, get("disabled")?, get("linked")?); if clip.is_none() && invert.is_none() && disabled.is_none() && linked.is_none() { return Err(bad("transparency.setOpacityMask", "give at least one of clip, invert, disabled, linked")); } @@ -363,7 +368,7 @@ fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result< } fn toggle_new_clip(s: &mut Session, p: &Value) -> Result { - let v = bool_or(p, "value", s.menu.new_masks_unclipped); + let v = switch_or(p, "value", s.menu.new_masks_unclipped, "transparency.toggleNewMasksClipping")?; s.menu.new_masks_unclipped = !v; Ok(json!({ "value": v })) } @@ -376,7 +381,7 @@ fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result { } let d = &s.doc()?.doc; let cur = if knockout { d.page_knockout } else { d.page_isolate }; - let v = bool_or(p, "value", !cur); + let v = switch_or(p, "value", !cur, if knockout { "transparency.togglePageKnockoutGroup" } else { "transparency.togglePageIsolatedBlending" })?; if v != cur { s.edit(if knockout { "Page Knockout Group" } else { "Page Isolated Blending" }, |d, _| { *flag(d, knockout) = v; @@ -387,7 +392,7 @@ fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result { } fn toggle_new_invert(s: &mut Session, p: &Value) -> Result { - let v = bool_or(p, "value", !s.menu.new_masks_inverted); + let v = switch_or(p, "value", !s.menu.new_masks_inverted, "transparency.toggleNewMasksInverted")?; s.menu.new_masks_inverted = v; Ok(json!({ "value": v })) } diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 7906a68e8..1e8d9ced6 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -214,7 +214,7 @@ fn attributes(s: &Session, p: &Value) -> Result { /// Overprint Black on `n` and its descendants; returns the objects changed. fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) -> usize { let mut changed = usize::from(op.apply(n, spots)); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { changed += overprint_black_tree(Arc::make_mut(c), op, spots); } changed diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 4cc4bbc2e..b68378ba9 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, 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|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)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -242,6 +242,10 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool { /// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and /// other containers inside them), a compound path for its parts. pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { + // A ruler guide has no paints. + if n.is_ruler_guide() { + return; + } let expand = match n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } => true, NodeKind::Compound { .. } => false, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 04b1ce685..b8bdd24d0 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -352,7 +352,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result Result { } }; s.edit("Character", |d, _| { + // The paragraph change, for the graph labels to keep. + let mut para_change = None; + let out_of_order_error = |name: &str| bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")); for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; @@ -794,7 +797,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy = (local(tcy_up_down)?, local(tcy_left_right)?); super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale, tcy)); let span = super::typecmd::para_span(range, t); - t.edit_paras(span, |para| { + let paras = |para: &mut vectorcraft_doc::ParaStyle, out_of_order: &mut Option<&'static str>| { if let Some(v) = num("leftIndent") { para.left_indent = v.clamp(-MAX_PARA, MAX_PARA); } @@ -882,17 +885,27 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = composer { para.composer = v; } - }); + }; + t.edit_paras(span, |para| paras(para, &mut out_of_order)); if let Some(name) = out_of_order { - return Err(bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum"))); + return Err(out_of_order_error(name)); } super::typecmd::refresh_bounds(t); + para_change = Some(paras); } // Graph labels keep a change made to all of their text through the graph being drawn again. if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + if let Some(paras) = para_change { + // The kept attributes are checked too: they may differ from the labels'. + super::graph::capture_label_paragraphs(d, &ids, &|para| { + let mut out_of_order = None; + paras(para, &mut out_of_order); + out_of_order.map_or(Ok(()), |name| Err(out_of_order_error(name))) + })?; + } } if let Some(v) = anti_alias { for id in &ids { @@ -900,6 +913,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { t.anti_alias = v; } } + // Graph labels keep the method through the graph being drawn again. + super::graph::capture_label_anti_alias(d, &ids, v); } Ok(()) })?; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index c9f51e065..4b8ae5d2b 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -51,7 +51,7 @@ pub struct ParamKeys<'a> { /// The text between the braces of the object `doc` starts with and what follows it, or why it /// has none. -fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { +pub(crate) fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { // (Named, not written in the `Err`: these are for the tests of the descriptions, not messages // the app shows, which the message catalogs would have to translate.) let (no_object, not_closed) = ("the description doesn't start with the params object `{…}`", "the params object `{…}` isn't closed"); @@ -78,7 +78,7 @@ fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { } /// `s` split at each `sep` that is outside brackets, quotes and backticks. -fn split_top(s: &str, sep: char) -> Vec<&str> { +pub(crate) fn split_top(s: &str, sep: char) -> Vec<&str> { let (mut out, mut depth, mut quote, mut start) = (Vec::new(), 0usize, None, 0); for (i, c) in s.char_indices() { match (quote, c) { @@ -98,7 +98,7 @@ fn split_top(s: &str, sep: char) -> Vec<&str> { out } -fn is_key(s: &str) -> bool { +pub(crate) fn is_key(s: &str) -> bool { let mut chars = s.chars(); chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 5d7f93581..712ca8a17 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -4,7 +4,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling, Selection}; use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; use super::create::anchor_from_json; @@ -131,11 +131,19 @@ pub fn specs() -> Vec { "Reshape", [], None, - "{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)", + "{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together. Without focal (or with focal null) and without radius, the earlier rule applies: the selection does not matter, an open path stretches from the grabbed point to its two ends, and on a closed path the anchors follow with the falloff over half the diagonal of the subpath's bounding box; the Reshape tool always sends focal", has_doc, reshape ), - cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor), + cmd!( + "path.insertAnchor", + "Add Anchor Point", + [], + None, + "{id, subpath?, segment, t: 0..1} add an anchor at t on the segment → {anchor}; the direct-selected anchors and selected segments keep the same points (a selected segment it splits stays selected as its two halves), and a path that isn't selected becomes the selection with the new anchor direct-selected", + has_doc, + insert_anchor + ), cmd!( "path.cutAtAnchors", "Cut Path at Selected Anchor Points", @@ -281,9 +289,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } if is_translation && (delta.x != 0.0 || delta.y != 0.0) { for &i in &sel.guides { - if let Some(g) = doc.guides.get_mut(i) { - *g = g.moved(delta); - } + doc.edit_ruler_guide(i, |g| *g = g.moved(delta)); } } Ok(()) @@ -1224,7 +1230,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { s.edit("Clear", |d, selection| { if with_guides { let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); } let targets: BTreeSet = whole.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect(); let whole_ids: Vec = targets @@ -1232,6 +1238,12 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .copied() .filter(|id| !d.ancestry(*id).unwrap_or_default().iter().rev().skip(1).any(|ancestor| targets.contains(ancestor))) .collect(); + // The compound paths that hold a deleted object; one left with no members goes too. + let compounds: BTreeSet = whole_ids + .iter() + .chain(sel.keys()) + .filter_map(|id| d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })))) + .collect(); if !whole_ids.is_empty() { d.remove_many(&whole_ids)?; } @@ -1280,6 +1292,11 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { } } emptied.finish(d)?; + for p in compounds { + if d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty())) { + d.remove(p)?; + } + } selection.set(survivors); Ok(()) })?; @@ -1399,6 +1416,145 @@ fn open_run(mut anchors: Vec) -> Option { Some(SubPath::new(anchors, false)) } +/// What an anchor does in a Reshape drag. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Stretch { + /// Not selected: it stays in place. + Fixed, + /// Selected: it takes a share of the drag that depends on where it lies between the anchors + /// that stay and the focal points. + Selected, + /// A focal point (the grabbed anchor, or one the Reshape tool highlighted): it moves by the + /// whole drag. + Focal, +} + +/// The share of a Reshape drag that each anchor of a subpath takes, as `[in handle, anchor, out +/// handle]`, from what each anchor does (`roles`, one per anchor). A focal point takes all of the +/// drag and an anchor that stays takes none. An open path's end anchors stay unless they are focal +/// points. A selected anchor between two focal points takes all of the drag, and one between two +/// anchors that stay takes none. For a selected anchor between an anchor that stays and a focal +/// point, `u` is its distance from the anchor that stays along the control polygon (anchor, out +/// handle, the next in handle, the next anchor) divided by the focal point's distance from it, and +/// its share is `u² (3 - 2u)`. A handle takes the share on the tangent line of that curve at its +/// anchor, so the two handles of a smooth anchor stay in line with it. That share is below 0 or +/// above 1 for a handle long enough beside the span. A closed subpath with no anchor that stays +/// moves whole when it has a focal point. +fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[f64; 3]> { + let n = anchors.len(); + let mut shares = vec![[0.0; 3]; n]; + let role = |i: usize| match roles.get(i) { + Some(Stretch::Selected) if !closed && (i == 0 || i + 1 == n) => Stretch::Fixed, + Some(&r) => r, + None => Stretch::Fixed, + }; + // The walk along the subpath starts at an anchor that stays, or else at a focal point, and on a + // closed subpath it comes back round to the same anchor, so every selected anchor lies between + // two anchors that are not selected ones. + let start = if closed { (0..n).find(|&i| role(i) == Stretch::Fixed).or_else(|| (0..n).find(|&i| role(i) == Stretch::Focal)) } else { Some(0) }; + let Some(start) = start.filter(|_| n > 0) else { return shares }; + let len = if closed { n + 1 } else { n }; + let order: Vec = (0..len).map(|k| (start + k) % n).collect(); + // Where each anchor's in handle, point and out handle lie along the control polygon. The in + // handle of the first anchor and the out handle of the last are not part of an open path. + let mut pos: Vec<[f64; 3]> = Vec::with_capacity(len); + let mut run = 0.0; + let mut before: Option<&Anchor> = None; + for (k, a) in order.iter().filter_map(|&i| anchors.get(i)).enumerate() { + if let Some(b) = before { + run += b.h_out.distance(a.h_in); + } + let c_in = run; + if k > 0 { + run += a.h_in.distance(a.p); + } + let c = run; + if k + 1 < len { + run += a.p.distance(a.h_out); + } + pos.push([c_in, c, run]); + before = Some(a); + } + // The nearest anchor before and after each place in the walk that is not a selected one. + let bound = |k: usize| role(order.get(k).copied().unwrap_or(start)) != Stretch::Selected; + let mut previous = vec![0; len]; + let mut last = 0; + for (k, slot) in previous.iter_mut().enumerate() { + if bound(k) { + last = k; + } + *slot = last; + } + let mut next = vec![len - 1; len]; + let mut first = len - 1; + for (k, slot) in next.iter_mut().enumerate().rev() { + if bound(k) { + first = k; + } + *slot = first; + } + let at = |k: usize| pos.get(k).map_or(0.0, |c| c[1]); + // The share of the anchor at `k` in the walk, between an anchor that stays at `end` and a + // focal point at `focal` along the walk. + let smooth = |k: usize, end: f64, focal: f64| { + let span = (focal - end).abs(); + let Some(&[c_in, c, c_out]) = pos.get(k).filter(|_| span > 0.0 && span.is_finite()) else { return [0.0; 3] }; + let along = |c: f64| (c - end).abs() / span; + let u = along(c); + let w = u * u * (3.0 - 2.0 * u); + let slope = 6.0 * u * (1.0 - u); + [w + slope * (along(c_in) - u), w, w + slope * (along(c_out) - u)] + }; + for (k, &i) in order.iter().enumerate().take(n) { + let share = match role(i) { + Stretch::Fixed => [0.0; 3], + Stretch::Focal => [1.0; 3], + Stretch::Selected => { + let (kp, kn) = (previous.get(k).copied().unwrap_or(k), next.get(k).copied().unwrap_or(k)); + match (role(order.get(kp).copied().unwrap_or(i)), role(order.get(kn).copied().unwrap_or(i))) { + (Stretch::Focal, Stretch::Focal) => [1.0; 3], + (Stretch::Fixed, Stretch::Focal) => smooth(k, at(kp), at(kn)), + (Stretch::Focal, Stretch::Fixed) => smooth(k, at(kn), at(kp)), + _ => [0.0; 3], + } + } + }; + // A coordinate that isn't finite moves nothing. + if let Some(s) = shares.get_mut(i) + && share.iter().all(|s| s.is_finite()) + { + *s = share; + } + } + shares +} + +/// After an anchor is added at `(si, added)` of path `id`, number the direct-selected anchors and +/// selected segments after it on that subpath one higher, so the selection keeps the same points. +/// A selected segment that the new anchor splits stays selected as its two halves, and the new +/// anchor joins its ends in the anchors that move with it. +fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usize) { + if let Some(set) = sel.anchors.get_mut(&id) { + // A selected anchor index can name no anchor (`select.anchors` takes any index), so the + // renumbering saturates instead of overflowing. + *set = set.iter().map(|&(s, a)| if s == si && a >= added { (s, a.saturating_add(1)) } else { (s, a) }).collect(); + } + let split = added.checked_sub(1); + if sel.segments.get(&id).is_some_and(|set| split.is_some_and(|g| set.contains(&(si, g)))) { + sel.anchors.entry(id).or_default().insert((si, added)); + } + if let Some(set) = sel.segments.get_mut(&id) { + *set = set + .iter() + .flat_map(|&(s, g)| match (s == si, Some(g) == split) { + (true, true) => vec![(s, g), (s, g.saturating_add(1))], + (true, false) if g >= added => vec![(s, g.saturating_add(1))], + _ => vec![(s, g)], + }) + .collect(); + } +} + fn reshape(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); @@ -1406,19 +1562,94 @@ fn reshape(s: &mut Session, p: &Value) -> Result { let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let tol = f64_or(p, "tol", 3.0).max(0.0); let radius = p.get("radius").and_then(Value::as_f64); - s.edit("Reshape", |d, _| { + if radius.is_some_and(|r| r < 0.0) { + return Err(bad("path.reshape", "`radius` must be 0 or more")); + } + // The anchor within `tol` of the grab point, if there is one. + let near = |sp: &SubPath| match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) { + Some((i, dist)) if dist <= tol => Some(i), + _ => None, + }; + // The other focal points the Reshape tool highlighted on this path. + let mut focal: BTreeSet<(usize, usize)> = match p.get("focal") { + None | Some(Value::Null) => BTreeSet::new(), + Some(v) if v.is_array() => super::select::checked_refs(Some(v), "path.reshape")?.into_iter().collect(), + Some(_) => return Err(bad("path.reshape", "`focal` must be an array of [subpath, anchor] pairs")), + }; + // An empty `focal` chooses the focal-point rule too. + if radius.is_some() && !matches!(p.get("focal"), None | Some(Value::Null)) { + return Err(bad("path.reshape", "`focal` and `radius` cannot be used together")); + } + // A step that names neither `focal` nor `radius` (as the Reshape tool recorded one before it + // had focal points) keeps that earlier rule: the selection does not matter, an open path + // stretches from the grabbed point to its two ends, and a closed one follows with a falloff + // over the default radius. + let earlier = radius.is_none() && matches!(p.get("focal"), None | Some(Value::Null)); + { + let st = s.doc()?; + let node = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + let path = node.path_data().ok_or_else(|| EngineError::Other(format!("object {id} is not a path")))?; + if let Some((si, ai)) = focal.iter().copied().find(|&(si, ai)| path.subpaths.get(si).is_none_or(|sp| ai >= sp.anchors.len())) { + return Err(bad("path.reshape", format!("`focal` names [{si}, {ai}], which is not an anchor of the path"))); + } + // A drag of no length on an anchor moves nothing, so it is not an edit. + if delta == Vec2::ZERO { + let (si, ..) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?; + if path.subpaths.get(si).is_some_and(|sp| near(sp).is_some()) { + return ok(); + } + } + } + s.edit("Reshape", |d, sel| { let path = path_mut(d, id)?; let (si, seg, t, _, _) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?; - let sp = &mut path.subpaths[si]; - let grabbed = match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) { - Some((i, dist)) if dist <= tol => i, - _ => sp.insert_anchor(seg, t), + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?; + let grabbed = match near(sp) { + Some(i) => i, + None => { + let added = sp.insert_anchor(seg, t); + focal = focal.iter().map(|&(s, a)| if s == si && a >= added { (s, a.saturating_add(1)) } else { (s, a) }).collect(); + renumber_after_insert(sel, id, si, added); + // On a partly selected path the new anchor is selected too. + if let Some(set) = sel.anchors.get_mut(&id) { + set.insert((si, added)); + } + added + } }; + let earlier_closed = earlier && sp.closed; + if radius.is_none() && !earlier_closed { + focal.insert((si, grabbed)); + // Anchors that are not direct-selected stay; a path selected whole, or not selected, + // has every anchor selected, and so does every path under the earlier rule. + let selected = sel.anchors.get(&id).filter(|_| !earlier); + for (k, sp) in path.subpaths.iter_mut().enumerate() { + if focal.range((k, 0)..=(k, usize::MAX)).next().is_none() { + continue; + } + let roles: Vec = (0..sp.anchors.len()) + .map(|i| match (focal.contains(&(k, i)), selected.is_none_or(|set| set.contains(&(k, i)))) { + (true, _) => Stretch::Focal, + (false, true) => Stretch::Selected, + (false, false) => Stretch::Fixed, + }) + .collect(); + let weights = stretch_weights(&sp.anchors, sp.closed, &roles); + for (a, [w_in, w, w_out]) in sp.anchors.iter_mut().zip(weights) { + a.p += delta * w; + a.h_in += delta * w_in; + a.h_out += delta * w_out; + } + } + return Ok(()); + } + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?; + let closed = sp.closed; // Each anchor's distance along the path from the grabbed one, the shorter way round a // closed path: the lengths are measured once, as a path can have many anchors. let pos = sp.anchor_arc_positions(); let total = pos.last().copied().unwrap_or(0.0); - // The default reach is the subpath's size, as before, now measured along it. + // The default reach of the earlier rule: half the diagonal of the subpath's bounding box. let r = radius.unwrap_or_else(|| { let mut bp = vectorcraft_geom::BezPath::new(); sp.to_bezpath_into(&mut bp); @@ -1426,7 +1657,6 @@ fn reshape(s: &mut Session, p: &Value) -> Result { (b.width().hypot(b.height()) / 2.0).max(1.0) }); let from = pos.get(grabbed).copied().unwrap_or(0.0); - let closed = sp.closed; for (i, a) in sp.anchors.iter_mut().enumerate() { let w = if i == grabbed { 1.0 @@ -1459,6 +1689,7 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no such segment".into())); } let idx = sp.insert_anchor(seg, t); + renumber_after_insert(sel, id, si, idx); // A path that wasn't selected becomes the selection, its new anchor direct-selected, so // the point added shows and can be edited at once (#4237). if !sel.contains(id) { diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index c18412c1d..5b9e25d35 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -16,8 +16,8 @@ use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; use super::expand::{fill_shape, reshaped, seeded}; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection, - number_or, str_param, switch_or, + CommandSpec, EngineError, Result, Session, always, bad, check_switches, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection, + number_or, switch_or, text_param, }; pub fn specs() -> Vec { @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Outline Stroke", ["Object", "Path"], None, - "{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}", + "{} replace stroked paths with filled outlines; a dimension whose lines are outlined 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 stay as they are, and if the outlined art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, outline_stroke ), @@ -221,6 +221,41 @@ pub(crate) fn len_param(p: &Value, key: &str) -> Option { } } +/// The number `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string +/// counts only when all of it is a number (`"20"`). Any other value, such as `true`, `"wide"` or +/// `"20abc"`, is an error: "`{key}` must be a number, not {v}". +pub(crate) fn number_given(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v { + Value::String(s) => whole_number(s), + _ => num_param(p, key), + } + .map(Some) + .ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + } +} + +/// The distance `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string +/// counts only when all of it is a length that [`vectorcraft_doc::Unit::parse`] reads (`"5 mm"`) or a +/// number. Any other value, such as `"20abc"`, is an error, as [`number_given`] gives it. +pub(crate) fn length_given(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v { + Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| whole_number(s)), + _ => num_param(p, key), + } + .map(Some) + .ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + } +} + +/// `s` as a finite number when all of it, spaces around it aside, is one. +fn whole_number(s: &str) -> Option { + s.trim().parse::().ok().filter(|v| v.is_finite()) +} + fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } @@ -684,21 +719,22 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result { /// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected /// objects' leaf shapes, as one group of overprinting paths above the topmost selected object. fn trap(s: &mut Session, p: &Value) -> Result { - use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps}; + use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, try_traps}; const ID: &str = "object.pathfinder.trap"; let mut q = p.clone(); - if let Some(t) = len_param(p, "thickness") { + if let Some(t) = length_given(p, "thickness", ID)? { if !(po::TRAP_MIN_WIDTH..=po::TRAP_MAX_WIDTH).contains(&t) { return Err(bad(ID, "thickness must be from 0.01 to 5000 pt")); } q["thickness"] = json!(t); } - if num_param(p, "heightWidth").is_some_and(|v| !(1.0..=1000.0).contains(&v)) { + if number_given(p, "heightWidth", ID)?.is_some_and(|v| !(1.0..=1000.0).contains(&v)) { return Err(bad(ID, "heightWidth must be from 1 to 1000 %")); } - if num_param(p, "tintReduction").is_some_and(|v| !(0.0..=100.0).contains(&v)) { + if number_given(p, "tintReduction", ID)?.is_some_and(|v| !(0.0..=100.0).contains(&v)) { return Err(bad(ID, "tintReduction must be from 0 to 100 %")); } + check_switches(p, &["trapsWithProcessColor", "reverseTraps"], ID)?; // The effect-only settings stay at their defaults. if let Some(o) = q.as_object_mut() { o.remove("precision"); @@ -722,7 +758,12 @@ fn trap(s: &mut Session, p: &Value) -> Result { if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 { return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into())); } - let made = traps(&sources, &options); + let Some(made) = try_traps(&sources, &options) else { + return Err(EngineError::Other(format!( + "Trap: more than {} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time", + vectorcraft_render::effects::trap::MAX_NEAR_PAIRS + ))); + }; if made.is_empty() { return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into())); } @@ -743,21 +784,23 @@ fn trap(s: &mut Session, p: &Value) -> Result { // ---------- Offset Path ---------- -fn join_param(p: &Value, key: &str) -> po::Join { - match str_param(p, key).map(str::to_ascii_lowercase).as_deref() { - Some("round") => po::Join::Round, - Some("bevel") => po::Join::Bevel, - _ => po::Join::Miter, +fn join_param(p: &Value, key: &str, cmd: &str) -> Result { + match text_param(p, key, cmd)?.map(str::to_ascii_lowercase).as_deref() { + None | Some("miter") => Ok(po::Join::Miter), + Some("round") => Ok(po::Join::Round), + Some("bevel") => Ok(po::Join::Bevel), + Some(_) => Err(bad(cmd, format!("`{key}` must be one of miter, round, bevel"))), } } fn offset_path(s: &mut Session, p: &Value) -> Result { - let delta = len_param(p, "offset").unwrap_or(10.0); + const C: &str = "object.path.offsetPath"; + let delta = length_given(p, "offset", C)?.unwrap_or(10.0); if delta.abs() > 1e5 { - return Err(bad("object.path.offsetPath", "offset out of range")); + return Err(bad(C, "offset out of range")); } - let join = join_param(p, "joins"); - let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0); + let join = join_param(p, "joins", C)?; + let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0); let roots = selected_roots(s)?; let ids = s.edit("Offset Path", |d, sel| { let (mut out, mut above) = (vec![], vec![]); @@ -1036,7 +1079,11 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { let before = d.clone(); - let mut ended = super::dimension::end_links(d, &roots); + // A dimension selected with the art it measures keeps its own lines: it is out of the + // document while the art is outlined. + let lifted = super::dimension::lift(d, &roots); + let kept = !lifted.is_empty(); + let ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut new_sel = vec![]; let mut changed = 0; @@ -1045,10 +1092,10 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { new_sel.push(id); changed += n; } - // A dimension whose source was outlined stays plain: its link no longer fits that art. - ended.retain(|e| e.source_kept(&before, d)); - super::dimension::restore_unchanged(d, ended); - if changed == 0 { + super::dimension::put_back(d, lifted); + // A dimension whose source was outlined stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); + if changed == 0 || (kept && d.layers == before.layers) { return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into())); } sel.set(new_sel.iter().copied()); @@ -1167,23 +1214,21 @@ pub fn auto_simplify_level(s: &Session) -> Result { } fn simplify(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.path.simplify"; + let (auto, given_level, given_tolerance) = (switch_or(p, "auto", false, C)?, number_given(p, "level", C)?, length_given(p, "tolerance", C)?); + let corner_angle = number_given(p, "cornerAngle", C)?.unwrap_or(30.0).clamp(0.0, 180.0); + let (straight_lines, whole) = (switch_or(p, "straightLines", false, C)?, switch_or(p, "wholePaths", false, C)?); // With auto, the slider's level is Auto-Simplify's for the selection's size; otherwise it is the level given. - let level = if bool_or(p, "auto", false) { + let level = if auto { Some(auto_simplify_level(s)?) } else { - num_param(p, "level").map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL }) + given_level.map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL }) }; let tolerance = match level { Some(l) => po::simplify_level_tolerance(l), - None => len_param(p, "tolerance").unwrap_or(1.0), + None => given_tolerance.unwrap_or(1.0), }; - let opts = po::SimplifyOptions { - tolerance: tolerance.clamp(1e-4, 1e4), - corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0), - straight_lines: bool_or(p, "straightLines", false), - smooth: 0.0, - }; - let whole = bool_or(p, "wholePaths", false); + let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, smooth: 0.0 }; // A path with segments direct-selected simplifies only their runs, and keeps those runs (and the // other anchors it had selected) direct-selected. let roots = selected_roots(s)?; @@ -1257,10 +1302,17 @@ fn grid_axis(p: &Value, n: f64, [size, gutter, total]: [&str; 3], extent: f64) - } fn split_into_grid(s: &mut Session, p: &Value) -> Result { - let rows = num_param(p, "rows").unwrap_or(2.0).round(); - let cols = num_param(p, "columns").unwrap_or(2.0).round(); + const C: &str = "object.path.splitIntoGrid"; + let rows = number_given(p, "rows", C)?.unwrap_or(2.0).round(); + let cols = number_given(p, "columns", C)?.unwrap_or(2.0).round(); if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) { - return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500")); + return Err(bad(C, "rows and columns must be 1..500")); + } + let add_guides = switch_or(p, "addGuides", false, C)?; + // Each distance [`grid_axis`] reads is a number, a string with a unit or null, checked before the + // selection, so a bad one is an error whatever is selected. + for key in ["height", "rowGutter", "totalHeight", "width", "columnGutter", "totalWidth", "gutter"] { + length_given(p, key, C)?; } let too_small = || EngineError::Other("Split Into Grid: select paths large enough for the grid".into()); // The selected paths, back to front: one grid over them all replaces them, where the topmost @@ -1281,7 +1333,7 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result { return Err(too_small()); } let mut edges: Vec<(bool, f64)> = Vec::new(); - if bool_or(p, "addGuides", false) { + if add_guides { // Row-major cells: the first row has the column edges, each row's first cell the row edges. let bounds = cells.iter().filter_map(PathData::bounds); edges.extend(bounds.clone().take(cols).flat_map(|c| [(true, c.x0), (true, c.x1)])); @@ -1299,7 +1351,9 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result { let node = shape_node(d, c, Some(&top)); out.push(d.insert(par, idx + k, node)?); } - d.guides.extend(edges.iter().map(|&(vertical, pos)| vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) })); + for &(vertical, pos) in &edges { + d.add_ruler_guide(&vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) }); + } sel.set(out.iter().copied()); Ok(out) })?; @@ -1316,9 +1370,10 @@ fn is_painted(a: &Appearance) -> bool { } fn clean_up(s: &mut Session, p: &Value) -> Result { - let stray = bool_or(p, "strayPoints", true); - let unpainted = bool_or(p, "unpaintedObjects", true); - let empty_text = bool_or(p, "emptyTextPaths", true); + const C: &str = "object.path.cleanUp"; + let stray = switch_or(p, "strayPoints", true, C)?; + let unpainted = switch_or(p, "unpaintedObjects", true, C)?; + let empty_text = switch_or(p, "emptyTextPaths", true, C)?; let found = { let d = &s.doc()?.doc; let mut v = vec![]; diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 55c4390c2..4075676ca 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -15,13 +15,13 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; -use vectorcraft_geom::Rect; +use vectorcraft_geom::{Point, Rect}; use super::edit::{outermost, selected_roots}; use super::pathops::Beside; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_doc, has_selection, ids_param, matrix_param, ok, point_param, - str_param, targets, + CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, finite_numbers, has_doc, has_selection, ids_param, + matrix_param, ok, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Mirror", ["Object", "Repeat"], None, - "{ids?, angle?: deg (90 = vertical axis), offset?: pt (10)} mirror repeat of the selection → {id}", + "{ids?, angle?: deg clockwise from level (the mirror axis; 90 = vertical), offset?: pt (10)} mirror repeat of the selection → {id}", has_selection, repeat_mirror ), @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Options…", ["Object", "Repeat"], None, - "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, flipRows?, flipCols? | angle?} set the options of the selected repeats", + "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?: deg clockwise (0: where the source art sits), center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?: deg clockwise from level (the mirror axis; 90 = vertical)} set the options of the selected repeats", has_repeat, repeat_options ), @@ -271,8 +271,9 @@ fn enter_edit(d: &mut Document, sel: &mut Selection, name: &str, original: Optio /// Leave editing mode: drop the layer, return the edit record. fn leave_edit(d: &mut Document, sel: &mut Selection) -> Result<(PatternEdit, Vec>)> { let pe = d.pattern_edit.take().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; - let art = match d.remove(pe.layer) { - Ok(l) => l.children().cloned().unwrap_or_default(), + // The tile art: ruler guides on the edit layer are not part of it and stay in the document. + let art = match d.remove_edit_layer(pe.layer) { + Ok(l) => l.children().into_iter().flatten().cloned().collect(), Err(_) => vec![], }; sel.clear(); @@ -287,9 +288,12 @@ fn after_edit_mode(s: &mut Session, layer: Option) -> Result<()> { Ok(()) } +/// The tile type `p` gives `cmd` (`tileType`, `brickOffset`), else `current` with a given brick offset. +/// A `tileType` that is not a string, or a `brickOffset` that is not a number, is an error. fn tile_type_param(p: &Value, cmd: &str, current: TileType) -> Result { + check_numbers(p, &["brickOffset"], cmd)?; let off = p.get("brickOffset").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }); - match str_param(p, "tileType") { + match text_param(p, "tileType", cmd)? { Some(t) => TileType::parse(t, off).ok_or_else(|| bad(cmd, format!("unknown tileType `{t}` ({})", TileType::IDS.join("|")))), None => Ok(match (current, off) { (TileType::BrickByRow { .. }, Some(o)) => TileType::BrickByRow { offset: o }, @@ -311,12 +315,13 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "select the art for the pattern tile")); } let tt = tile_type_param(p, C, TileType::Grid)?; + check_numbers(p, &["width", "height"], C)?; let (w, h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); if w.is_some_and(|v| !(v.is_finite() && v > 0.0)) || h.is_some_and(|v| !(v.is_finite() && v > 0.0)) { return Err(bad(C, "width/height must be > 0")); } - let edit = bool_or(p, "edit", true); - let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let edit = switch_or(p, "edit", true, C)?; + let wanted = text_param(p, "name", C)?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Make Pattern", |d, sel| { let name = match &wanted { Some(n) => unique_pattern_name(d, n), @@ -343,7 +348,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { fn pattern_edit(s: &mut Session, p: &Value) -> Result { const C: &str = "object.pattern.edit"; - let name = match str_param(p, "name") { + let name = match text_param(p, "name", C)? { Some(n) => n.to_string(), None => { // The selected object's fill (or stroke) pattern. @@ -419,11 +424,12 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result { } fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { - let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Save a Copy", |d, _| { let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?; - let art: Vec> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default(); + let art: Vec> = + d.node(pe.layer).into_iter().flat_map(|l| l.children().into_iter().flatten()).filter(|c| !c.is_ruler_guide()).cloned().collect(); let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim()))); def.name = name.clone(); def.art = copies(d, &art).into_iter().map(Arc::new).collect(); @@ -438,19 +444,21 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { fn pattern_options(s: &mut Session, p: &Value) -> Result { const C: &str = "pattern.options"; - let name = match str_param(p, "name") { + let name = match text_param(p, "name", C)? { Some(n) => n.to_string(), None => editing(s).map(|e| e.pattern.clone()).ok_or_else(|| bad(C, "give `name` (or edit a pattern)"))?, }; let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?; let tt = tile_type_param(p, C, cur.tile_type)?; + check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?; + check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); for k in ["width", "height"] { if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) { return Err(bad(C, format!("{k} must be > 0"))); } } - let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); + let new_name = text_param(p, "newName", C)?.map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); if let Some(n) = &new_name { let d = &s.doc()?.doc; if d.pattern(n).is_some() { @@ -461,17 +469,22 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { } } let overlap = match p.get("overlap") { - Some(o) => Overlap { - right_in_front: str_param(o, "h").map_or(cur.overlap.right_in_front, |v| v == "right"), - bottom_in_front: str_param(o, "v").map_or(cur.overlap.bottom_in_front, |v| v == "bottom"), + None | Some(Value::Null) => cur.overlap, + Some(o) if o.is_object() => Overlap { + right_in_front: overlap_side(o, "h", ["left", "right"], cur.overlap.right_in_front, C)?, + bottom_in_front: overlap_side(o, "v", ["top", "bottom"], cur.overlap.bottom_in_front, C)?, }, - None => cur.overlap, + Some(got) => { + let (path, expected) = ("overlap", "an object"); + return Err(bad(C, format!("`{path}` must be {expected}, not {got}"))); + } }; let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer); let out_name = new_name.clone().unwrap_or(name.clone()); s.edit("Pattern Options", |d, _| { // While editing, the tile art is the edit layer's. - let live_art: Option>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned()); + let live_art: Option>> = + edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; def.tile_type = tt; def.overlap = overlap; @@ -514,6 +527,19 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": out_name })) } +/// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the +/// first, `current` when `o` leaves it out or gives null. Any other value is an error. +fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result { + match o.get(side) { + None | Some(Value::Null) => Ok(current), + Some(Value::String(v)) if names.contains(&v.as_str()) => Ok(v == names[1]), + Some(_) => { + let key = format!("overlap.{side}"); + Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", ")))) + } + } +} + /// Is `new` taken for pattern `old` by a swatch or a color group (they share one namespace, /// [`Document::swatch_name_taken`])? It is free when the swatches named `new` are one or more /// swatches of the pattern and none of the pattern's swatches is named `old` (those take the new @@ -571,7 +597,7 @@ fn nodes_using(d: &Document, name: Option<&str>) -> Vec { fn pattern_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "pattern.delete"; - let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?.to_string(); + let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing name"))?.to_string(); if s.doc()?.doc.pattern(&name).is_none() { return Err(bad(C, format!("no pattern `{name}`"))); } @@ -694,18 +720,25 @@ fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn } fn repeat_radial(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.repeat.radial"; + check_numbers(p, &["instances", "radius"], C)?; let n = f64_or(p, "instances", 8.0); if !(1.0..=1000.0).contains(&n) { - return Err(bad("object.repeat.radial", "instances must be 1..1000")); + return Err(bad(C, "instances must be 1..1000")); } - let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0); - make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) + let radius = p.get("radius").and_then(Value::as_f64); + if radius.is_some_and(|r| r < 0.0) { + let key = "radius"; + return Err(bad(C, format!("`{key}` must be a number not below 0"))); + } + make_repeat(s, p, C, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) } fn repeat_grid(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["hSpacing", "vSpacing", "rows", "cols"], "object.repeat.grid")?; let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64)); - let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); - let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); + let rows = p.get("rows").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500)); + let cols = p.get("cols").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500)); make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| { let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0)); let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0)); @@ -718,6 +751,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result { } fn repeat_mirror(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["angle", "offset"], "object.repeat.mirror")?; let angle = f64_or(p, "angle", 90.0); let offset = f64_or(p, "offset", 10.0); make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) @@ -803,16 +837,26 @@ pub(crate) fn expand_repeats(d: &mut Document, roots: &[NodeId]) -> Result Result { const C: &str = "object.repeat.options"; let reps = selected_repeats(s); + let numbers = ["instances", "radius", "startAngle", "endAngle", "hSpacing", "vSpacing", "rows", "cols", "brickOffset", "angle"]; + check_numbers(p, &numbers, C)?; + check_switches(p, &["reverseOverlap", "flipRows", "flipCols"], C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); let flag = |k: &str| p.get(k).and_then(Value::as_bool); if num("instances").is_some_and(|n| !(1.0..=1000.0).contains(&n)) { return Err(bad(C, "instances must be 1..1000")); } - let grid_type = match str_param(p, "gridType") { + let grid_type = match text_param(p, "gridType", C)? { Some(t) => Some(TileType::parse(t, num("brickOffset")).ok_or_else(|| bad(C, format!("unknown gridType `{t}`")))?), None => None, }; - let center = point_param(p, "center"); + let center = match p.get("center") { + None | Some(Value::Null) => None, + Some(v) => { + let key = "center"; + let [x, y] = finite_numbers::<2>(v).ok_or_else(|| bad(C, format!("{key} must be [x, y] with finite coordinates")))?; + Some(Point::new(x, y)) + } + }; s.edit("Repeat Options", |d, _| { for r in &reps { let Some(NodeKind::Repeat(spec)) = d.node_mut(*r).map(|n| &mut n.kind) else { continue }; @@ -852,6 +896,8 @@ fn repeat_options(s: &mut Session, p: &Value) -> Result { } if let Some(t) = grid_type { *gt = t; + } else if let (Some(v), TileType::BrickByRow { offset } | TileType::BrickByColumn { offset }) = (num("brickOffset"), &mut *gt) { + *offset = v; } if let Some(v) = flag("flipRows") { *flip_rows = v; diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 3e15c563f..011dfc131 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -10,6 +10,7 @@ use vectorcraft_doc::pattern::pattern_paint; use vectorcraft_doc::{Appearance, Document, Node, NodeId, PatternDef, TileType}; use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes}; +use super::Result; use super::clipboard::Clipboard; use super::clipboard::resources::SwatchChoices; use super::swatchlib::LibraryInfo; @@ -84,11 +85,11 @@ pub fn swatch_source(w: &Swatch) -> Option> { /// Add pattern `name` of library `lib` to `d` with the resources its art uses, as pasting art /// painted with it does: the same pattern there already is used, and a name another pattern or /// swatch has gets a number → the pattern's name in `d`. -pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String { +pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> Result { let user = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(pattern_paint(name), Paint::None, 0.0)); - let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false); + let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false)?; let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name).and_then(|r| r["to"].as_str()); - renamed.unwrap_or(name).to_string() + Ok(renamed.unwrap_or(name).to_string()) } /// The pattern of `d` that is pattern `name` of library `lib`: the same tile options and art under diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index db9bbf67d..1ded346d8 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -377,7 +377,7 @@ fn placeable(l: &Node) -> bool { fn art_of<'a>(nodes: impl Iterator>) -> Vec { nodes .filter_map(|c| match &c.kind { - NodeKind::Path { guide: true, .. } => None, + _ if c.is_guide() => None, NodeKind::Layer { children, clip, .. } => placeable(c).then(|| { let mut g = (**c).clone(); g.kind = NodeKind::Group { children: art_of(children.iter()).into_iter().map(Arc::new).collect(), clip: *clip }; diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 1cd137bdd..a5b71d538 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -88,6 +88,9 @@ pub fn describe(p: &Plugin) -> Value { if m.paint_output { v["paintOutput"] = json!(true); } + if !m.command_allowlist.is_empty() { + v["commandAllowlist"] = json!(m.command_allowlist); + } if !m.commands.is_empty() { let commands: Vec = m .commands @@ -258,6 +261,37 @@ impl FilterRun { self.plugin.run(&self.input, &self.params).map_err(err) } + /// Does the plug-in call commands (its manifest's `commandAllowlist`)? Then it runs only in the + /// session ([`FilterRun::run_with_calls`]). + pub fn calls(&self) -> bool { + !self.plugin.manifest().command_allowlist.is_empty() + } + + /// Runs the plug-in in `s`, making the command calls it asks for on the active document + /// ([`super::plugin_call::call`]) → its output. + fn run_calling(&self, s: &mut Session) -> Result> { + let may = self.plugin.manifest().permissions(); + let calls = super::plugin_call::Calls::of(s, may.contains(&Permission::DocRead), may.contains(&Permission::DocWrite)); + let mut bridge = |id: &str, args: &Value| super::plugin_call::call(s, &calls, id, args); + self.plugin.run_calling(&self.input, &self.params, self.plugin.limits(), &mut bridge).0.map_err(err) + } + + /// Runs a plug-in that calls commands and applies its output: the calls and the output are one + /// undo step, and a run that fails changes nothing ([`super::plugin_call::transaction`]). + fn run_with_calls(self, s: &mut Session) -> Result { + let label = self.label(); + super::plugin_call::transaction(s, &label, |s| { + let output = self.run_calling(s)?; + self.finish(s, &output) + }) + } + + /// The name of its undo step: the plug-in's, or its command's. + fn label(&self) -> String { + let m = self.plugin.manifest(); + self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label) + } + /// Hands in `output`, which [`FilterRun::run`] returned: the next `plugin.run` that makes this /// same run (same plug-in, `ids` and parameters, the document unchanged) applies it instead of /// running the plug-in. Any other run drops it. @@ -277,9 +311,9 @@ impl FilterRun { return Err(not_granted(&m.name, Permission::State)); } let may_write = may.contains(&Permission::DocWrite); - let (inputs, parent) = (self.inputs, self.parent); // A command's undo step is named after the command. - let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label); + let label = self.label(); + let (inputs, parent) = (self.inputs, self.parent); let ids = s.edit(&label, |d, sel| { let before = (!may_write).then(|| d.layers.clone()); let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; @@ -316,6 +350,12 @@ fn run(s: &mut Session, p: &Value) -> Result { // An output handed in is for this run only (the app hands it in just before), whatever it does. let handed = s.plugin_output.take(); let r = FilterRun::prepare(s, p)?; + // One that calls commands runs here, where it can make them, on the document with any edit in + // progress committed. + if r.calls() { + let r = if super::plugin_call::settle(s)? { FilterRun::prepare(s, p)? } else { r }; + return r.run_with_calls(s); + } // Handed in for this very run (the app ran it in the background), it is what running the // plug-in again would return. let handed = handed.filter(|h| (h.serial, &h.input, &h.params) == (r.plugin.serial(), &r.input, &r.params)); @@ -463,6 +503,16 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result } }; let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?; + if r.calls() { + let r = match super::plugin_call::settle(s)? { + true => { + let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + FilterRun::prepare_with(s, plugin, Some(command), id, p)? + } + false => r, + }; + return r.run_with_calls(s).map_err(failed); + } let output = r.run().map_err(&failed)?; r.finish(s, &output).map_err(failed) } @@ -654,6 +704,16 @@ fn install(s: &mut Session, p: &Value) -> Result { "`grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app", )); } + // A plug-in never calls a command no plug-in may call, whatever its manifest lists. + let (unknown, refused) = super::plugin_call::check_allowlist(plugin.manifest()); + if let Some(id) = refused.first() { + let (name, id) = ( + permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME), + permissions::display_text(id, permissions::MAX_SHOWN_FIELD), + ); + return Err(bad(C, format!("“{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call"))); + } + let unknown: Vec = unknown.into_iter().map(str::to_string).collect(); // A heavy plug-in is installed once a person agrees. if plugin.manifest().budget == Budget::Heavy && !allow_heavy { let (minutes, times) = vectorcraft_plugins::heavy_terms(); @@ -726,7 +786,11 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - Ok(describe_installed(®istry::install(plugin))) + let mut v = describe_installed(®istry::install(plugin)); + if !unknown.is_empty() { + v["unknownCommands"] = json!(unknown); + } + Ok(v) } /// `plugin.inspect`: what installing the module would install, without installing it, whether that @@ -737,6 +801,13 @@ fn inspect(s: &mut Session, p: &Value) -> Result { let mut v = describe(&plugin); v["needsApproval"] = json!(!s.plugin_approved(&plugin)); v["needsReplace"] = json!(takes_over(s, &plugin)); + let (unknown, refused) = super::plugin_call::check_allowlist(plugin.manifest()); + if !unknown.is_empty() { + v["unknownCommands"] = json!(unknown); + } + if !refused.is_empty() { + v["refusedCommands"] = json!(refused); + } v }) } @@ -775,6 +846,15 @@ pub(crate) fn sync_prefs(s: &Session) { *last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder))); } +/// The Additional Plug-ins Folder preference was cleared from `old`: when `old` is the folder last +/// loaded, it is forgotten, so setting the preference to it again loads it again. +pub(crate) fn forget_prefs_folder(old: &str) { + let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + if last.as_ref().is_some_and(|(f, _, _)| f == old.trim()) { + *last = None; + } +} + /// Turns Safe Mode on for `--safe-mode`, before the plug-in folder is attached: no plug-in loads or /// runs in this process from then on. pub fn start_in_safe_mode() { @@ -971,7 +1051,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -989,7 +1069,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, in paint order from the bottom, with at most 16 MiB of pixels and 16 MiB of type runs per run (an object that doesn't fit what the objects before it left comes with pixelsOmitted or truncated; an image that reaches the decoder counts toward the pixels even when decoding fails); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, in paint order from the bottom, with at most 16 MiB of pixels and 16 MiB of type runs per run (an object that doesn't fit what the objects before it left comes with pixelsOmitted or truncated; an image that reaches the decoder counts toward the pixels even when decoding fails); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer). A filter with a commandAllowlist runs the commands it names while it runs (vc_call, see docs/plugins.md): its calls and its output are one undo step, and a run that fails or makes a call that fails changes nothing → {plugin, ids}", has_doc, run ), @@ -998,7 +1078,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session (a module whose commandAllowlist names a command no plug-in may call, such as one that reads or writes files, is refused) → its plugin.list entry, plus unknownCommands?: [id] (commandAllowlist ids no command has; a call of one fails)", always, install ), @@ -1007,7 +1087,7 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true), unknownCommands?: [id] (commandAllowlist ids no command has) and refusedCommands?: [id] (commandAllowlist ids no plug-in may call: plugin.install refuses the module)", always, inspect ), diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs new file mode 100644 index 000000000..b151adcea --- /dev/null +++ b/crates/engine/src/cmd/plugin_call.rs @@ -0,0 +1,577 @@ +//! Commands a filter plug-in calls with `vc_call` ([`vectorcraft_plugins::host`]): which commands a +//! plug-in may never call ([`denied`]), the call itself ([`call`]), and the run around the calls +//! ([`transaction`]). +//! +//! A filter whose manifest's `commandAllowlist` names commands runs on the session's thread. Each +//! call it makes runs that command on the document the run is for, through [`Session::execute`] with +//! its params checked as any request's are, and sees the selection the calls before it left. The +//! calls and the filter's output are one undo step, named after the plug-in or its command; a run +//! that fails, traps, runs out of budget or is canceled, or makes a call that fails, leaves every +//! document, its selection and its history as they were. +//! +//! A plug-in never calls a command that reads or writes a file or folder, uses the clipboard, +//! changes preferences, runs a script, an action or a plug-in (a plug-in command, or an effect a +//! plug-in adds), opens, closes or switches documents, or undoes or redoes, whatever its manifest +//! lists. A command whose params name a file or folder is refused unless [`NOT_FILES`] says that +//! key names part of the document in it, and so is a call whose arguments carry such a key at any +//! depth ([`names_file`]); a call runs with no file or folder readable or writable +//! ([`execute_confined`]). Every other command is refused too unless +//! [`reviewed::ALLOWED`] lists it, so a command added to the registry later is refused until +//! someone decides. + +mod reviewed; + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_plugins::host::{CALL_COMMAND_FAILED, CALL_DENIED_COMMAND, CALL_DISABLED, CALL_UNKNOWN_COMMAND, CallFailure}; + +use super::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// Command ids a plug-in may never call, by their start: plug-ins, scripts and actions, files, +/// documents and links, the clipboard, preferences, the view and the app, and the undo history. +const DENIED_PREFIXES: &[&str] = &[ + "plugin.", + "script.", + "action.", + "actionSet.", + "command.", + "prefs.", + "help.", + "engine.", + "file.", + "document.", + "addon.document.", + "library.", + "links.", + "recovery.", + "shortcutSet.", + "clipboard.", + "view.", + "print.", + "pdf.", + "webExport.", + "css.", + "assets.", + "importMatches.", + "edit.undo", + "edit.redo", + "edit.clearHistory", + "edit.copy", + "edit.cut", + "edit.paste", + "edit.newDocument", + "edit.colorSettings", + "edit.dropSelection", + "artboard.copy", + "artboard.cut", + "text.rescanFonts", + "text.fontList", + "text.spelling.", + "colorTheme.", + "magicWand.", + "eyedropper.", + "layer.pasteRemembersLayers", + "type.systemLayout", + "type.fractionalWidths", + "appearance.setNewArtBasic", + "appearance.newArt", + "perspective.widget", + "transparency.toggleNewMasks", +]; + +/// Words in a command id that name a file or the clipboard (any case): a command with one of them +/// in its id may read or write a file a plug-in can't see. +const FILE_WORDS: &[&str] = &[ + "export", + "import", + "save", + "load", + "open", + "file", + "folder", + "link", + "librar", + "preset", + "recover", + "package", + "print", + "profile", + "clipboard", +]; + +/// Param keys that name a file or a folder. +const FILE_KEYS: &[&str] = &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url"]; + +/// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of +/// its art, a setting, an attribute it stores or a result field), not a file. +pub(crate) const NOT_FILES: &[(&str, &str)] = &[ + ("object.setLiveShape", "path"), + ("path.reverse", "paths"), + ("object.pathfinder.options", "paths"), + ("object.maskImage", "path"), + ("text.createInPath", "path"), + ("imageTrace.make", "paths"), + ("symbol.instance.setAppearance", "path"), + ("symbol.instance.selectPart", "path"), + ("object.objectsOnPath.attach", "path"), + ("attributes.set", "url"), + ("svgInteractivity.info", "files"), + ("addon.cleanup.rejoin", "paths"), +]; + +/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], +/// [`FILE_WORDS`])? +fn denied_id(id: &str) -> bool { + let lower = id.to_ascii_lowercase(); + DENIED_PREFIXES.iter().any(|p| id.starts_with(p)) || FILE_WORDS.iter().any(|w| lower.contains(w)) +} + +/// Why a plug-in may never call `spec`, or `None` when it may (when its manifest lists it): its id +/// or a file key ([`denied_by_rule`]), [`reviewed::DENIED`], or no place in [`reviewed::ALLOWED`]. +pub(crate) fn denied(spec: &CommandSpec) -> Option<&'static str> { + if let Some(why) = denied_by_rule(spec) { + return Some(why); + } + if reviewed::DENIED.binary_search(&spec.id).is_ok() { + return Some("reviewed"); + } + if reviewed::ALLOWED.binary_search(&spec.id).is_err() { + return Some("not reviewed"); + } + None +} + +/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a key in +/// [`FILE_KEYS`] that [`NOT_FILES`] doesn't name for it), or `None`. +fn denied_by_rule(spec: &CommandSpec) -> Option<&'static str> { + let id = spec.id; + if denied_id(id) { + return Some("id"); + } + let mut keys = FILE_KEYS.iter().filter(|k| !NOT_FILES.contains(&(id, **k))); + let in_schema = |k: &str| spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == k)); + if keys.any(|k| names_key(spec.params, k) || in_schema(k)) { + return Some("file key"); + } + None +} + +/// The ids `m`'s `commandAllowlist` names that no command has (a later version may add them; a +/// call of one fails), and those a plug-in may never call ([`denied`]). +pub(crate) fn check_allowlist(m: &vectorcraft_plugins::Manifest) -> (Vec<&str>, Vec<&str>) { + let (mut unknown, mut refused) = (Vec::new(), Vec::new()); + for id in &m.command_allowlist { + match super::find_command(id) { + Some(spec) if denied(spec).is_some() => refused.push(id.as_str()), + Some(_) => {} + None if denied_id(id) => refused.push(id.as_str()), + None => unknown.push(id.as_str()), + } + } + (unknown, refused) +} + +/// Does params text `text` name the key `key`: `key:` or `key?:`, not as the end of a longer word? +pub(crate) fn names_key(text: &str, key: &str) -> bool { + text.match_indices(key).any(|(at, _)| { + let before = text[..at].chars().next_back(); + let after = &text[at + key.len()..]; + before.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) && (after.starts_with(':') || after.starts_with("?:")) + }) +} + +/// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth +/// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`] +/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep +/// arguments can't overflow the thread's. +fn names_file(id: &str, args: &Value) -> bool { + let mut stack = vec![(args, true)]; + while let Some((v, top)) = stack.pop() { + match v { + Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))), + Value::Object(o) => { + for (k, x) in o { + let k = k.as_str(); + if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) { + return true; + } + stack.push((x, false)); + } + } + _ => {} + } + } + false +} + +/// Param keys whose value names an effect or a command (`id` is `effect.apply`'s other name for +/// `effect`). +const PLUGIN_KEYS: &[&str] = &["effect", "effects", "id", "command", "commands"]; + +/// Does `v` name a plug-in anywhere: an effect or a command ([`PLUGIN_KEYS`]) whose id starts with +/// `plugin.`, at any depth? A call that does is refused, so a plug-in never runs another, or +/// itself. Other strings, such as text, may start with `plugin.`. +fn names_plugin(v: &Value) -> bool { + names_plugin_in(v, false) +} + +/// [`names_plugin`] for `v`, the value of a key in [`PLUGIN_KEYS`] (or an item of one) when +/// `named`. +fn names_plugin_in(v: &Value, named: bool) -> bool { + match v { + Value::String(s) => named && s.starts_with(vectorcraft_plugins::PREFIX), + Value::Array(a) => a.iter().any(|x| names_plugin_in(x, named)), + Value::Object(o) => o.iter().any(|(k, x)| names_plugin_in(x, PLUGIN_KEYS.contains(&k.as_str()))), + _ => false, + } +} + +/// Commits an edit in progress in the active document (not a preview's), as [`transaction`] does → +/// whether that changed the document or its selection. A run that calls commands is prepared again +/// then, so its input is the document its calls change. +pub(crate) fn settle(s: &mut Session) -> Result { + if s.previewing { + return Ok(false); + } + let Some(before) = s.active().map(|d| (d.doc.clone(), d.selection.clone())) else { return Ok(false) }; + super::edit::settle_interaction(s)?; + Ok(s.active().is_none_or(|d| !Arc::ptr_eq(&before.0, &d.doc) || before.1 != d.selection)) +} + +/// What a run that makes command calls keeps fixed: the document it runs in, the documents open, +/// whether an interaction holds its changes (its own, or a preview's), and whether the plug-in may +/// read and change the document. +pub(crate) struct Calls { + document: Option, + open: Vec, + interaction: bool, + may_read: bool, + may_write: bool, +} + +impl Calls { + /// The run's fixed state in `s` as it is now; `may_read` and `may_write` are whether it has + /// `doc.read` and `doc.write`. + pub(crate) fn of(s: &Session, may_read: bool, may_write: bool) -> Calls { + let (document, open) = (s.active().map(|d| d.uid), s.docs.iter().map(|d| d.uid).collect()); + Calls { document, open, interaction: s.in_interaction(), may_read, may_write } + } + + /// Is `s` still as the run needs it: the same documents open, the same one active and the + /// interaction holding the run's changes still open? + fn kept(&self, s: &Session) -> bool { + s.active().map(|d| d.uid) == self.document + && s.docs.iter().map(|d| d.uid).eq(self.open.iter().copied()) + && (!self.interaction || s.in_interaction()) + } +} + +/// Makes plug-in call `id` with `args` in `s` → its result, or why it didn't run or failed. A +/// command a plug-in may never call ([`denied`]), one whose args name a plug-in, one that changes +/// which documents are open or active or ends the run's edit, and one that changes the document for +/// a plug-in without `doc.write` are refused, and so is every call of a plug-in without `doc.read`: +/// a command's result describes the document. +pub(crate) fn call(s: &mut Session, run: &Calls, id: &str, args: &Value) -> std::result::Result { + let refused = || CallFailure { code: CALL_DENIED_COMMAND, why: EngineError::Other("a plug-in may not call this command".into()).to_string() }; + if !run.may_read { + return Err(refused()); + } + let Some(spec) = super::find_command(id) else { + return Err(CallFailure { code: CALL_UNKNOWN_COMMAND, why: super::params::unknown_command(id).to_string() }); + }; + if denied(spec).is_some() || names_plugin(args) || names_file(id, args) { + return Err(refused()); + } + let before = s.active().map(|d| d.doc.clone()); + let r = execute_confined(s, id, args); + let value = match r { + Ok(v) => v, + Err(e @ EngineError::Disabled(..)) => return Err(CallFailure { code: CALL_DISABLED, why: e.to_string() }), + Err(e) => return Err(CallFailure { code: CALL_COMMAND_FAILED, why: e.to_string() }), + }; + let changed = before.is_some_and(|b| s.active().is_some_and(|d| !Arc::ptr_eq(&b, &d.doc))); + if !run.kept(s) || (changed && !run.may_write) { + return Err(refused()); + } + Ok(value) +} + +/// Runs command `id` with `args` in `s` with no file or folder readable or writable: every path the +/// command reaches through the checked file access ([`crate::file_access`]) is refused, whatever +/// param carries it. +fn execute_confined(s: &mut Session, id: &str, args: &Value) -> Result { + crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || s.execute(id, args)) +} + +/// Runs `f`, a plug-in run that makes command calls, as one undo step named `label` in the active +/// document: the commands it calls and the output it applies change the document inside one +/// interaction, which is kept when `f` succeeds. When `f` fails, every document goes back to how it +/// was (its art, selection, interaction and history), and so does the session state the commands a +/// plug-in may call change ([`Saved`]). During a preview the preview's own interaction holds the +/// changes. A trace handed in for the next Image Trace is set aside while `f` runs, so a call +/// traces anew, and handed back after; the next Image Trace still checks it was made of its inputs. +pub(crate) fn transaction(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + if s.active().is_some() && !s.previewing { + super::edit::settle_interaction(s)?; + } + let own = s.active().is_some() && !s.previewing && !s.in_interaction(); + if own { + s.begin_interaction(label)?; + } + let saved = Saved::of(s); + let start = s.docs.clone(); + let active = s.active; + let trace = s.trace_hand_off.take(); + let r = f(s); + s.trace_hand_off = trace; + match r { + Ok(v) => { + if own + && let Some(st) = s.active_mut() + && let Some(it) = st.interaction.take() + { + st.keep_interaction(it); + } + Ok(v) + } + Err(e) => { + let open = std::mem::replace(&mut s.docs, start); + for st in &mut s.docs { + // Past the newest revision the document reached, so the restored one redraws. + let newest = open.iter().filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision); + st.revision = newest.max(st.revision).saturating_add(1); + crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection); + } + s.active = active; + if own && let Some(st) = s.active_mut() { + st.interaction = None; + } + saved.restore(s); + Err(e) + } + } +} + +/// The session state outside the documents that a command a plug-in may call changes: the paint +/// defaults and the colors it remembers, the drawing mode, the clipboard, the undo group, the +/// preferences and menu checks, the selected gradient stop, mesh point and Appearance item, and the +/// options a command keeps for its next run. [`transaction`] puts it back when a run fails. +struct Saved { + paint: crate::PaintDefaults, + fill_active: bool, + draw_mode: crate::DrawMode, + draw_inside: Option, + clipboard: crate::Clipboard, + untitled_counter: u32, + explicit_undo_group: Option>, + prefs: crate::Prefs, + menu: super::menucmds::MenuState, + gradient_stop: Option<(usize, super::gradient::StopOwner)>, + freeform_point: Option<(usize, super::gradient::StopOwner)>, + active_appearance_item: Option, + eyedropper_sample: Option>, + last_solid: vectorcraft_color::Color, + last_gradient: vectorcraft_color::GradientPaint, + recent_colors: Vec, + recent_urls: Vec, + global_edit_options: super::globaledit::Options, + envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, +} + +impl Saved { + fn of(s: &Session) -> Saved { + Saved { + paint: s.paint.clone(), + fill_active: s.fill_active, + draw_mode: s.draw_mode, + draw_inside: s.draw_inside, + clipboard: s.clipboard.clone(), + untitled_counter: s.untitled_counter, + explicit_undo_group: s.explicit_undo_group.clone(), + prefs: s.prefs.clone(), + menu: s.menu.clone(), + gradient_stop: s.gradient_stop, + freeform_point: s.freeform_point, + active_appearance_item: s.active_appearance_item.clone(), + eyedropper_sample: s.eyedropper_sample.clone(), + last_solid: s.last_solid, + last_gradient: s.last_gradient.clone(), + recent_colors: s.recent_colors.clone(), + recent_urls: s.recent_urls.clone(), + global_edit_options: s.global_edit_options.clone(), + envelope_defaults: s.envelope_defaults, + } + } + + fn restore(self, s: &mut Session) { + s.paint = self.paint; + s.fill_active = self.fill_active; + s.draw_mode = self.draw_mode; + s.draw_inside = self.draw_inside; + s.clipboard = self.clipboard; + s.untitled_counter = self.untitled_counter; + s.explicit_undo_group = self.explicit_undo_group; + s.prefs = self.prefs; + s.menu = self.menu; + s.gradient_stop = self.gradient_stop; + s.freeform_point = self.freeform_point; + s.active_appearance_item = self.active_appearance_item; + s.eyedropper_sample = self.eyedropper_sample; + s.last_solid = self.last_solid; + s.last_gradient = self.last_gradient; + s.recent_colors = self.recent_colors; + s.recent_urls = self.recent_urls; + s.global_edit_options = self.global_edit_options; + s.envelope_defaults = self.envelope_defaults; + s.pending_paint = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every command in the registry is sorted: the ones a plug-in may call name no file, folder, + /// clipboard, preference or script in their params text, and the known file, document, script + /// and history commands are refused. + #[test] + fn every_command_is_sorted() { + let specs = crate::cmd::command_specs(); + for id in [ + "document.save", + "file.revert", + "document.serialize", + "links.update", + "graphicStyle.libraries", + "symbol.libraries", + "brush.libraries", + "text.findFontFiles", + "plugin.run", + "plugin.install", + "command.batch", + "edit.undo", + "edit.redo", + "edit.copy", + "edit.paste", + "prefs.set", + ] { + let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; + assert!(denied(spec).is_some(), "{id} is allowed"); + } + for id in ["select.all", "object.transform", "path.reverse", "symbol.instance.selectPart"] { + let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; + assert_eq!(denied(spec), None, "{id} is refused"); + } + for (id, key) in NOT_FILES { + let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; + assert!(names_key(spec.params, key), "{id} has no `{key}` param"); + } + let leaks: Vec<&str> = specs + .iter() + .filter(|c| denied(c).is_none()) + .filter(|c| { + let lower = c.id.to_ascii_lowercase(); + FILE_WORDS.iter().any(|w| lower.contains(w)) + || FILE_KEYS.iter().any(|k| names_key(c.params, k) && !NOT_FILES.contains(&(c.id, *k))) + || names_key(c.params, "sourceDocument") + }) + .map(|c| c.id) + .collect(); + assert!(leaks.is_empty(), "allowed commands that name a file or another document: {leaks:?}"); + let allowed = specs.iter().filter(|c| denied(c).is_none()).count(); + assert!(allowed > 0 && allowed < specs.len()); + } + + /// Every command no rule refuses is in exactly one of [`reviewed::ALLOWED`] and + /// [`reviewed::DENIED`], and they name only such commands, in order: a command added to the + /// registry fails here until someone puts it in one. + #[test] + fn every_command_is_reviewed() { + for list in [reviewed::ALLOWED, reviewed::DENIED] { + assert!(list.windows(2).all(|w| w[0] < w[1]), "a reviewed list is out of order or names a command twice"); + } + let (allowed, refused) = (|id: &str| reviewed::ALLOWED.binary_search(&id).is_ok(), |id: &str| reviewed::DENIED.binary_search(&id).is_ok()); + let unreviewed: Vec<&str> = + crate::cmd::command_specs().iter().filter(|c| denied_by_rule(c).is_none() && allowed(c.id) == refused(c.id)).map(|c| c.id).collect(); + assert!(unreviewed.is_empty(), "commands in neither or both of reviewed::ALLOWED and reviewed::DENIED: {unreviewed:?}"); + let stale: Vec<&str> = reviewed::ALLOWED + .iter() + .chain(reviewed::DENIED) + .copied() + .filter(|id| crate::cmd::find_command(id).is_none_or(|c| denied_by_rule(c).is_some())) + .collect(); + assert!(stale.is_empty(), "reviewed commands that no command has or a rule refuses: {stale:?}"); + } + + /// Only an effect or a command naming a plug-in counts, at any depth; text that happens to + /// start with `plugin.` doesn't. + #[test] + fn a_plugin_is_named_only_as_an_effect_or_a_command() { + use serde_json::json; + assert!(names_plugin(&json!({"effect": "plugin.test.fx"}))); + assert!(names_plugin(&json!({"id": "plugin.test.fx"}))); + assert!(names_plugin(&json!({"effects": ["builtin.blur", "plugin.test.fx"]}))); + assert!(names_plugin(&json!({"items": [{"effect": "plugin.test.fx", "params": {}}]}))); + assert!(names_plugin(&json!({"command": "plugin.test.run"}))); + assert!(!names_plugin(&json!({"text": "plugin.example"}))); + assert!(!names_plugin(&json!({"name": "plugin.style", "effect": "drop-shadow"}))); + assert!(!names_plugin(&json!({"effect": {"name": "plugin.style"}}))); + } + + /// A file or folder key counts at any depth, in objects and in arrays; a key [`NOT_FILES`] names + /// for the command only at the top level; ordinary nested params don't count. + #[test] + fn a_file_key_counts_at_any_depth() { + use serde_json::json; + let texture = json!({"effect": "distort.glass", "params": {"textureImage": {"path": "/tmp/t.png"}}}); + assert!(names_file("effect.apply", &texture)); + assert!(names_file("effect.setParams", &texture)); + assert!(names_file("effect.apply", &json!({"params": {"layers": [{"name": "a"}, {"folder": "/tmp"}]}}))); + assert!(names_file("select.all", &json!([[[{"url": "file:///etc/hosts"}]]]))); + assert!(!names_file("effect.apply", &json!({"effect": "distort.glass", "params": {"distortion": 5, "textureImage": "frosted"}}))); + assert!(!names_file("object.move", &json!({"dx": 5, "dy": 0, "ids": [1, 2], "opts": {"copy": false}}))); + // `path` is a shape's path in object.setLiveShape, but only as its own param. + assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); + assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); + assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); + // Deep nesting is walked without recursion. + let mut deep = json!({"file": "/tmp/x"}); + for _ in 0..10_000 { + deep = Value::Array(vec![deep]); + } + assert!(names_file("select.all", &deep)); + std::mem::forget(deep); + } + + /// A call reads no file even when a param the checks above don't know carries its path: the + /// command runs with no file readable. Run as an ordinary request, the same texture loads. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_confined_call_reads_no_file() { + use serde_json::json; + let file = std::env::temp_dir().join(format!("vc-plugin-call-texture-{}.png", std::process::id())); + let png = vectorcraft_trace::Raster::from_fn(8, 8, |x, y| if (x + y) % 2 == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }).encode_png(); + std::fs::write(&file, png).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let p = json!({"effect": "distort.glass", "params": {"textureImage": {"path": file.to_string_lossy()}}}); + let e = execute_confined(&mut s, "effect.apply", &p).map(|_| ()).unwrap_err().to_string(); + let read = s.execute("effect.apply", &p).map(|_| ()); + let _ = std::fs::remove_file(&file); + assert!(e.contains("read authority is absent"), "{e}"); + read.unwrap(); + } + + #[test] + fn keys_are_found_as_whole_keys() { + assert!(names_key("{path: a .wasm file}", "path")); + assert!(names_key("{ids?: [id], path?: string}", "path")); + assert!(!names_key("{subpath: 1}", "path")); + assert!(!names_key("{x} on the path: the art", "the")); + assert!(!names_key("{pathData: 1}", "path")); + assert!(names_key("path: x", "path")); + } +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs new file mode 100644 index 000000000..541389bb8 --- /dev/null +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -0,0 +1,679 @@ +//! The commands no rule in [`super::denied`] refuses, each placed in one list: those a plug-in may +//! call ([`ALLOWED`]) and those it may not ([`DENIED`]). A command in neither is refused, and the +//! test `every_command_is_reviewed` fails until it is placed. + +/// Commands a plug-in may call when its manifest lists them, in order. +pub(super) const ALLOWED: &[&str] = &[ + "addon.arrange.grid", + "addon.arrange.groupOverlapping", + "addon.arrange.randomizeOpacity", + "addon.arrange.replace", + "addon.arrange.size", + "addon.arrange.sort", + "addon.arrange.swap", + "addon.cleanup.alignInPlace", + "addon.cleanup.breakText", + "addon.cleanup.check", + "addon.cleanup.combineText", + "addon.cleanup.deepUngroup", + "addon.cleanup.fix", + "addon.cleanup.joinText", + "addon.cleanup.rejoin", + "addon.cleanup.removeBreaks", + "addon.cleanup.replaceFonts", + "addon.cleanup.resetTextTransform", + "addon.cleanup.show", + "addon.cleanup.simplify", + "addon.color.applyNearestSwatch", + "addon.color.fillToStroke", + "addon.color.filter", + "addon.color.nearestSwatch", + "addon.color.select", + "addon.color.strokeToFill", + "addon.color.swatchLegend", + "addon.contour.set", + "addon.pathtools.addPoints", + "addon.pathtools.close", + "addon.pathtools.corner", + "addon.pathtools.cornerRemove", + "addon.pathtools.cornerSet", + "addon.pathtools.crossings", + "addon.pathtools.divideDashes", + "addon.pathtools.equalize", + "addon.pathtools.erase", + "addon.pathtools.expandDashes", + "addon.pathtools.extend", + "addon.pathtools.inspect", + "addon.pathtools.mergeEdge", + "addon.pathtools.removeKeepShape", + "addon.pathtools.setAnchor", + "addon.pathtools.shape.convert", + "addon.pathtools.shape.create", + "addon.pathtools.shape.set", + "addon.pathtools.slideAnchor", + "addon.pathtools.smooth", + "addon.pen.append", + "addon.pen.close", + "addon.pen.closeJoin", + "addon.pen.removePoint", + "addon.pen.reshape", + "addon.pen.segment", + "addon.pen.togglePoint", + "addon.select.byArtboards", + "addon.shapes.box3d", + "addon.shapes.configure", + "addon.shapes.ellipse", + "addon.shapes.polygon", + "addon.shapes.rectangle", + "addon.shapes.spiral", + "addon.shapes.star", + "addon.type.releasePath", + "addon.type.textTweaker", + "appearance.addEffect", + "appearance.addFill", + "appearance.addStroke", + "appearance.clear", + "appearance.copyFrom", + "appearance.duplicateItem", + "appearance.moveItem", + "appearance.reduceToBasic", + "appearance.removeItem", + "appearance.setActiveItem", + "appearance.setItem", + "appearance.showAllHidden", + "appearance.targetContents", + "appearance.transfer", + "artboard.convertToArtboards", + "artboard.delete", + "artboard.deleteEmpty", + "artboard.duplicate", + "artboard.fitToArt", + "artboard.fitToSelection", + "artboard.move", + "artboard.new", + "artboard.rearrange", + "artboard.rename", + "artboard.reorder", + "artboard.setProps", + "artboard.switchOrientation", + "attributes.info", + "attributes.set", + "brush.apply", + "brush.delete", + "brush.duplicate", + "brush.freehand", + "brush.get", + "brush.list", + "brush.move", + "brush.new", + "brush.options", + "brush.remove", + "brush.selectionArt", + "brush.setCurrent", + "brush.unused", + "charStyle.apply", + "charStyle.delete", + "charStyle.duplicate", + "charStyle.edit", + "charStyle.list", + "charStyle.new", + "charStyle.redefine", + "charStyle.rename", + "charStyle.setAttrs", + "charStyle.unused", + "color.convert", + "color.gamutCheck", + "color.harmony", + "color.plates", + "dataset.capture", + "dataset.delete", + "dataset.list", + "dataset.new", + "dataset.next", + "dataset.prev", + "dataset.rename", + "dataset.select", + "dataset.set", + "dataset.update", + "dimension.applyToAll", + "dimension.create", + "dimension.update", + "edit.clear", + "edit.colors.adjustBalance", + "edit.colors.blendFrontToBack", + "edit.colors.blendHorizontally", + "edit.colors.blendVertically", + "edit.colors.invert", + "edit.colors.saturate", + "edit.colors.toCMYK", + "edit.colors.toGrayscale", + "edit.colors.toRGB", + "edit.duplicate", + "edit.findNext", + "edit.findReplace", + "effect.apply", + "effect.deferredStatus", + "effect.duplicate", + "effect.expandAppearance", + "effect.list", + "effect.move", + "effect.remove", + "effect.setParams", + "flattener.preview", + "gradient.selectStop", + "graph.column", + "graph.create", + "graph.design", + "graph.marker", + "graph.setData", + "graph.setType", + "graphicStyle.apply", + "graphicStyle.delete", + "graphicStyle.duplicate", + "graphicStyle.list", + "graphicStyle.merge", + "graphicStyle.move", + "graphicStyle.new", + "graphicStyle.redefine", + "graphicStyle.rename", + "graphicStyle.sortByName", + "graphicStyle.unused", + "guide.add", + "guide.list", + "guide.move", + "guide.remove", + "guide.select", + "guide.setLayer", + "image.info", + "imageTrace.expand", + "imageTrace.make", + "imageTrace.makeAndExpand", + "imageTrace.release", + "imageTrace.setView", + "layer.clippingMask.toggle", + "layer.collectInNew", + "layer.delete", + "layer.duplicate", + "layer.flatten", + "layer.hideOthers", + "layer.highlight", + "layer.locate", + "layer.lockOthers", + "layer.merge", + "layer.move", + "layer.new", + "layer.newSublayer", + "layer.outlineOthers", + "layer.previewAll", + "layer.releaseToLayers", + "layer.releaseToLayersBuild", + "layer.reverse", + "layer.selectAll", + "layer.setCurrent", + "layer.setProps", + "layer.showAll", + "layer.target", + "layer.template", + "layer.unlockAll", + "livePaint.expand", + "livePaint.fill", + "livePaint.gapOptions", + "livePaint.info", + "livePaint.make", + "livePaint.merge", + "livePaint.release", + "livePaint.strokeEdge", + "node.move", + "object.align", + "object.arrange.bringForward", + "object.arrange.bringToFront", + "object.arrange.sendBackward", + "object.arrange.sendToBack", + "object.arrange.sendToCurrentLayer", + "object.blend.expand", + "object.blend.info", + "object.blend.make", + "object.blend.release", + "object.blend.replaceSpine", + "object.blend.reverseFrontToBack", + "object.blend.reverseSpine", + "object.blend.spine.addAnchor", + "object.blend.spine.convertAnchor", + "object.blend.spine.moveAnchor", + "object.blend.spine.removeAnchor", + "object.blend.spine.reshapeSegment", + "object.clippingMask.editContents", + "object.clippingMask.editMask", + "object.clippingMask.make", + "object.clippingMask.release", + "object.compoundPath.make", + "object.compoundPath.release", + "object.compoundShape.expand", + "object.compoundShape.make", + "object.compoundShape.release", + "object.compoundShape.setMode", + "object.convertDocumentColorMode", + "object.createObjectMosaic", + "object.createTrimMarks", + "object.cropImage", + "object.distort", + "object.distribute", + "object.distributeSpacing", + "object.envelope.editContents", + "object.envelope.expand", + "object.envelope.info", + "object.envelope.makeWithMesh", + "object.envelope.makeWithTopObject", + "object.envelope.makeWithWarp", + "object.envelope.options", + "object.envelope.release", + "object.envelope.resetWithMesh", + "object.envelope.resetWithWarp", + "object.envelope.setMeshPoint", + "object.exitIsolation", + "object.expand", + "object.expand.info", + "object.expandBrush", + "object.expandShape", + "object.flattenTransparency", + "object.group", + "object.hide", + "object.hide.above", + "object.hide.otherLayers", + "object.hide.unselected", + "object.intertwine.arrange", + "object.intertwine.edit", + "object.intertwine.make", + "object.intertwine.release", + "object.isolate", + "object.lock", + "object.lock.above", + "object.lock.otherLayers", + "object.lock.unselected", + "object.makePixelPerfect", + "object.maskImage", + "object.mesh.addLine", + "object.mesh.create", + "object.mesh.deletePoint", + "object.mesh.expand", + "object.mesh.movePoint", + "object.mesh.movePoints", + "object.mesh.setPointColor", + "object.move", + "object.nudge", + "object.objectsOnPath.attach", + "object.objectsOnPath.expand", + "object.objectsOnPath.options", + "object.path.addAnchorPoints", + "object.path.cleanUp", + "object.path.divideObjectsBelow", + "object.path.offsetPath", + "object.path.outlineStroke", + "object.path.simplify", + "object.path.smooth", + "object.path.splitIntoGrid", + "object.pathfinder.crop", + "object.pathfinder.divide", + "object.pathfinder.exclude", + "object.pathfinder.intersect", + "object.pathfinder.merge", + "object.pathfinder.minusBack", + "object.pathfinder.minusFront", + "object.pathfinder.outline", + "object.pathfinder.trap", + "object.pathfinder.trim", + "object.pathfinder.unite", + "object.pattern.cancel", + "object.pattern.done", + "object.pattern.edit", + "object.pattern.make", + "object.puppetWarp", + "object.puppetWarp.pins", + "object.rasterize", + "object.reflect", + "object.repeat.expand", + "object.repeat.grid", + "object.repeat.mirror", + "object.repeat.options", + "object.repeat.radial", + "object.repeat.release", + "object.resetBoundingBox", + "object.rotate", + "object.scale", + "object.setBounds", + "object.setLiveShape", + "object.setPixelAligned", + "object.setProps", + "object.shape.convertToShape", + "object.shear", + "object.showAll", + "object.slice.clipToArtboard", + "object.slice.combine", + "object.slice.create", + "object.slice.delete", + "object.slice.deleteAll", + "object.slice.divide", + "object.slice.duplicate", + "object.slice.fromGuides", + "object.slice.fromSelection", + "object.slice.make", + "object.slice.move", + "object.slice.options", + "object.slice.release", + "object.slice.select", + "object.slice.setRect", + "object.textWrap.make", + "object.textWrap.options", + "object.textWrap.release", + "object.transform", + "object.transformAgain", + "object.transformEach", + "object.ungroup", + "object.unlockAll", + "object.vectorHalftone", + "paint.complement", + "paint.default", + "paint.editGradient", + "paint.freeform.addLine", + "paint.freeform.addPoint", + "paint.freeform.deletePoint", + "paint.freeform.get", + "paint.freeform.selectPoint", + "paint.freeform.setPoint", + "paint.freeform.splitLine", + "paint.invert", + "paint.lastColor", + "paint.lastGradient", + "paint.none", + "paint.proxies", + "paint.recent", + "paint.sampleColor", + "paint.setFill", + "paint.setGradientGeom", + "paint.setStroke", + "paint.swap", + "paint.toggleActive", + "paraStyle.apply", + "paraStyle.delete", + "paraStyle.duplicate", + "paraStyle.list", + "paraStyle.new", + "paraStyle.redefine", + "paraStyle.rename", + "paraStyle.setAttrs", + "paraStyle.unused", + "path.appendAnchor", + "path.average", + "path.averageJoin", + "path.blob", + "path.close", + "path.convertAnchor", + "path.convertAnchors", + "path.create", + "path.curvature", + "path.curvatureEdit", + "path.cutAtAnchors", + "path.deleteAnchors", + "path.eraseRegion", + "path.eraseSegments", + "path.freehand", + "path.insertAnchor", + "path.join", + "path.joinScrub", + "path.knife", + "path.lineCut", + "path.mirrorCut", + "path.moveAnchor", + "path.moveAnchors", + "path.rectCut", + "path.removeAnchor", + "path.removeAnchors", + "path.reshape", + "path.reshapeSegment", + "path.reverse", + "path.setAnchors", + "path.setFillRule", + "path.setHandle", + "path.smoothRegion", + "path.split", + "pattern.delete", + "pattern.list", + "pattern.options", + "pattern.transform", + "perspective.attach", + "perspective.draw", + "perspective.editText", + "perspective.grid.define", + "perspective.grid.get", + "perspective.grid.lock", + "perspective.grid.lockStation", + "perspective.grid.rulers", + "perspective.grid.set", + "perspective.grid.show", + "perspective.grid.snap", + "perspective.move", + "perspective.nudge", + "perspective.plane.matchObject", + "perspective.plane.move", + "perspective.plane.set", + "perspective.release", + "perspective.transform", + "recolor.apply", + "recolor.colors", + "recolor.randomize", + "recolor.reduce", + "select.add", + "select.all", + "select.allOnArtboard", + "select.anchors", + "select.anchorsMany", + "select.color", + "select.font", + "select.globalEdit", + "select.globalEditExclude", + "select.globalEditOptions", + "select.inverse", + "select.key", + "select.magicWand", + "select.nextAbove", + "select.nextBelow", + "select.none", + "select.object.allOnSameLayers", + "select.object.areaText", + "select.object.bristleBrushStrokes", + "select.object.brushStrokes", + "select.object.clippingMasks", + "select.object.directionHandles", + "select.object.pointText", + "select.object.slices", + "select.object.strayPoints", + "select.object.textObjects", + "select.recall", + "select.reselect", + "select.same.appearance", + "select.same.appearanceAttribute", + "select.same.blendingMode", + "select.same.fillAndStroke", + "select.same.fillColor", + "select.same.fontFamily", + "select.same.fontFamilyStyle", + "select.same.fontFamilyStyleSize", + "select.same.fontSize", + "select.same.graphicStyle", + "select.same.opacity", + "select.same.shapeType", + "select.same.strokeColor", + "select.same.strokeWeight", + "select.same.symbolInstance", + "select.same.textFillAndStrokeColor", + "select.same.textFillColor", + "select.same.textStrokeColor", + "select.segments", + "select.set", + "select.toggle", + "select.update", + "shape.arc", + "shape.ellipse", + "shape.flare", + "shape.line", + "shape.polarGrid", + "shape.polygon", + "shape.rectangle", + "shape.rectangularGrid", + "shape.spiral", + "shape.star", + "shapeBuilder.merge", + "shapeBuilder.regions", + "shaper.expand", + "shaper.release", + "shaper.releaseSource", + "shaper.scribble", + "shaper.select", + "slice.list", + "stroke.set", + "stroke.setAdvanced", + "stroke.weightDown", + "stroke.weightUp", + "stroke.widthPoint.copy", + "stroke.widthPoint.remove", + "stroke.widthPoint.set", + "svgFilter.apply", + "svgFilter.define", + "svgFilter.delete", + "svgFilter.list", + "svgInteractivity.clearEvents", + "svgInteractivity.info", + "svgInteractivity.removeEvent", + "svgInteractivity.setEvent", + "swatch.addUsedColors", + "swatch.delete", + "swatch.duplicate", + "swatch.edit", + "swatch.editGroup", + "swatch.list", + "swatch.merge", + "swatch.move", + "swatch.new", + "swatch.newGroup", + "swatch.resetDefaults", + "swatch.setSpot", + "swatch.sortByKind", + "swatch.sortByName", + "swatch.spotOptions", + "swatch.ungroup", + "swatch.unused", + "symbol.adjust", + "symbol.delete", + "symbol.duplicate", + "symbol.edit", + "symbol.exitEditing", + "symbol.instance.reset", + "symbol.instance.resetTransform", + "symbol.instance.selectPart", + "symbol.instance.setAppearance", + "symbol.list", + "symbol.new", + "symbol.options", + "symbol.place", + "symbol.replace", + "symbol.selectInstances", + "symbol.setCurrent", + "symbol.spray", + "symbol.unused", + "symbol.update", + "text.areaOptions", + "text.convertListToText", + "text.create", + "text.createInPath", + "text.discardEmpty", + "text.editRange", + "text.fitHeadline", + "text.fonts", + "text.getRange", + "text.glyphAlternates", + "text.insertInline", + "text.missingFonts", + "text.replaceFont", + "text.reshapeArea", + "text.reshapePath", + "text.setFormat", + "text.setGlyphAlternate", + "text.setPathHandle", + "text.setRangeStyle", + "text.setStyle", + "text.setText", + "text.startList", + "text.tabs.clear", + "text.tabs.get", + "text.tabs.set", + "text.thread.add", + "text.thread.break", + "text.thread.create", + "text.thread.info", + "text.thread.releaseSelection", + "text.thread.remove", + "text.touchType", + "transparency.disableOpacityMask", + "transparency.editOpacityMask", + "transparency.enableOpacityMask", + "transparency.info", + "transparency.makeOpacityMask", + "transparency.opacityMaskInfo", + "transparency.releaseOpacityMask", + "transparency.set", + "transparency.setOpacityMask", + "transparency.stopEditingOpacityMask", + "transparency.togglePageIsolatedBlending", + "transparency.togglePageKnockoutGroup", + "transparency.viewOpacityMask", + "type.changeCase", + "type.compositeFonts.list", + "type.convertToAreaType", + "type.convertToPointType", + "type.createOutlines", + "type.dynamicText", + "type.fillPlaceholder", + "type.fitHeadline", + "type.insert", + "type.opticalMarginAlignment", + "type.orientation.horizontal", + "type.orientation.vertical", + "type.pathOptions", + "type.resetHorizontalScale", + "type.resetTracking", + "type.ruby", + "type.scaleTextAttributes", + "type.size.decrease", + "type.size.decreaseByFive", + "type.size.increase", + "type.size.increaseByFive", + "type.smartPunctuation", + "type.step", + "variable.bind", + "variable.define", + "variable.delete", + "variable.highlight", + "variable.list", + "variable.lock", + "variable.makeGraphDynamic", + "variable.makeImageDynamic", + "variable.makeTextDynamic", + "variable.makeVisibilityDynamic", + "variable.rename", + "variable.unbind", +]; + +/// Commands a plug-in may not call although no rule refuses them, in order: they change the +/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run +/// plug-ins again. +pub(super) const DENIED: &[&str] = &[ + "addon.cleanup.splitText", + "addon.pathtools.movePointsTogether", + "addon.pen.nextSegment", + "effect.refreshDeferred", + "graphicStyle.setOptions", + "object.blend.options", + "object.liquify", + "object.pathfinder.options", + "type.compositeFonts.set", +]; diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index ef8b156b6..a92b4095a 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -1,11 +1,12 @@ -//! Preferences (Edit → Preferences): `prefs.get`, `prefs.set`, `prefs.reset`, `prefs.list`. +//! Preferences (Edit → Preferences): `prefs.get`, `prefs.set`, `prefs.reset`, `prefs.list`, +//! `prefs.export`, `prefs.import`. //! //! [`PREF_SPECS`] is the single source of truth for every preference: its category, section, //! label and value kind. The Preferences dialog renders from it and `prefs.set` validates with it. use serde_json::{Map, Value, json}; -use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param, switch_or, text_param}; use crate::Prefs; use crate::units::Measure; use vectorcraft_doc::{GridPrefs, Unit}; @@ -229,6 +230,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("guideColor", "Guides & Grid", "Guides", "Color", color), p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)), p!("lockGuidesByDefault", "Guides & Grid", "Guides", "Lock Guides by Default", bool), + p!("cmdRulerDragArtboardGuides", "Guides & Grid", "Guides", "Artboard Guides with Cmd/Ctrl + Ruler Drag", bool), p!("gridColor", "Guides & Grid", "Grid", "Color", color), p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)), p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", len(1.0, 16383.0, General)), @@ -457,6 +459,9 @@ pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinde /// Validate a value for preference group `key` against `current`. fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result { + if !v.is_object() { + return Err(format!("`{key}` must be an object")); + } match key { "eyedropper" => { let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default(); @@ -692,6 +697,9 @@ impl Session { let history_changed = p.history_states != self.prefs.history_states; let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color; let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder; + if p.plugins_folder.trim().is_empty() && !self.prefs.plugins_folder.trim().is_empty() { + super::plugin::forget_prefs_folder(&self.prefs.plugins_folder); + } let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions; let corner_radius_changed = p.corner_radius != self.prefs.corner_radius; let lock_guides_changed = p.lock_guides_by_default != self.prefs.lock_guides_by_default; @@ -763,15 +771,117 @@ pub fn specs() -> Vec { "Set Preference", [], None, - "{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)", + "{key, value} or {values: {key: value, …}, documentUnits?: bool=true} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step, unless documentUnits is false; the eyedropper group takes a partial object, as eyedropper.setOptions, and a group given anything but an object is an error; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)", always, set ), cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset), cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list), + cmd!( + query "prefs.export", + "Export Preferences", + [], + None, + "{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder and glyphSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}", + always, + export + ), + cmd!( + query "prefs.import", + "Import Preferences", + [], + None, + "{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}", + always, + import + ), ] } +/// The `format` of a preferences file (`prefs.export`, `prefs.import`). +pub const PREFS_FORMAT: &str = "vectorcraft-preferences"; + +/// The `version` `prefs.export` writes; `prefs.import` refuses a newer one. +pub const PREFS_VERSION: u64 = 1; + +/// The largest preferences file `prefs.import` reads. +const MAX_PREFS_FILE_BYTES: usize = 1 << 20; + +/// Folder preferences, which name folders on one computer: a preferences file leaves them out. +const FOLDER_PREFS: &[&str] = &["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"]; + +/// The preferences `prefs.export` writes: every [`PREF_SPECS`] key but the [`FOLDER_PREFS`], and +/// the [`PREF_GROUPS`]. +fn exported(prefs: &crate::Prefs) -> Map { + let all = prefs.to_json(); + let keys = PREF_SPECS.iter().map(|s| s.key).filter(|k| !FOLDER_PREFS.contains(k)).chain(PREF_GROUPS.iter().copied()); + keys.map(|k| (k.to_string(), all[k].clone())).collect() +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "prefs.export"; + let path = text_param(p, "path", C)?; + let prefs = exported(&s.prefs); + let count = prefs.len(); + let file = json!({ "format": PREFS_FORMAT, "version": PREFS_VERSION, "prefs": prefs }); + let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + // A file `prefs.import` would refuse is not written. + if text.len() > MAX_PREFS_FILE_BYTES { + return Err(bad(C, "the file is larger than 1 MB")); + } + match path { + Some(path) => { + super::fileio::write_file(path, text.as_bytes())?; + Ok(json!({"path": path, "count": count})) + } + None => Ok(json!({"data": text, "count": count})), + } +} + +/// The preferences in the bytes of a preferences file, unchecked: `Err` says why the file is not +/// one this version reads. +fn read_prefs_file(bytes: &[u8]) -> std::result::Result, String> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a preferences file: {e}"))?; + let Some(file) = v.as_object() else { return Err("not a preferences file: no JSON object".into()) }; + match file.get("format").and_then(Value::as_str) { + Some(PREFS_FORMAT) => {} + Some(format) => return Err(format!("not a preferences file (format `{format}`)")), + None => return Err("not a preferences file: no `format`".into()), + } + match file.get("version").and_then(Value::as_u64) { + Some(v) if v > PREFS_VERSION => return Err(format!("the file was saved by a newer version of VectorCraft (version {v})")), + Some(1..) => {} + _ => return Err("not a preferences file: no `version`".into()), + } + let Some(prefs) = file.get("prefs").and_then(Value::as_object) else { return Err("not a preferences file: no `prefs`".into()) }; + if prefs.is_empty() { + return Err("the file holds no preferences".into()); + } + if let Some(key) = prefs.keys().find(|k| FOLDER_PREFS.contains(&k.as_str())) { + return Err(format!("`{key}` names a folder, which preferences files leave out")); + } + Ok(prefs.clone()) +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "prefs.import"; + let too_large = || bad(C, "the file is larger than 1 MB"); + let bytes = match (text_param(p, "path", C)?, text_param(p, "data", C)?) { + (Some(path), None) => super::fileio::read_file_head(path, MAX_PREFS_FILE_BYTES + 1)?, + (None, Some(text)) if text.len() > MAX_PREFS_FILE_BYTES => return Err(too_large()), + (None, Some(text)) => text.as_bytes().to_vec(), + _ => return Err(bad(C, "give `path` or `data`")), + }; + if bytes.len() > MAX_PREFS_FILE_BYTES { + return Err(too_large()); + } + let values = read_prefs_file(&bytes).map_err(|e| bad(C, e))?; + let mut next = s.prefs.clone(); + next.set_values(&values).map_err(|e| bad(C, e))?; + s.apply_prefs(next); + Ok(json!({ "imported": values.len() })) +} + fn get(s: &mut Session, p: &Value) -> Result { let all = s.prefs.to_json(); match str_param(p, "key") { @@ -789,6 +899,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if values.is_empty() { return Err(bad("prefs.set", "give {key, value} or {values}")); } + let document_units = switch_or(p, "documentUnits", true, "prefs.set")?; let mut next = s.prefs.clone(); next.set_values(&values).map_err(|e| bad("prefs.set", e))?; if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() { @@ -797,7 +908,7 @@ fn set(s: &mut Session, p: &Value) -> Result { s.prefs.check_folders(&values).map_err(EngineError::Other)?; s.apply_prefs(next); // Units ▸ General is also the open document's units (as Document Setup sets them). - if values.contains_key("unitsGeneral") && s.active().is_some() { + if document_units && values.contains_key("unitsGeneral") && s.active().is_some() { s.set_document_units(s.default_units())?; } let all = s.prefs.to_json(); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 44de123fa..9fe4665a5 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (screen angle, degrees counter-clockwise as PostScript setscreen takes it; default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index d181541c3..8e1c3f5be 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -48,12 +48,13 @@ pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[F Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned()))) } -/// Leave guide paths out of `nodes` (the renderer draws them; they never print). +/// Leave guides out of `nodes`: guide paths (the renderer draws them; they never print) and ruler +/// guides. fn drop_guides(nodes: &mut Vec>) { fn has_guides(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) + n.is_guide() || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) } - nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. })); + nodes.retain(|n| !n.is_guide()); for n in nodes.iter_mut().filter(|n| has_guides(n)) { if let Some(children) = Arc::make_mut(n).children_mut() { drop_guides(children); @@ -76,9 +77,11 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b let mut images = vec![]; for (_, r) in regions { let area = outset(r, bleed); - // At most MAX_PIXELS, and 65535 pixels a side. + // At most MAX_PIXELS, and no side longer than the renderer draws (rounded up to whole + // pixels below). let side = area.width().max(area.height()).max(1.0); - let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side); + let longest = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0; + let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(longest / side); if !(scale.is_finite() && scale > 0.0) { continue; } diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index d41a4529b..566a16dbf 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -263,7 +263,7 @@ fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) { it.effects_mut().retain(keep); } if members { - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { retain_effects(Arc::make_mut(c), true, keep); } } @@ -367,7 +367,9 @@ pub(crate) fn replace_with_image(mut v: Node, image: Node) -> Node { v.appearance = Default::default(); v.set_clips(false); if let Some(ch) = v.children_mut() { - *ch = vec![Arc::new(image)]; + // Its ruler guides stay above the image. + let guides = ch.split_off(Node::guide_run_start(ch)); + *ch = std::iter::once(Arc::new(image)).chain(guides).collect(); } v } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index b8b544881..6f64528cc 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 path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), 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; vc.args is args; 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, 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, 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}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport", + "{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", always, run ), @@ -45,6 +45,33 @@ pub fn specs() -> Vec { always, stop ), + cmd!( + query "script.console", + "Script Console", + [], + None, + "{} the Script Console's lines, oldest first: each run's start, its $.write and $.writeln output, its vc.log entries, its uncaught error as name:line:column: message (name: file:line:column: message when it is in another file), its end and a report of its time, host calls and slowest commands; a session keeps at most 2048 lines and 1 MiB of text, dropping the oldest, a line at most 4096 bytes and a script's name in its lines at most 256 bytes, each cut with … (VectorCraft's own limits); line feeds, carriage returns and the two together break lines, other control characters show as their Unicode Control Pictures → {lines: [{run: the run's number in the session, name, kind: start|output|log|error|end|report, text}], droppedLines, truncatedLines} (an end line's text is ended: finished|error|fuel|hostLimit|limit|stopped|internal)", + always, + console_lines + ), + cmd!( + query "script.console.clear", + "Clear Script Console", + [], + None, + "{} empty the Script Console and set its dropped and cut counts to zero; a running script goes on writing, and scriptReport keeps the last run's console → {cleared: lines removed}", + always, + console_clear + ), + cmd!( + query "script.listInstalled", + "List Installed Scripts", + [], + None, + "{} list the .js and .jsx files of the Scripts folder next to the settings, which File › Scripts lists, each subfolder as a submenu, in menu order; only names are read, never a script's text; links, hidden files, names starting with a dot and folders without a script are left out. VectorCraft's own limits: 2048 folder entries looked at, 512 scripts, 128 subfolders, 4 levels of subfolders, names of 255 bytes, labels of 128 characters, 250 ms → {folder: the Scripts folder's path | null (a session without one, and the web), scripts: [{path, label: the file name without its extension (the whole file name when another row of its folder has that label), folders: [the labels of the subfolders it is in]}], truncated: a limit was reached or a subfolder could not be read, unreadable: the folder is there and could not be read, or is a link}", + always, + list_installed + ), cmd!( query "script.check", "Check Script", @@ -104,10 +131,9 @@ fn run(s: &mut Session, p: &Value) -> Result { if serde_json::to_vec(&args).map_or(true, |b| b.len() > script::MAX_ARGS_BYTES) { return Err(param("`args` is larger than 1 MiB")); } - match p.get("answers") { - None => {} - Some(Value::Object(o)) if o.is_empty() => {} - Some(_) => return Err(param("`answers` must be an empty object: scripts cannot ask questions yet")), + let answers = script::dialogs::parse_answers(p.get("answers")).map_err(param)?; + if s.script_frames && !answers.is_empty() { + return Err(param("`answers` is only for a run with nobody to ask: in the app, the person answers each dialog")); } let fuel = max_fuel(p)?; let text = match (path, source) { @@ -135,7 +161,10 @@ fn run(s: &mut Session, p: &Value) -> Result { _ => return Err(param("give exactly one of `path` and `source`")), }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); - s.script_start(script::Request { name: name.clone(), source: text, args, fuel })?; + let locale = script::dollar::ui_locale(s.ui_language.as_deref().unwrap_or(&s.prefs.interface_language)); + let request = + script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, locale, answers }; + let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); } @@ -146,6 +175,60 @@ fn stop(s: &mut Session, _: &Value) -> Result { Ok(json!({"stopped": s.script_request_stop()})) } +fn console_lines(s: &mut Session, _: &Value) -> Result { + Ok(s.script_console().to_json()) +} + +fn console_clear(s: &mut Session, _: &Value) -> Result { + Ok(json!({"cleared": s.clear_script_console()})) +} + +/// The error for a script that File › Scripts listed and that [`script::folder::read`] no longer +/// reads. +pub const NOT_LISTED: &str = "the script is no longer in the Scripts folder"; + +impl Session { + /// Have File › Scripts and `script.listInstalled` list the scripts of the folder `dir`. The + /// folder is only read, and never made. + pub fn attach_scripts_dir(&mut self, dir: std::path::PathBuf) { + self.scripts_dir = Some(dir); + } + + /// The Scripts folder ([`Session::attach_scripts_dir`]); none on the web, which has no folders. + pub fn scripts_dir(&self) -> Option<&std::path::Path> { + self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) + } + + /// The text of the script at `path`, which a listing of the Scripts folder gave + /// ([`script::folder::read`]), checked as `script.run` checks a file's. + pub fn installed_script(&self, path: &str) -> Result { + let root = self.scripts_dir().ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + crate::file_access::check_read(path).map_err(EngineError::Other)?; + // One byte more than a script may have shows that the file is too large. + let bytes = script::folder::read(root, std::path::Path::new(path), script::MAX_SOURCE_BYTES + 1) + .ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + if bytes.len() > script::MAX_SOURCE_BYTES { + return Err(EngineError::Other(TOO_LARGE.to_string())); + } + script_text(&bytes) + } +} + +fn list_installed(s: &mut Session, _: &Value) -> Result { + let Some(root) = s.scripts_dir() else { + return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); + }; + let folder = root.to_string_lossy(); + crate::file_access::check_read(&folder).map_err(EngineError::Other)?; + let listing = script::folder::list(root); + let scripts: Vec = listing + .scripts() + .into_iter() + .map(|(folders, label, path)| json!({"path": path.to_string_lossy(), "label": label, "folders": folders})) + .collect(); + Ok(json!({"folder": folder, "scripts": scripts, "truncated": listing.truncated, "unreadable": listing.unreadable})) +} + /// The folder a script is in, which its included files are read from. struct Folder { dir: std::path::PathBuf, diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 0f16246df..25bf6db54 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -252,6 +252,18 @@ pub(crate) fn selectable_ids(d: &Document, iso: Option) -> BTreeSet, locks: &LockedArtboards, id: NodeId) -> bool { + is_selectable_within(d, iso, Some(locks), id) +} + +/// [`is_selectable`] without the locked artboards, for an edit's new selection: after the edit, +/// the session takes the art on a locked artboard out of the selection, except the new art the +/// edit made. +pub(crate) fn is_selectable_on_any_artboard(d: &Document, iso: Option, id: NodeId) -> bool { + is_selectable_within(d, iso, None, id) +} + +/// [`is_selectable`], leaving out the locked artboards when `locks` is `None`. +fn is_selectable_within(d: &Document, iso: Option, locks: Option<&LockedArtboards>, id: NodeId) -> bool { let Some(a) = d.ancestry(id) else { return false }; if !a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())) { return false; @@ -262,7 +274,7 @@ pub(crate) fn is_selectable(d: &Document, iso: Option, locks: &LockedArt None => { // The object Select All takes: the outermost one that is not a layer. let top = a.iter().filter_map(|i| d.node(*i)).find(|n| !n.is_layer()); - top.is_some_and(|t| locks.is_empty() || !locks.holds(t)) + top.is_some_and(|t| locks.is_none_or(|l| l.is_empty() || !l.holds(t))) } } } @@ -430,15 +442,16 @@ fn key(s: &mut Session, p: &Value) -> Result { if id.is_some() { return Err(bad("select.key", "give `id` or `guide`, not both")); } - if !sel.guides.contains(&g) { + let gid = super::docmenu::guide_ids(&s.doc()?.doc).get(g).copied(); + let Some(gid) = gid.filter(|id| sel.guides.contains(id)) else { return Err(bad("select.key", format!("guide {g} is not selected"))); - } + }; if sel.is_empty() { return Err(bad("select.key", "select objects to align to the guide")); } s.select(|_, sel| { sel.key = None; - sel.key_guide = Some(g); + sel.key_guide = Some(gid); })?; return key_result(s); } @@ -460,9 +473,9 @@ fn key(s: &mut Session, p: &Value) -> Result { /// `{key}`, with `guide` when a guide is the key. fn key_result(s: &Session) -> Result { - let sel = &s.doc()?.selection; - let mut out = json!({ "key": sel.key }); - if let Some(g) = sel.key_guide { + let st = s.doc()?; + let mut out = json!({ "key": st.selection.key }); + if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) { out["guide"] = json!(g); } Ok(out) diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 5792c44fd..06ec06793 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -340,8 +340,9 @@ fn release(s: &mut Session, _: &Value) -> Result { fn from_guides(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?; + let guides = d.ruler_guides(); let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec { - let mut v: Vec = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); + let mut v: Vec = guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); v.extend([lo, hi]); v.sort_by(f64::total_cmp); v.dedup(); diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 8ae2a49c2..35d1a1277 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -85,6 +85,15 @@ pub fn specs() -> Vec { always, change_word ), + cmd!( + query "text.spelling.editDictionary", + "Edit Custom Dictionary", + [], + None, + "{remove?: [word…], add?: [word…] (each one word, at most 64 characters; at most 10000 of each)} deletes the words of remove that are there and adds those of add, then saves once: all of it, or none of it when the save fails (Edit Custom Dictionary's Done) → {removed: [word…], added: [word…]}", + always, + edit_dictionary + ), cmd!( query "text.spelling.ignoreAll", "Ignore All", @@ -223,6 +232,12 @@ fn suggest(_s: &mut Session, p: &Value) -> Result { Ok(json!({ "suggestions": suggestions })) } +/// `w` as the custom dictionary keeps it (trimmed, lower case), or None when it isn't one word of at +/// most `craft_proof::MAX_CUSTOM_WORD_CHARS` characters: what `text.spelling.addWord` takes. +pub fn custom_word(w: &str) -> Option { + craft_proof::custom_word(w) +} + /// `p`'s `key` as a custom-dictionary word (trimmed, lower case), or an error naming what it must be. fn word_param(p: &Value, key: &str, command: &str) -> Result { let w = str_param(p, key).ok_or_else(|| bad(command, format!("missing `{key}`")))?; @@ -342,6 +357,62 @@ fn change_word(s: &mut Session, p: &Value) -> Result { Ok(json!({ "word": to })) } +/// Most words `text.spelling.editDictionary` takes in each of its lists. +const MAX_EDIT_WORDS: usize = 10_000; + +/// `p`'s `key`, a list of custom-dictionary words (none when it is missing), deduplicated. +fn word_list(p: &Value, key: &str, command: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(BTreeSet::new()) }; + let words = v.as_array().ok_or_else(|| bad(command, format!("`{key}` must be a list of words")))?; + if words.len() > MAX_EDIT_WORDS { + return Err(bad(command, format!("`{key}` holds more than {MAX_EDIT_WORDS} words"))); + } + words + .iter() + .map(|w| { + w.as_str() + .and_then(craft_proof::custom_word) + .ok_or_else(|| bad(command, format!("`{key}` must hold single words of at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS))) + }) + .collect() +} + +/// `text.spelling.editDictionary`: Edit Custom Dictionary's Done. Every delete and add is made, +/// then saved once; when the save fails, all of them are taken back, so a failed Done leaves the +/// dictionary as it was rather than half changed. +fn edit_dictionary(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.spelling.editDictionary"; + let (remove, add) = (word_list(p, "remove", C)?, word_list(p, "add", C)?); + let before: BTreeSet = craft_proof::user_dictionary().into_iter().collect(); + // A word in both lists stays; a word to delete that another window deleted already is skipped. + let removed: Vec = remove.iter().filter(|w| !add.contains(*w) && before.contains(*w)).cloned().collect(); + let added: Vec = add.iter().filter(|w| !before.contains(*w)).cloned().collect(); + if removed.is_empty() && added.is_empty() { + return Ok(json!({ "removed": removed, "added": added })); + } + for w in &removed { + craft_proof::remove_word(w); + } + for w in &added { + craft_proof::add_word(w); + } + let saved = save_dictionary(s, &removed, C); + dictionary_changed(); + saved.inspect_err(|_| { + for w in &added { + craft_proof::remove_word(w); + } + for w in &removed { + craft_proof::add_word(w); + } + // Words the save took in from the file stay, as for one word. + if !added.is_empty() { + retake_file_words(s); + } + })?; + Ok(json!({ "removed": removed, "added": added })) +} + /// `text.spelling.ignoreAll`: Ignore All, for the active document. fn ignore_all(s: &mut Session, p: &Value) -> Result { const C: &str = "text.spelling.ignoreAll"; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 1fb3373c7..e97659bfc 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d73f01eaa..d31031d5d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -153,7 +153,8 @@ fn descends(n: &Node, top: bool) -> bool { /// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents. fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { match n.children() { - Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)), + // A layer's ruler guides have no appearance to set. + Some(ch) if descends(n, top) => ch.iter().filter(|c| !c.is_ruler_guide()).for_each(|c| painted(c, false, out)), _ => out.push(n), } } diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index eaa5b8c54..fe2816ed1 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -753,7 +753,7 @@ fn add(s: &mut Session, p: &Value) -> Result { if let (Paint::Pattern { pattern, .. }, Some(src)) = (&w.paint, &patterns) { // The pattern comes with its swatch; a pattern the document has without one // gets one. - let target = patternlib::import(d, src, pattern); + let target = patternlib::import(d, src, pattern)?; let name = match patternlib::pattern_swatch(d, &target) { Some(name) => name, None => { diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 22cfc74e9..a5f677f5d 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -248,7 +248,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result { /// the instance editing was entered from. The symbol already holds the layer's art ([`sync`]). pub(crate) fn finish(d: &mut Document, sel: &mut Selection) { let Some(se) = d.symbol_edit.take() else { return }; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); match se.instance.filter(|i| d.node(*i).is_some()) { Some(i) => sel.set([i]), None => sel.clear(), @@ -480,18 +480,24 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc], bool), new: /// an instance of the symbol itself. pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> { let Some(se) = d.symbol_edit.clone() else { return Ok(()) }; - let art: Option>> = d.node(se.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the symbol. + let art_of = |d: &Document| -> Option>> { + d.node(se.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()) + }; + let art = art_of(d); let (Some(art), Some(index)) = (art, d.symbols.iter().position(|x| x.name == se.symbol)) else { d.symbol_edit = None; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); return Ok(()); }; // Entering, and edits that left the layer's art alone, write nothing. let same = |b: &[Arc]| b.len() == art.len() && b.iter().zip(&art).all(|(a, b)| Arc::ptr_eq(a, b)); let (old, old_group) = match before.symbol_edit.as_ref().filter(|b| b.layer == se.layer) { None => return Ok(()), - Some(_) if before.node(se.layer).and_then(|l| l.children()).is_some_and(|b| same(b)) => return Ok(()), - Some(b) => (before.node(se.layer).and_then(|l| l.children().cloned()).unwrap_or_default(), b.group), + Some(b) => match art_of(before) { + Some(old) if same(&old) => return Ok(()), + old => (old.unwrap_or_default(), b.group), + }, }; if art.iter().any(|n| holds_symbol(d, n, &se.symbol, &mut HashSet::new())) { return Err(EngineError::Other("a symbol can't hold an instance of itself".into())); diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 1e74eb456..b8cae195c 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -157,6 +157,9 @@ pub struct BuiltinSymbolLibrary { pub const BUILTIN: &[BuiltinSymbolLibrary] = &[ BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes }, BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames }, + BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers }, + BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons }, + BuiltinSymbolLibrary { id: "skill-icons", name: "Skill Icons", make: skill_icons }, ]; /// Built-in library `id`, made once. @@ -221,6 +224,140 @@ fn ui_frames() -> SymbolLibrary { SymbolLibrary { name: "UI Frames".into(), doc } } +/// Map Markers: pin, flag, crosshair and checkpoint for map/HUD art (original, no 9-slice). +fn map_markers() -> SymbolLibrary { + let c = Point::ZERO; + let pin = [(0.0, 22.0), (-9.0, 4.0), (-12.0, -6.0), (-8.0, -16.0), (0.0, -20.0), (8.0, -16.0), (12.0, -6.0), (9.0, 4.0)]; + let flag = [(-16.0, 22.0), (-12.0, 22.0), (-12.0, -8.0), (18.0, -14.0), (-12.0, -20.0), (-12.0, -22.0), (-16.0, -22.0)]; + let pin: Vec = pin.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let flag: Vec = flag.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let markers = [ + ("Pin", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pin, true)), "#e74c3c"), + ("Flag", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&flag, true)), "#3498db"), + ("Crosshair", shapes::star(c, 20.0, 5.0, 4, 0.0), "#2c3e50"), + ("Checkpoint", shapes::polygon(c, 18.0, 4, 0.0), "#27ae60"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in markers { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + let edge = fill.lerp(&Color::BLACK, 0.35); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Map Markers".into(); + SymbolLibrary { name: "Map Markers".into(), doc } +} + +/// Inventory Icons: sword, shield, potion, key and gem for loot/RPG art (original, no 9-slice). +fn inventory_icons() -> SymbolLibrary { + let c = Point::ZERO; + let sword = [ + (0.0, -22.0), + (3.0, -10.0), + (3.0, 6.0), + (8.0, 8.0), + (8.0, 12.0), + (3.0, 12.0), + (3.0, 20.0), + (-3.0, 20.0), + (-3.0, 12.0), + (-8.0, 12.0), + (-8.0, 8.0), + (-3.0, 6.0), + (-3.0, -10.0), + ]; + let shield = [(0.0, -20.0), (16.0, -12.0), (14.0, 8.0), (0.0, 20.0), (-14.0, 8.0), (-16.0, -12.0)]; + let potion = + [(-4.0, -20.0), (4.0, -20.0), (4.0, -12.0), (10.0, -6.0), (12.0, 6.0), (8.0, 18.0), (-8.0, 18.0), (-12.0, 6.0), (-10.0, -6.0), (-4.0, -12.0)]; + let key = [ + (-4.0, -18.0), + (4.0, -18.0), + (6.0, -14.0), + (6.0, -8.0), + (4.0, -6.0), + (4.0, 16.0), + (8.0, 16.0), + (8.0, 20.0), + (-2.0, 20.0), + (-2.0, 12.0), + (0.0, 12.0), + (0.0, -6.0), + (-4.0, -8.0), + (-6.0, -14.0), + ]; + let sword: Vec = sword.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let shield: Vec = shield.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let potion: Vec = potion.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let key: Vec = key.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let icons = [ + ("Sword", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&sword, true)), "#95a5a6"), + ("Shield", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&shield, true)), "#3498db"), + ("Potion", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&potion, true)), "#9b59b6"), + ("Key", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&key, true)), "#f1c40f"), + ("Gem", shapes::polygon(c, 16.0, 6, 0.0), "#1abc9c"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in icons { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + let edge = fill.lerp(&Color::BLACK, 0.35); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Inventory Icons".into(); + SymbolLibrary { name: "Inventory Icons".into(), doc } +} + +/// Skill Icons: attack, defend, heal, buff and ult marks for RPG/UI art (original, no 9-slice). +fn skill_icons() -> SymbolLibrary { + let c = Point::ZERO; + let attack = [(-18.0, 14.0), (-6.0, 2.0), (-2.0, 6.0), (18.0, -14.0), (14.0, -18.0), (-6.0, -2.0), (-10.0, 2.0), (-18.0, 6.0)]; + let defend = [(0.0, -18.0), (14.0, -10.0), (12.0, 6.0), (0.0, 18.0), (-12.0, 6.0), (-14.0, -10.0)]; + let heal = [ + (-4.0, -18.0), + (4.0, -18.0), + (4.0, -4.0), + (18.0, -4.0), + (18.0, 4.0), + (4.0, 4.0), + (4.0, 18.0), + (-4.0, 18.0), + (-4.0, 4.0), + (-18.0, 4.0), + (-18.0, -4.0), + (-4.0, -4.0), + ]; + let buff = [(0.0, -18.0), (10.0, -2.0), (4.0, -2.0), (4.0, 18.0), (-4.0, 18.0), (-4.0, -2.0), (-10.0, -2.0)]; + let attack: Vec = attack.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let defend: Vec = defend.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let heal: Vec = heal.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let buff: Vec = buff.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let icons = [ + ("Attack", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&attack, true)), "#e74c3c"), + ("Defend", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&defend, true)), "#3498db"), + ("Heal", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&heal, true)), "#2ecc71"), + ("Buff", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&buff, true)), "#f1c40f"), + ("Ult", shapes::star(c, 18.0, 7.0, 8, 0.0), "#9b59b6"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in icons { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + let edge = fill.lerp(&Color::BLACK, 0.35); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Skill Icons".into(); + SymbolLibrary { name: "Skill Icons".into(), doc } +} + /// An instance of symbol `name` (what the resource gathering follows). fn instance(name: &str) -> Node { Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) @@ -314,7 +451,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; // Where the symbols land, worked out on a copy: nothing to add and nothing to place is no edit. let mut probe = (*st.doc).clone(); - let imported = clip.import_into(&mut probe, &choices, false); + let imported = clip.import_into(&mut probe, &choices, false)?; let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); 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 }; @@ -322,7 +459,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let mut out = json!({ "library": info.id }); if imported.added > 0 || place { let placed = s.edit("Add to Symbols", |d, sel| { - let imported = clip.import_into(d, &choices, false); + let imported = clip.import_into(d, &choices, false)?; // Every symbol brought in (nested ones too) takes its Symbol Options along. for sym in &clip.symbols { let to = target(&imported.renamed, &sym.name); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index b6d20999a..21ed6d802 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -12,7 +12,7 @@ use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, - index, number_or, point_param, str_param, switch_or, targets, + index, number_or, point_param, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -49,7 +49,7 @@ pub fn specs() -> Vec { "Touch Type", [], None, - "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", + "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", has_doc, touch_type ), @@ -568,7 +568,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) { /// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; - let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect(); + let not_tags = || bad(cmd, "features must be a list of tags"); + let tags: Vec<&str> = v.as_array().ok_or_else(not_tags)?.iter().map(|t| t.as_str().ok_or_else(not_tags)).collect::>()?; if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) { return Err(bad( cmd, @@ -708,8 +709,8 @@ impl CharChange { stroke_opts.weight = Some(w.clamp(0.0, 1000.0)); } let c = Self { - font: str_param(p, "font").map(str::to_string), - style: str_param(p, "style").map(str::to_string), + font: text_param(p, "font", cmd)?.map(str::to_string), + style: text_param(p, "style", cmd)?.map(str::to_string), size: num("size"), leading: auto_or_num(p, "leading", cmd)?, tracking: num("tracking"), @@ -1086,12 +1087,16 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { } /// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it -/// was shaped at (superscript, small caps and Shrink Text to Fit included). +/// was shaped at (superscript, small caps and Shrink Text to Fit included), in horizontal type +/// as its Horizontal Scale draws it ([`vectorcraft_text::spacing_scale`]). fn glyph_em(db: &vectorcraft_text::FontDb, t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option { let face = db.face_by_id(g.font_id)?; - let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0; + let runs = edit::slice_runs(&t.runs, g.byte, g.byte + g.len); + let style = &runs.first()?.style; + // The glyph's own scale takes in the Horizontal Scale. + let h_scale = style.h_scale / 100.0; let [a, b, ..] = g.xf.as_coeffs(); - Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0) + Some(a.hypot(b) * face.units_per_em() / h_scale * vectorcraft_text::spacing_scale(style, t.vertical)).filter(|em| em.is_finite() && *em > 0.0) } /// The current pair correction in thousandths of an em, including an automatic correction. @@ -1275,19 +1280,25 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { // out (overflowing text) is shaped alone in its writing direction, with the rest of its grapheme // in the grapheme's own styles. let plain = t.plain_text(); - let unchosen = holder.as_ref().filter(|_| !styled).and_then(|_| { - let mut probe = t.clone(); - edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); - own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0) - }); + let mut probe = t.clone(); + edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); + let probe_lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); + let unchosen = holder + .as_ref() + .filter(|_| !styled) + .and_then(|_| own_glyph(&plain, &probe_lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0)); let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { None => None, Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), }; - let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid); + // Without a choice, the glyph drawn in whatever face draws the character, as + // `text.glyphAlternates` reports it (a fallback face's glyph, the capital of All Caps). + let drawn = match &choice { + Some(c) => c.gid, + None => drawn_glyph(&plain, &probe_lay, ch, &bytes, &face).or_else(|| shaped_alone(&probe, ch, &bytes, &face).1).unwrap_or(default), + }; // The run holding the character. The request changes nothing when the choice is the one the // character's run holds and that run holds the character alone; otherwise the edit splits the // run, so that the following text does not take the character into a ligature. @@ -1310,6 +1321,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { } } if held == choice && (choice.is_none() || alone) { + // The text is left as it is, so the glyph is the one the unchanged text draws. The probe + // splits a run that holds an earlier character's choice, and the split can break a ligature + // that takes in this character. + let drawn = match &choice { + None if probe.runs != t.runs => { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + drawn_glyph(&plain, &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default) + } + _ => drawn, + }; return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn})); } s.edit("Glyphs", |d, _| { @@ -1602,6 +1623,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { path_effect: Default::default(), path_align: Default::default(), path_spacing: 0.0, + dynamic_text: false, wrap: Vec::new(), optical_margin: false, anti_alias: Default::default(), @@ -1893,6 +1915,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { path_effect: Default::default(), path_align: Default::default(), path_spacing: 0.0, + dynamic_text: false, wrap: Vec::new(), optical_margin: false, anti_alias: Default::default(), diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 3fd8e8f23..89ec47b27 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -976,12 +976,12 @@ fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { } let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para)); let mut trial = (*s.doc()?.doc).clone(); - let mut placed = import_text_styles(&src, &mut trial, chars, paras); + let mut placed = import_text_styles(&src, &mut trial, chars, paras)?; // An undo step only when the document changes. The step takes the document the import made, // unless the edit first ends a typing session, which can change the document it starts from. if trial != *s.doc()?.doc { if super::typing_in_progress(s) { - placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?; + placed = s.edit(label, |d, _| import_text_styles(&src, d, chars, paras))?; } else { s.edit(label, |d, _| { *d = trial; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 73c938dc1..ab0c7d342 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1105,7 +1105,7 @@ fn self_referencing_pattern_is_not_added_again() { let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target, true, true); + super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some()); assert!( dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0] @@ -1116,7 +1116,7 @@ fn self_referencing_pattern_is_not_added_again() { ); let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc, before); } @@ -1148,13 +1148,13 @@ fn separate_self_referencing_patterns_are_not_added_again() { dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); } let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target, true, true); + super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); for k in 0..9 { assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); } let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); assert_eq!(dst.doc().unwrap().doc, before); @@ -1162,7 +1162,7 @@ fn separate_self_referencing_patterns_are_not_added_again() { let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); target.patterns.retain(|p| !p.name.starts_with("Dots8")); let left = target.patterns.len(); - let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1); assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added)); } @@ -1195,13 +1195,13 @@ fn a_long_loop_of_patterns_is_not_added_again() { dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); } let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target, true, true); + super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); for k in 0..9 { assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); } let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); assert_eq!(dst.doc().unwrap().doc, before); diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index ae15ab8b9..6c80c962d 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Create", ["Type", "Threaded Text"], None, - "{} thread the selected area type and type on a path (and closed paths, which become area frames) in stacking order → {ids}", + "{} thread the selected area type and type on a path (and closed paths, which become area frames) in stacking order; a frame with Dynamic Text has it turned off and keeps the sizes it was drawn at → {ids, dynamicTextOff?: [id…] (the frames it was turned off on; left out when none)}", has_multi, create ), @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Thread Text", [], None, - "{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected", + "{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three); a frame with Dynamic Text has it turned off and keeps the sizes it was drawn at → {id, dynamicTextOff?: [id…] (the frames it was turned off on; left out when none)}; the two frames are selected", has_doc, add ), @@ -126,6 +126,11 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } #[cfg(test)] COPIED.with(|c| c.set(c.get() + 1)); + // Type on a path in a thread is not Dynamic Text: its frames keep the sizes it draws them + // at, before the story is taken from them. + if thread.iter().filter(|id| text_of(doc, **id).is_some()).count() >= 2 { + dynamic_text_off(doc, &thread); + } // The story: live frames' runs, plus the text of frames deleted by this edit. let mut story: Vec = vec![]; let mut sources: Vec = vec![]; @@ -264,15 +269,28 @@ fn thread_setting(before: &Document, doc: &Document, live: &[NodeI changed.or(live.first()).and_then(now) } -/// Turn Auto Size off for the frames of each thread of two or more frames, as [`reflow`] does, -/// without re-flowing the story: for a document restored from a file as it was saved, so a later -/// change to a frame's settings keeps the frame's size. +/// Turn Dynamic Text off on the frames among `ids` that have it on, each keeping the sizes it is +/// drawn at ([`super::typemenu::bake_dynamic_text`]); the frames it turned off. +pub(crate) fn dynamic_text_off(doc: &mut Document, ids: &[NodeId]) -> Vec { + let on: Vec = ids.iter().copied().filter(|id| text_of(doc, *id).is_some_and(|t| t.dynamic_text)).collect(); + for id in &on { + if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) { + super::typemenu::bake_dynamic_text(t); + } + } + on +} + +/// Turn Auto Size and Dynamic Text off for the frames of each thread of two or more frames, as +/// [`reflow`] does, without re-flowing the story: for a document restored from a file as it was +/// saved, so a later change to a frame's settings keeps the frame's size. pub(crate) fn keep_frame_sizes(doc: &mut Document) { for thread in doc.text_threads.clone() { let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); if live.len() < 2 { continue; } + dynamic_text_off(doc, &live); for id in live { if text_of(doc, id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight) && let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) @@ -534,6 +552,8 @@ fn create(s: &mut Session, _: &Value) -> Result { return Err(bad(C, "select area type and closed paths (at least one area type object)")); } let out = ids.clone(); + // The frames that lose Dynamic Text: those of the new thread and the threads it takes in. + let off: Vec = with_threads(d, &ids).into_iter().filter(|i| text_of(d, *i).is_some_and(|t| t.dynamic_text)).map(|i| i.0).collect(); s.edit("Thread Text", |d, _| { for id in &ids { if text_of(d, *id).is_none() { @@ -562,7 +582,11 @@ fn create(s: &mut Session, _: &Value) -> Result { touch(d, ids[0]); Ok(()) })?; - Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>() })) + let mut reply = json!({ "ids": out.iter().map(|i| i.0).collect::>() }); + if !off.is_empty() { + reply["dynamicTextOff"] = json!(off); + } + Ok(reply) } /// `text.thread.add`: the loaded text of a port, placed. @@ -573,7 +597,8 @@ fn add(s: &mut Session, p: &Value) -> Result { let before = switch_or(p, "before", false, C)?; let d = &s.doc()?.doc; let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type or type on a path"))?; - let style = src.first_style(); + // A new frame takes the style `from` is drawn in: threading turns its Dynamic Text off. + let style = vectorcraft_text::drawn(src).first_style(); // The frame to thread into: an existing object (`to`), or a new one beside `from`. let (to, new) = match (given("to"), given("rect"), given("at")) { (Some(v), None, None) => { @@ -610,6 +635,8 @@ fn add(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "give one of `to`, `rect` or `at`")), }; let appearance = d.node(from).map(|n| n.appearance.clone()); + // The frames that lose Dynamic Text: those of `from`'s thread and `to`. + let off: Vec = with_threads(d, &[from, to]).into_iter().filter(|i| text_of(d, *i).is_some_and(|t| t.dynamic_text)).map(|i| i.0).collect(); let id = s.edit("Thread Text", |d, sel| { let to = match new { Some(t) => { @@ -645,7 +672,11 @@ fn add(s: &mut Session, p: &Value) -> Result { sel.set([from, to]); Ok(to) })?; - Ok(json!({ "id": id.0 })) + let mut reply = json!({ "id": id.0 }); + if !off.is_empty() { + reply["dynamicTextOff"] = json!(off); + } + Ok(reply) } /// An empty frame of `kind` (area type or type on a path) placed by `xf`, with the type options of @@ -654,6 +685,7 @@ fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, kind: TextKi let mut t = src.clone(); t.runs = vec![TextRun { text: String::new(), style, inline: None }]; t.set_all_paras(src.para_at(0).clone()); + t.dynamic_text = false; t.kind = kind; t.xf = xf; refresh_bounds(&mut t); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index a4fe33762..6d29550fa 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -8,10 +8,10 @@ use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAl use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; -use super::pathops::{len_param, num_param, shape_node}; +use super::pathops::{length_given, number_given, shape_node}; use super::{ CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, - str_param, switch_or, targets, + str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -115,6 +115,15 @@ pub fn specs() -> Vec { has_doc, reshape_path ), + cmd!( + "text.setPathHandle", + "Move Type Path Handle", + [], + None, + "{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)", + has_doc, + set_path_handle + ), cmd!( "text.fitHeadline", "Fit Headline", @@ -240,16 +249,16 @@ fn ruby(s: &mut Session, p: &Value) -> Result { let ids = text_targets(s, p, C)?; let range = TextRange::parse(p, C)?; let remove = switch_or(p, "remove", false, C)?; - let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string); + let reading = text_param(p, "text", C)?.map(str::trim).filter(|t| !t.is_empty()).map(str::to_string); if !remove && reading.is_none() { return Err(bad(C, "give `text` (the reading) or `remove`")); } - let position = match str_param(p, "position").map(str::to_ascii_lowercase).as_deref() { + let position = match text_param(p, "position", C)?.map(str::to_ascii_lowercase).as_deref() { None | Some("above") | Some("right") => RubyPosition::Above, Some("below") | Some("left") => RubyPosition::Below, Some(_) => return Err(bad(C, "`position` is above or below")), }; - let align = match str_param(p, "align").map(str::to_ascii_lowercase).as_deref() { + let align = match text_param(p, "align", C)?.map(str::to_ascii_lowercase).as_deref() { None | Some("center") | Some("centre") => RubyAlign::Center, Some("start") | Some("left") | Some("top") => RubyAlign::Start, Some("end") | Some("right") | Some("bottom") => RubyAlign::End, @@ -587,6 +596,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if texts.is_empty() { return Err(EngineError::Other("Create Outlines: select text objects".into())); } + let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone()); let ended = super::dimension::end_links(d, &roots); let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); let mut out = vec![]; @@ -682,7 +692,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { new_sel.push(gid); } } - super::dimension::restore_unchanged(d, ended); + if let Some(before) = &before { + super::dimension::restore_unchanged(before, d, ended); + } // Outlined frames of threaded text leave their threads with the text they show. super::threads::leave_threads(d, &threaded); sel.set(new_sel); @@ -803,19 +815,21 @@ pub(crate) fn justify_param(v: &str) -> Option { fn set_style(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setStyle"; - let font = str_param(p, "font").map(str::to_string); - let style = str_param(p, "style").map(str::to_string); - let size = len_param(p, "size"); + let font = text_param(p, "font", C)?.map(str::to_string); + let style = text_param(p, "style", C)?.map(str::to_string); + let size = length_given(p, "size", C)?; if size.is_some_and(|v| v <= 0.0) { return Err(bad(C, "size must be positive")); } let leading = match p.get("leading") { None | Some(Value::Null) => None, Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), - Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))), + Some(_) => { + Some(Some(length_given(p, "leading", C).ok().flatten().ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))) + } }; - let tracking = num_param(p, "tracking"); - let justify = match str_param(p, "justify") { + let tracking = number_given(p, "tracking", C)?; + let justify = match text_param(p, "justify", C)? { Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?), None => None, }; @@ -1268,6 +1282,35 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { ok() } +fn set_path_handle(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setPathHandle"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); + let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; + let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); + let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let independent = switch_or(p, "independent", false, C)?; + let node = s.doc()?.doc.node(id); + let on_path = matches!(node.map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })); + if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) { + return Err(bad(C, "not type on a path outside perspective")); + } + s.edit("Reshape Type Path", |doc, _| { + let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let before = t.kind.clone(); + let within = |t: &TextObject| { + t.type_path().and_then(|p| p.bounds()).is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD)) + }; + if !(t.set_path_handle((si, ai), out, pos, independent) && within(t)) { + t.kind = before; + return Err(bad(C, "no such path anchor, or the path would reach past the canvas")); + } + refresh_bounds(t); + Ok(()) + })?; + ok() +} + #[cfg(test)] mod area_tests; diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 88ccb36da..571c9c346 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -2081,3 +2081,35 @@ fn fit_min_percent_for_several_frames_keeps_each_frames_fit() { assert_eq!(fit(&s, plain), vectorcraft_doc::AreaFit::None, "the plain frame doesn't start shrinking"); assert_eq!(fit(&s, shrink), vectorcraft_doc::AreaFit::ShrinkText { min_percent: 30.0 }); } + +/// #3565: `text.setPathHandle` moves a direction handle of type on a path's path, in document +/// space, as `path.setHandle` moves a path's: a smooth anchor's other handle turns with it at +/// its length, `independent` leaves it and makes the anchor a corner; one undo step. +#[test] +fn set_path_handle_reshapes_type_on_a_path() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let path = + s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap(); + let text = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap(); + let id = NodeId(text["id"].as_u64().unwrap()); + let first = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => *t.type_path().unwrap().subpaths[0].anchors.first().unwrap(), + _ => panic!("not text"), + }; + let smooth = first(&s).kind == vectorcraft_geom::AnchorKind::Smooth; + s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 0, "which": "out", "x": 100, "y": 250})).unwrap(); + let a = first(&s); + assert!((a.h_out.x - 100.0).abs() < 1e-6 && (a.h_out.y - 250.0).abs() < 1e-6, "{a:?}"); + if smooth { + assert!((a.h_in.x - 100.0).abs() < 1e-6 && (a.h_in.y - 350.0).abs() < 1e-6, "smooth: the in handle turns with it at its length: {a:?}"); + } + s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 0, "which": "in", "x": 60, "y": 300, "independent": true})).unwrap(); + let b = first(&s); + assert_eq!(b.h_out, a.h_out, "independent: the other handle stays"); + assert_eq!(b.kind, vectorcraft_geom::AnchorKind::Corner); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(first(&s), a, "one undo step each"); + assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor"); + assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type"); +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 8d40002b3..deaac8acc 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -60,6 +60,15 @@ pub fn specs() -> Vec { has_selection, path_options ), + cmd!( + "type.dynamicText", + "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, + dynamic_text + ), cmd!( "type.fillPlaceholder", "Fill with Placeholder Text", @@ -293,11 +302,20 @@ fn to_area(s: &mut Session, p: &Value) -> Result { continue; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + // Across, the frame spans the lines as they are aligned: from their starts to their ends + // without the tracking after their last characters (`LineInfo::trailing`), not to the + // ink, which tracking can pull past an advance. Area type aligns them alike. + let (x0, x1) = lay.lines.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, z), l| (a.min(l.x0), z.max(l.x1 - l.trailing))); + if x0.is_finite() && x1.is_finite() && x0 <= x1 { + (b.x0, b.x1) = (x0, x1); + } + // Line breaking counts that tracking: the slack holds the most of it. + let trailing = lay.lines.iter().map(|l| l.trailing).filter(|t| t.is_finite()).fold(0.0, f64::max); let size = t.first_style().size; // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); - let slack = size * 0.5; + let slack = size * 0.5 + trailing; // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. let (sl, sr) = match t.para_at(0).justify { Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), @@ -502,6 +520,86 @@ fn path_options(s: &mut Session, p: &Value) -> Result { options(s) } +/// Whether type on a path `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> { + 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 frames = threaded_ids(d, &paths); + if !frames.is_empty() { + return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + } + Ok(()) +} + +/// The ids among `ids` of frames of threads, as a list for a message; empty when there are none. +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`]). +fn dynamic_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.dynamicText"; + let (ids, on, changing) = { + 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 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) + }; + if !changing.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); + } + } + 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!({"on": on, "ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) +} + +/// Turn Dynamic Text off on `t`, keeping the sizes its layout draws it at: every run's size, +/// leading and baseline shifts take the fit factor ([`vectorcraft_text::scaled`]). False when it +/// was already off. +pub(crate) fn bake_dynamic_text(t: &mut TextObject) -> bool { + if !t.dynamic_text { + return false; + } + let f = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale; + if f.is_finite() && f != 1.0 { + *t = vectorcraft_text::scaled(t, f); + } + t.dynamic_text = false; + refresh_bounds(t); + true +} + // ---------- placeholder / insert ---------- /// VectorCraft's own placeholder copy (not the classical Latin text). diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index f4db493d6..560da3942 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -23,7 +23,9 @@ static NEW_VIEW: ParamSchema = ParamSchema::new( Param::new("name", Type::String).default_note("the first free \"View N\"").null_omitted(), Param::new("center", Type::Point), Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None }).doc("1 = 100%"), - Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"), + Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }) + .defaults_to("0") + .doc("clockwise on screen, as view.rotate turns the view"), ], "save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once)", ) @@ -46,7 +48,8 @@ static EDIT_VIEWS: ParamSchema = ParamSchema::new( "rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}", ); -static LIST_VIEWS: ParamSchema = ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation}]}").writes(Writes::Nothing); +static LIST_VIEWS: ParamSchema = + ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation (degrees clockwise)}]}").writes(Writes::Nothing); static TRANSPARENCY_GRID: ParamSchema = ParamSchema::new( &[Param::new("on", Type::Boolean).default_note("toggle").null_omitted()], @@ -86,7 +89,7 @@ pub fn specs() -> Vec { "New View…", ["View"], None, - "{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}", + "{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0) (clockwise on screen, as view.rotate turns the view)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}", has_doc, new_view, schema: &NEW_VIEW @@ -101,7 +104,7 @@ pub fn specs() -> Vec { edit_views, schema: &EDIT_VIEWS ), - cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views, schema: &LIST_VIEWS), + cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation (degrees clockwise)}]}", has_doc, list_views, schema: &LIST_VIEWS), cmd!( query "view.transparencyGrid", "Show Transparency Grid", diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 6e89a7544..c79ec0b86 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -137,7 +137,7 @@ fn paintable(doc: &Document) -> Vec { if matches!(n.kind, NodeKind::Compound { .. }) { compounds.push(n.id); out.push(n.id); - } else if !n.is_container() { + } else if !n.is_container() && !n.is_ruler_guide() { out.push(n.id); } }); diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 77068d125..8d0d2b8ee 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -94,6 +94,7 @@ const WAIT_FOR_TOOL_EDIT: &[&str] = &[ "view.separationsPreview", "prefs.set", "prefs.reset", + "prefs.import", "text.addFontFiles", ]; diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d0e486240..08b8a2f29 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -110,6 +110,9 @@ pub struct DocState { /// paths the document links to are looked at within them later too, when the Links panel /// asks about a link outside any caller's scope. pub opened_by: Option>, + /// The live-effect plug-in stages deferred while the document is drawn on screen, and the time + /// budget of its first view ([`vectorcraft_plugins::deferred`]). Not saved. + pub plugin_effects: vectorcraft_plugins::deferred::Deferrals, /// What [`DocState::any_empty_artboards`] last measured. Not saved. empty_artboards: std::cell::RefCell, /// What [`DocState::any_art_to_fit`] last measured. Not saved. @@ -326,6 +329,7 @@ impl DocState { last_perspective: None, opened_by: file_access::current(), empty_artboards: Default::default(), + plugin_effects: Default::default(), art_to_fit: Default::default(), } } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index cdea7ac5b..27ebc9c40 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -23,6 +23,9 @@ //! A path is checked, then opened: a process that can change the folders in between (swapping in //! a link) can still win that race. The roots keep an agent to the files it was given; they don't //! guard against other programs on the computer. +//! +//! [`ScriptScope`] holds the folders a running script's own files (its includes and `$.evalFile`) +//! are read from, inside the roots. use std::cell::RefCell; use std::ffi::OsString; @@ -85,6 +88,12 @@ impl AutomationRoots { Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? }))) } + /// Roots that grant no access of either kind: inside [`confine`] with them, every path a + /// command reads or writes is refused. + pub(crate) fn none() -> Arc { + Arc::new(Self { read: Vec::new(), write: Vec::new() }) + } + /// May the file or folder at `path` be read (or written)? It must lie inside one of the roots /// of that kind. `Err` says why not. pub fn check(&self, path: &str, access: Access) -> Result<(), String> { @@ -341,6 +350,215 @@ fn windows_name_refusal(path: &str) -> Option<&'static str> { }) } +/// The folders a running script's own files are read from: the files its `#include` lines name and +/// the files it passes to `$.evalFile` (`Scope::Script`'s reads). VectorCraft's own rules: a script +/// run from a file may read inside that file's folder and the folders below it, and a script given as +/// text may read none. A path is refused when it has a `..` component, a Windows device name or +/// stream, or names a computer on the network, or when it leads (through its links, junctions +/// included) out of every granted folder. The automation roots ([`check_read`]) apply as well. +/// +/// The file opened is the path with its links followed, and after reading, the path is followed +/// again and must still lead to the same place: a process that changes the folders twice in between +/// can still win that race. +#[derive(Clone, Debug, Default)] +pub(crate) struct ScriptScope { + /// The granted folders, made absolute with their links followed. + roots: Vec, +} + +/// Why a script's file was not read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ScriptRead { + /// There is no such file. + Missing, + /// The scope does not let the script read it. + Refused, + /// The file has more than the bytes asked for. + TooLarge, + /// The file system gave this error. + Failed(String), +} + +impl ScriptScope { + /// The scope of a script run from a file in folder `dir`: that folder. + pub(crate) fn of_folder(dir: &Path) -> Self { + Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() } + } + + /// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or + /// stream, or a computer on the network? + fn refused_by_name(path: &Path) -> bool { + let text = path.to_string_lossy(); + text.contains('\0') + || path.components().any(|c| c == Component::ParentDir) + || (cfg!(windows) && (windows_name_refusal(&text).is_some() || network_path(&text))) + } + + /// The real path of the file at `path` and its first `max` bytes, at most. + pub(crate) fn read(&self, path: &Path, max: usize) -> Result<(PathBuf, Vec), ScriptRead> { + use std::io::Read; + if self.roots.is_empty() || Self::refused_by_name(path) || check_read(&path.to_string_lossy()).is_err() { + return Err(ScriptRead::Refused); + } + let real = match std::fs::canonicalize(path) { + Ok(real) => real, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // A link whose target is missing is there all the same: it is not followed. + return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + } + Err(e) => return Err(ScriptRead::Failed(e.to_string())), + }; + if !self.roots.iter().any(|r| inside(&real, r)) { + return Err(ScriptRead::Refused); + } + let file = std::fs::File::open(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let meta = file.metadata().map_err(|e| ScriptRead::Failed(e.to_string()))?; + if !meta.is_file() { + return Err(ScriptRead::Missing); + } + if meta.len() > max as u64 { + return Err(ScriptRead::TooLarge); + } + let mut bytes = Vec::new(); + // One byte more than asked for shows a file that grew while it was read. + file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| ScriptRead::Failed(e.to_string()))?; + if bytes.len() > max { + return Err(ScriptRead::TooLarge); + } + if std::fs::canonicalize(path).ok().as_ref() != Some(&real) { + return Err(ScriptRead::Refused); + } + Ok((real, bytes)) + } + + /// The first granted folder, as people read it (`None` for a script given as text). + pub(crate) fn folder(&self) -> Option { + self.roots.first().map(|r| crate::cmd::findfiles::plain(r.clone())) + } + + /// Does the absolute path `path` name a granted folder or a place inside one, by its names + /// alone, without regard to case? A path that does not is refused before the file system is + /// asked anything about it. + fn names_inside(&self, path: &Path) -> bool { + let lower = |c: Component| c.as_os_str().to_string_lossy().to_lowercase(); + path.is_absolute() + && self.roots.iter().any(|r| { + let root = crate::cmd::findfiles::plain(r.clone()); + let mut names = path.components(); + root.components().all(|c| names.next().is_some_and(|n| lower(n) == lower(c))) + }) + } + + /// The real path of the file or folder at `path`, which must be absolute: refused as + /// [`ScriptScope::read`] refuses a path, and also when its names alone place it outside every + /// granted folder, in which case the file system is not asked about it. + pub(crate) fn locate(&self, path: &Path) -> Result { + if self.roots.is_empty() || Self::refused_by_name(path) || !self.names_inside(path) { + return Err(ScriptRead::Refused); + } + probed(); + if check_read(&path.to_string_lossy()).is_err() { + return Err(ScriptRead::Refused); + } + 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 }); + } + Err(e) => return Err(ScriptRead::Failed(e.to_string())), + }; + if !self.roots.iter().any(|r| inside(&real, r)) { + return Err(ScriptRead::Refused); + } + Ok(real) + } + + /// 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)?; + let meta = std::fs::metadata(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let link = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()); + #[cfg(windows)] + let hidden = std::os::windows::fs::MetadataExt::file_attributes(&meta) & 2 != 0; + #[cfg(not(windows))] + let hidden = real.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')); + Ok(ScriptEntry { + dir: meta.is_dir(), + file: meta.is_file(), + len: meta.len(), + created: meta.created().ok(), + modified: meta.modified().ok(), + hidden, + readonly: meta.permissions().readonly(), + link, + }) + } + + /// The names in the folder at `path` that `keep` keeps, with whether each is a folder, in the + /// order of their names. A name whose links lead out of every granted folder, or out of the + /// automation read roots in force, is left out. More + /// than `max` names kept, or more than [`MAX_LISTED`] names looked at, is + /// [`ScriptRead::TooLarge`]. + pub(crate) fn list(&self, path: &Path, max: usize, keep: impl Fn(&str) -> bool) -> Result, ScriptRead> { + let real = self.locate(path)?; + let entries = std::fs::read_dir(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let mut out = Vec::new(); + for (n, entry) in entries.enumerate() { + if n >= MAX_LISTED { + return Err(ScriptRead::TooLarge); + } + let Ok(entry) = entry else { continue }; + let name = entry.file_name().to_string_lossy().into_owned(); + if !keep(&name) { + continue; + } + let Ok(target) = std::fs::canonicalize(entry.path()) else { continue }; + if !self.roots.iter().any(|r| inside(&target, r)) || check_read(&entry.path().to_string_lossy()).is_err() { + continue; + } + if out.len() >= max { + return Err(ScriptRead::TooLarge); + } + out.push((name, target.is_dir())); + } + out.sort(); + Ok(out) + } +} + +/// The most names [`ScriptScope::list`] looks at in one folder. +pub(crate) const MAX_LISTED: usize = 65_536; + +/// A file or folder as [`ScriptScope::stat`] gives it. +#[derive(Clone, Debug)] +pub(crate) struct ScriptEntry { + pub dir: bool, + /// A regular file. + pub file: bool, + pub len: u64, + pub created: Option, + pub modified: Option, + pub hidden: bool, + pub readonly: bool, + /// The path itself is a symbolic link. + pub link: bool, +} + +thread_local! { + /// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. + static PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn probed() { + PROBES.with(|p| p.set(p.get().saturating_add(1))); +} + +/// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. +#[cfg(test)] +pub(crate) fn script_probes() -> usize { + PROBES.with(std::cell::Cell::get) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 8dced7fb9..cc545b179 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -171,7 +171,8 @@ pub struct History { /// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here). pub limit: usize, /// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest - /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. + /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. The histories of a + /// session's open documents also share [`Session::history_budget`](crate::Session::history_budget). pub byte_budget: usize, /// Counts the changes made in place to steps below the newest ([`History::put_back`]). pub(crate) rewrites: u64, @@ -339,23 +340,93 @@ impl History { } } -/// The default [`History::byte_budget`]: an eighth of the machine's memory where it is known -/// (Linux), at most 1 GiB (the figure elsewhere); 256 MiB on the web, whose memory is small. +/// Let the oldest steps of `histories` go while together they keep more than `budget` +/// ([`History::bytes`]): undo steps first, the oldest of all the histories first, then redo steps, +/// the furthest ahead first. Each history keeps its last [`History::MIN_STEPS`] undo (and redo) +/// steps, and a held one ([`History::mark`]) keeps all of its steps; their memory still counts. +pub(crate) fn trim_together<'a>(histories: impl IntoIterator, budget: usize) { + let all: Vec<&mut History> = histories.into_iter().collect(); + let mut total = all.iter().fold(0usize, |t, h| t.saturating_add(h.bytes())); + if total <= budget { + return; + } + fn steps(h: &History, redo: bool) -> &[HistoryEntry] { + if redo { &h.redo } else { &h.undo } + } + let mut free: Vec<&mut History> = all.into_iter().filter(|h| !h.held).collect(); + for redo in [false, true] { + // How many of each history's oldest steps go. + let mut gone = vec![0usize; free.len()]; + while total > budget { + // The oldest step any history may still let go. + let next = free + .iter() + .zip(&gone) + .enumerate() + .filter(|(_, (h, n))| steps(h, redo).len().saturating_sub(**n) > History::MIN_STEPS) + .filter_map(|(i, (h, n))| steps(h, redo).get(*n).map(|e| (e.serial, i, e.bytes))) + .min(); + let Some((_, i, bytes)) = next else { break }; + total = total.saturating_sub(bytes); + if let Some(n) = gone.get_mut(i) { + *n += 1; + } + } + for (h, n) in free.iter_mut().zip(gone) { + let steps = if redo { &mut h.redo } else { &mut h.undo }; + steps.drain(..n.min(steps.len())); + } + } +} + +/// What [`Session::trim_histories`](crate::Session::trim_histories) last left: the budget, and for +/// each open document its uid and what a new, undone, redone, merged, recosted, removed or put-back +/// step changes in its history. +pub(crate) type HistoriesSeen = (usize, Vec<(u64, (u64, u64), usize, usize, usize, u64, bool)>); + +impl crate::Session { + /// Let the oldest undo and redo steps of the open documents go while together they keep more + /// than [`Session::history_budget`](crate::Session::history_budget) ([`trim_together`]). While a + /// script runs, its commands leave the histories as they are: the run's end trims them. While an + /// undo group is open in any document, the histories stay as they are too, so a canceled group + /// leaves the other documents' steps in place: closing the groups trims them. When + /// neither the budget nor any history changed since the last call, the steps are not counted + /// again. + pub(crate) fn trim_histories(&mut self) { + if self.script_in_run || self.docs.iter().any(|d| d.undo_group.is_some()) { + return; + } + if self.histories_seen.as_ref() == Some(&self.histories_now()) { + return; + } + trim_together(self.docs.iter_mut().map(|d| &mut d.history), self.history_budget); + self.histories_seen = Some(self.histories_now()); + } + + fn histories_now(&self) -> HistoriesSeen { + let doc = |d: &crate::DocState| { + let h = &d.history; + (d.uid, h.step(), h.undo.len(), h.redo.len(), h.undo.last().map_or(0, |e| e.bytes), h.rewrites, h.held) + }; + (self.history_budget, self.docs.iter().map(doc).collect()) + } +} + +/// The default [`History::byte_budget`] and [`Session::history_budget`](crate::Session::history_budget), +/// which the open documents' histories share: a quarter of the installed memory, or on Linux of the +/// process's cgroup memory limit where that is lower ([`vectorcraft_render::physical_memory`], see +/// [`history_budget_for`]); 1 GiB where the installed memory can't be read, and 256 MiB on the web. pub fn default_history_budget() -> usize { - const MAX: usize = 1 << 30; if cfg!(target_arch = "wasm32") { return 256 << 20; } static BUDGET: std::sync::OnceLock = std::sync::OnceLock::new(); - *BUDGET.get_or_init(|| physical_memory().map_or(MAX, |m| (m / 8).clamp(64 << 20, MAX))) + *BUDGET.get_or_init(|| history_budget_for(vectorcraft_render::physical_memory())) } -/// The machine's memory, where cheap to learn without platform calls. -fn physical_memory() -> Option { - if !cfg!(target_os = "linux") { - return None; - } - let info = std::fs::read_to_string("/proc/meminfo").ok()?; - let kb: usize = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; - kb.checked_mul(1024) +/// The default [`History::byte_budget`] for a reading of `memory` bytes installed: a quarter of it, +/// at least 64 MiB, and at most a quarter of the address space (on 32-bit builds); 1 GiB when the +/// reading is `None` or 0. +pub(crate) fn history_budget_for(memory: Option) -> usize { + memory.filter(|&m| m > 0).map_or(1 << 30, |m| usize::try_from(m / 4).unwrap_or(usize::MAX).clamp(64 << 20, usize::MAX / 4)) } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 14df8529d..347f6734e 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -99,19 +99,25 @@ 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). + // 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). 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["fitScale"] = json!(lay.fit_scale); + } else { + v["dynamicText"] = json!(t.dynamic_text); + v["fitScale"] = json!(lay.fit_scale); } } } _ => {} } if let Some(ch) = n.children() { + // A layer's ruler guides are listed by `guide.list`, not as its objects. + let ch: Vec<&std::sync::Arc> = ch.iter().filter(|c| !c.is_ruler_guide()).collect(); let total = ch.len(); let take_n = opts.child_limit.map_or(total, |l| l.min(total as u64) as usize); if opts.depth.is_none_or(|d| d > 0) { @@ -133,7 +139,8 @@ pub fn document(s: &Session) -> Value { document_opts(s, SummaryOpts::default()) } -/// An artboard's name and rectangle, its background color (`#rrggbb`) when it has one, +/// An artboard's name and rectangle, its background color (`#rrggbb`) when it has one, its guide +/// label (a layer color's name or `#rrggbb`) when it has one, /// `locked: true` when it is locked, `showCenterMark: true`, `showCrossHairs: true` and /// `showVideoSafeAreas: true` when it shows them, and `videoRulerPixelAspectRatio` when it isn't 1. fn artboard_summary(a: &vectorcraft_doc::Artboard) -> Value { @@ -141,6 +148,9 @@ fn artboard_summary(a: &vectorcraft_doc::Artboard) -> Value { if let Some(c) = a.color { v["color"] = json!(c.to_hex()); } + if let Some(c) = a.guide_label { + v["guideLabel"] = json!(c.label_text()); + } for (key, on) in [ ("locked", a.locked), ("showCenterMark", a.show_center_mark), @@ -199,6 +209,10 @@ fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec { } fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec, out: &mut Vec>) { + // Ruler guides are not objects (`guide.list` lists them). + if n.is_ruler_guide() { + return; + } if n.kind_label().eq_ignore_ascii_case(kind) { out.push((n, path.clone())); } @@ -291,7 +305,7 @@ fn document_base(s: &Session) -> Value { // The key object Align aligns to and Distribute Spacing spaces from (`select.key`). "key": st.selection.key.map(|k| k.0), // The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`. - "keyGuide": st.selection.key_guide, + "keyGuide": st.selection.key_guide.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), @@ -428,7 +442,8 @@ pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec, } fn walk(nodes: &[Arc], w: &mut Walk) { - for n in nodes.iter().rev() { + // Ruler guides are not objects (`guide.list` lists them). + for n in nodes.iter().rev().filter(|n| !n.is_ruler_guide()) { if w.filter.hits(n, &w.symbols) { w.ids.push(n.id); if (w.out.len() as u64) < w.limit { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d18afc87e..091348a6b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -59,6 +59,13 @@ pub struct Session { pub clipboard: Clipboard, /// Executed commands (for actions and debugging), the most recent ones kept ([`Journal`]). pub journal: Journal, + /// About the most memory the undo steps of all the open documents may keep together + /// ([`History::bytes`]): past it the oldest steps of any of them go, but each keeps its last + /// [`History::MIN_STEPS`] undo (and redo) steps. [`default_history_budget`] by default. + pub history_budget: usize, + /// The budget and histories [`Session::trim_histories`] last left, so it does not count the + /// steps again while none of them changed. + pub(crate) histories_seen: Option, pub(crate) tool: Box, /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). @@ -155,9 +162,15 @@ pub struct Session { /// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process /// ([`Session::attach_profile_store`]). profile_dir: Option, + /// The file Edit › Color Settings are kept in; `None`: only for the process + /// ([`Session::attach_color_settings`]). + color_settings_file: Option, /// Where the plug-ins `plugin.install` installs are kept; `None`: only for the process /// ([`Session::attach_plugin_store`]). plugin_dir: Option, + /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder + /// ([`Session::attach_scripts_dir`]). + scripts_dir: Option, /// The plug-in ids whose grant in the plug-in store should be gone but could not be removed: /// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful /// [`Session::store_grant`] for the id takes it out. @@ -249,6 +262,10 @@ pub struct Session { pub(crate) script_stop: vectorcraft_js::Interrupt, /// The report of the run that ended last. pub(crate) script_report: Option, + /// The Script Console's lines ([`script::console`]). + pub(crate) script_console: script::console::Console, + /// The runs started in this session, which numbers each run's console lines. + pub(crate) script_runs: u64, /// `script.run` starts the job and returns, and the frontend advances it once per frame /// ([`Session::script_pump`]). Off, `script.run` advances it to its end. pub script_frames: bool, @@ -354,6 +371,8 @@ use command_sets::given_ids; pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; pub use error::{EngineError, Result, SCRIPT_BUSY}; +#[cfg(test)] +use history::history_budget_for; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; pub(crate) use interaction::PreviewCopies; pub use interaction::{Interaction, LastTransform, UndoGroup, UndoScope}; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 9ea86a21e..aa8b5c7f4 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -124,6 +124,9 @@ pub struct Prefs { pub guide_style: String, /// Lock Guides by Default: the app starts with View › Guides › Lock Guides on. pub lock_guides_by_default: bool, + /// Artboard Guides with Cmd/Ctrl + Ruler Drag: Cmd/Ctrl held when a guide is dragged out of a + /// ruler makes it a guide of the active artboard, or with the Artboard tool a canvas guide. + pub cmd_ruler_drag_artboard_guides: bool, pub grid_color: String, pub grid_style: String, pub gridline_every: f64, @@ -434,6 +437,7 @@ impl Default for Prefs { guide_color: s("#4affff"), guide_style: s("lines"), lock_guides_by_default: false, + cmd_ruler_drag_artboard_guides: false, grid_color: s("#c8c8c8"), grid_style: s("lines"), gridline_every: 72.0, diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index b28cc23ca..22e0ce956 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -45,7 +45,7 @@ pub(crate) fn doc_sane(d: &Document, sel: &Selection) -> bool { && d.ruler_origin.x.abs() <= MAX_COORD && d.ruler_origin.y.abs() <= MAX_COORD && d.artboards.iter().all(|a| rect_in_range(a.rect)) - && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) + && d.ruler_guides().iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && objects_in_range(d, &sel.objects) } diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index 61ec8f0fd..fb08bb649 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -26,10 +26,10 @@ use crate::cmd::{CommandSpec, bad, command_specs}; use crate::{EngineError, Result}; /// The API level of the commands' parameters. -pub const API_VERSION: u32 = 2; +pub const API_VERSION: u32 = 3; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { diff --git a/crates/engine/src/script/console.rs b/crates/engine/src/script/console.rs new file mode 100644 index 000000000..a0e3ee6d0 --- /dev/null +++ b/crates/engine/src/script/console.rs @@ -0,0 +1,316 @@ +//! The Script Console: what scripts write with `$.write` and `$.writeln`, what they log with +//! `vc.log`, their uncaught errors, and a start, an end and a report line for each run. +//! +//! A session keeps its own console, and each run keeps one more for its report. Each holds at most +//! [`MAX_CONSOLE_LINES`] lines and [`MAX_CONSOLE_BYTES`] of text, dropping its oldest lines first, +//! and a line holds at most [`MAX_LINE_BYTES`], cut at a character boundary and ended with +//! [`CUT_MARK`]. A script's name in its lines is cut the same way to [`MAX_NAME_BYTES`]. These are +//! VectorCraft's own limits. A line break is a line feed, a carriage +//! return or the two together. Other control characters show as their Unicode Control Pictures +//! (U+2400 to U+2421), and the line and paragraph separators as U+2424. + +use std::collections::VecDeque; +use std::sync::Arc; + +use serde_json::{Value, json}; + +/// The most lines a console keeps. +pub const MAX_CONSOLE_LINES: usize = 2_048; +/// The most bytes of text (UTF-8) a console keeps. +pub const MAX_CONSOLE_BYTES: usize = 1 << 20; +/// The most bytes (UTF-8) of one line, its cut mark included. +pub const MAX_LINE_BYTES: usize = 4_096; +/// The most bytes (UTF-8) of a script's name as console lines give it, its cut mark included. +pub const MAX_NAME_BYTES: usize = 256; +/// The end of a line that was cut. +pub const CUT_MARK: char = '…'; + +/// How a run ended, as its end line gives it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ending { + /// The script ran to its end and its changes were kept. + Finished, + /// An uncaught error or a syntax error. + Error, + /// The instructions ran out (`maxFuel`). + Fuel, + /// A host limit: host calls, data passed to commands, growth, or a command's fault. + HostLimit, + /// Another limit of the interpreter: heap, call depth or a nested run that could not pause. + Limit, + /// `script.stop`, Stop or a cancel from the caller. + Stopped, + /// A fault inside VectorCraft. + Internal, +} + +impl Ending { + /// The id `script.console` gives. + pub fn id(self) -> &'static str { + match self { + Ending::Finished => "finished", + Ending::Error => "error", + Ending::Fuel => "fuel", + Ending::HostLimit => "hostLimit", + Ending::Limit => "limit", + Ending::Stopped => "stopped", + Ending::Internal => "internal", + } + } +} + +/// A command among the slowest of a run: its id, how often it ran and how long it took. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Slow { + pub id: String, + pub calls: u64, + pub total_ms: u64, + pub max_ms: u64, +} + +/// What a console line holds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Entry { + /// The run started. + Start, + /// A line of `$.write` and `$.writeln` output. + Output(String), + /// A `vc.log` entry: a string as it is, any other value as JSON. + Log(String), + /// The uncaught error that ended the run: `name:line:column: message`. + Error(String), + /// The run ended. + End(Ending), + /// The run's report: its time (when the platform has a clock), host calls and slowest commands. + Report { ms: Option, host_calls: u64, slowest: Vec }, +} + +impl Entry { + /// The id of the line's kind that `script.console` gives. + pub fn kind(&self) -> &'static str { + match self { + Entry::Start => "start", + Entry::Output(_) => "output", + Entry::Log(_) => "log", + Entry::Error(_) => "error", + Entry::End(_) => "end", + Entry::Report { .. } => "report", + } + } + + /// The line's text in English, as `script.console` and the run report give it. + pub fn text(&self) -> String { + match self { + Entry::Start => "started".to_string(), + Entry::Output(t) | Entry::Log(t) | Entry::Error(t) => t.clone(), + Entry::End(e) => format!("ended: {}", e.id()), + Entry::Report { ms, host_calls, slowest } => { + let time = ms.map_or_else(|| "time unavailable".to_string(), |ms| format!("{ms} ms")); + let calls = if *host_calls == 1 { "host call" } else { "host calls" }; + let mut s = format!("{time}, {host_calls} {calls}"); + if !slowest.is_empty() { + let list: Vec = slowest.iter().map(|c| format!("{} ×{} {} ms", c.id, c.calls, c.total_ms)).collect(); + s.push_str("; slowest: "); + s.push_str(&list.join(", ")); + } + s + } + } + } + + /// The bytes the line counts toward [`MAX_CONSOLE_BYTES`]. + fn bytes(&self) -> usize { + match self { + Entry::Output(t) | Entry::Log(t) | Entry::Error(t) => t.len(), + Entry::Start | Entry::End(_) => 16, + Entry::Report { slowest, .. } => 48 + slowest.iter().map(|c| c.id.len() + 24).sum::(), + } + } +} + +/// A console line: the run it belongs to, by its number in the session, and the script's name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConsoleLine { + pub run: u64, + pub name: Arc, + pub entry: Entry, +} + +/// A bounded store of console lines. +#[derive(Clone, Debug, Default)] +pub struct Console { + lines: VecDeque, + bytes: usize, + dropped: u64, + truncated: u64, + /// The number of the first line kept: every line the console was given has its own number, + /// counted from 0, which dropping and clearing do not change. + first: u64, +} + +impl Console { + /// The lines kept, oldest first. + pub fn lines(&self) -> &VecDeque { + &self.lines + } + + /// The number of [`Console::lines`]' first line; each line after it has the next number. + pub fn first_number(&self) -> u64 { + self.first + } + + /// The lines dropped to make room since the console was last cleared. + pub fn dropped_lines(&self) -> u64 { + self.dropped + } + + /// The lines cut to [`MAX_LINE_BYTES`] since the console was last cleared. + pub fn truncated_lines(&self) -> u64 { + self.truncated + } + + /// Removes every line and sets the counts to zero. + pub fn clear(&mut self) { + *self = Self { first: self.first + self.lines.len() as u64, ..Self::default() }; + } + + /// Adds a line, dropping the oldest ones past the limits. `cut`: the line's text was cut. + pub(crate) fn push(&mut self, line: ConsoleLine, cut: bool) { + if cut { + self.truncated = self.truncated.saturating_add(1); + } + let bytes = line.entry.bytes(); + while !self.lines.is_empty() && (self.lines.len() >= MAX_CONSOLE_LINES || self.bytes + bytes > MAX_CONSOLE_BYTES) { + if let Some(old) = self.lines.pop_front() { + self.bytes -= old.entry.bytes(); + self.dropped = self.dropped.saturating_add(1); + self.first += 1; + } + } + self.bytes += bytes; + self.lines.push_back(line); + } + + /// `{lines: [text…], droppedLines, truncatedLines}`, as a run report gives its console. + pub(crate) fn report(&self) -> Value { + let lines: Vec = self.lines.iter().map(|l| l.entry.text()).collect(); + json!({"lines": lines, "droppedLines": self.dropped, "truncatedLines": self.truncated}) + } + + /// `{lines: [{run, name, kind, text}…], droppedLines, truncatedLines}`, as `script.console` + /// gives it. + pub fn to_json(&self) -> Value { + let lines: Vec = + self.lines.iter().map(|l| json!({"run": l.run, "name": &*l.name, "kind": l.entry.kind(), "text": l.entry.text()})).collect(); + json!({"lines": lines, "droppedLines": self.dropped, "truncatedLines": self.truncated}) + } +} + +/// `c` as the console shows it: a control character as its Control Picture, a line or paragraph +/// separator as U+2424, anything else as it is. +pub(crate) fn shown(c: char) -> char { + match c { + '\u{0}'..='\u{1f}' => char::from_u32(0x2400 + c as u32).unwrap_or('\u{fffd}'), + '\u{7f}' => '\u{2421}', + '\u{80}'..='\u{9f}' => '\u{fffd}', + '\u{2028}' | '\u{2029}' => '\u{2424}', + c => c, + } +} + +/// Adds `text` to `line` as the console shows it, up to [`MAX_LINE_BYTES`]. → `true` when part of +/// it did not fit. +fn append(line: &mut String, text: &str) -> bool { + for c in text.chars().map(shown) { + if line.len() + c.len_utf8() > MAX_LINE_BYTES { + return true; + } + line.push(c); + } + false +} + +/// A script's name as console lines give it: shown as a line shows its text, and cut to +/// [`MAX_NAME_BYTES`] with [`CUT_MARK`]. +pub(crate) fn line_name(name: &str) -> Arc { + let mut out = String::new(); + for c in name.chars().map(shown) { + if out.len() + c.len_utf8() > MAX_NAME_BYTES { + mark_cut(&mut out, MAX_NAME_BYTES); + break; + } + out.push(c); + } + Arc::from(out) +} + +/// Ends a line that was cut with [`CUT_MARK`], within `limit` bytes. +fn mark_cut(line: &mut String, limit: usize) { + let mut at = line.len().min(limit - CUT_MARK.len_utf8()); + while !line.is_char_boundary(at) { + at -= 1; + } + line.truncate(at); + line.push(CUT_MARK); +} + +/// What `$.write` has written of the line it has not ended yet. +#[derive(Debug, Default)] +pub(crate) struct Pending { + text: String, + /// Part of the line did not fit; the rest is left out until the line ends. + cut: bool, + /// The last text written ended with a carriage return, so a line feed next ends no line. + after_cr: bool, +} + +impl Pending { + /// Writes `text`, calling `done` with each line it ends and whether that line was cut. + pub(crate) fn write(&mut self, text: &str, mut done: impl FnMut(String, bool)) { + if text.is_empty() { + return; + } + let mut rest = text; + if self.after_cr && rest.starts_with('\n') { + rest = &rest[1..]; + } + self.after_cr = false; + while let Some(at) = rest.find(['\r', '\n']) { + self.add(&rest[..at]); + done(self.take_line(), self.cut); + self.cut = false; + let skip = if rest[at..].starts_with("\r\n") { 2 } else { 1 }; + if skip == 1 && rest[at..].starts_with('\r') && at + 1 == rest.len() { + self.after_cr = true; + } + rest = &rest[at + skip..]; + } + self.add(rest); + } + + /// Ends the current line, empty or not. + pub(crate) fn end_line(&mut self, mut done: impl FnMut(String, bool)) { + let cut = self.cut; + done(self.take_line(), cut); + self.cut = false; + self.after_cr = false; + } + + /// Is part of a line waiting for its end? + pub(crate) fn is_empty(&self) -> bool { + self.text.is_empty() && !self.cut + } + + fn add(&mut self, text: &str) { + if !self.cut && !text.is_empty() { + self.cut = append(&mut self.text, text); + } + } + + fn take_line(&mut self) -> String { + if self.cut { + mark_cut(&mut self.text, MAX_LINE_BYTES); + } + std::mem::take(&mut self.text) + } +} diff --git a/crates/engine/src/script/dialogs.rs b/crates/engine/src/script/dialogs.rs new file mode 100644 index 000000000..1faf624f1 --- /dev/null +++ b/crates/engine/src/script/dialogs.rs @@ -0,0 +1,364 @@ +//! `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and +//! `prompt(message, preset[, title])`: the dialogs a script opens while it runs. +//! +//! A message and a preset arrive as the string `String(value)` gives, a title the same way unless +//! it is left out or `undefined` ([`DEFAULT_TITLE`]), and `errorIcon` and `noAsDflt` as the +//! boolean `Boolean(value)` gives. `alert` returns `undefined`, `confirm` `true` for Yes and +//! `false` for No, and `prompt` the text in its field for OK and `null` for Cancel. +//! +//! In the app the run waits at the call for the person's answer, and spends no fuel while it +//! waits; every dialog also has Stop, which ends the run and puts everything back. A run with +//! nobody to ask takes its answers, in order, from `script.run`'s `answers`; a call with no +//! answer left, or with the next answer for another kind of dialog, ends the run. The report's +//! `dialogs` lists each dialog and its answer. +//! +//! The values here are VectorCraft's own limits: [`MAX_MESSAGE_BYTES`], [`MAX_TITLE_BYTES`], +//! [`MAX_PROMPT_BYTES`], [`MAX_ANSWERS`], [`MAX_ANSWERS_BYTES`], [`MAX_RECORDS`] and +//! [`MAX_RECORD_BYTES`]. + +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; + +use serde_json::{Map, Value, json}; +use vectorcraft_js::{Arg, Data, HostError, HostFunction, Realm, RunError}; + +use super::{Host, console}; + +/// The most bytes (UTF-8) of a dialog's message. +pub const MAX_MESSAGE_BYTES: usize = 16 << 10; +/// The most bytes (UTF-8) of a dialog's title. +pub const MAX_TITLE_BYTES: usize = 256; +/// The most bytes (UTF-8) of a prompt's preset and of the text it returns. +pub const MAX_PROMPT_BYTES: usize = 16 << 10; +/// The most answers `script.run` takes. +pub const MAX_ANSWERS: usize = 10_000; +/// The most bytes of `answers`, as JSON. +pub const MAX_ANSWERS_BYTES: usize = 1 << 20; +/// The most dialogs a report lists. +pub const MAX_RECORDS: usize = 10_000; +/// The most bytes of the dialogs a report lists, as JSON. +pub const MAX_RECORD_BYTES: usize = 1 << 20; +/// A dialog's title when the script gives none, in English; the app translates it when it shows it. +pub const DEFAULT_TITLE: &str = "Script Alert"; + +/// Which dialog a script opened. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DialogKind { + Alert, + Confirm, + Prompt, +} + +impl DialogKind { + /// The function's name, which is also the `kind` of an answer and of a report's dialog. + pub fn id(self) -> &'static str { + match self { + Self::Alert => "alert", + Self::Confirm => "confirm", + Self::Prompt => "prompt", + } + } +} + +/// A dialog a script opened, which waits for its answer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScriptDialog { + pub kind: DialogKind, + pub message: String, + /// `None`: the script gave no title ([`DEFAULT_TITLE`]). + pub title: Option, + /// `alert`: show the error icon. + pub error_icon: bool, + /// `confirm`: No is the default button. + pub no_as_default: bool, + /// `prompt`: the text its field starts with. + pub preset: String, +} + +/// The answer to a [`ScriptDialog`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DialogAnswer { + /// OK, for `alert`. + Ok, + /// Yes (`true`) or No, for `confirm`. + Confirm(bool), + /// OK with the field's text, or Cancel (`None`), for `prompt`. + Prompt(Option), +} + +impl DialogAnswer { + pub fn kind(&self) -> DialogKind { + match self { + Self::Ok => DialogKind::Alert, + Self::Confirm(_) => DialogKind::Confirm, + Self::Prompt(_) => DialogKind::Prompt, + } + } + + /// What the function returns. + fn data(&self) -> Data { + match self { + Self::Ok => Data::Undefined, + Self::Confirm(b) => Data::Bool(*b), + Self::Prompt(Some(t)) => Data::String(t.clone()), + Self::Prompt(None) => Data::Null, + } + } + + /// The answer as a report gives it: `"ok"`, a boolean, a string or `null`. + fn json(&self) -> Value { + match self { + Self::Ok => json!("ok"), + Self::Confirm(b) => json!(b), + Self::Prompt(t) => json!(t), + } + } +} + +/// `text` as a dialog shows it. With `lines`, a line feed, a carriage return or the two together +/// is a line break; every other control character, and with `lines` false every line break too, +/// shows as its Unicode Control Picture, as the Script Console shows it. +pub fn shown_text(text: &str, lines: bool) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' if lines => { + chars.next_if_eq(&'\n'); + out.push('\n'); + } + '\n' if lines => out.push('\n'), + c => out.push(console::shown(c)), + } + } + out +} + +/// The longest start of `text` that is at most `max` bytes, cut at a character boundary. +pub fn cut_to(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut end = max; + while !text.is_char_boundary(end) { + end -= 1; + } + text.get(..end).unwrap_or_default() +} + +/// `script.run`'s `answers`: a list of `{kind: "alert", value: "ok"}`, `{kind: "confirm", value: +/// true | false}` and `{kind: "prompt", value: text | null}`. An empty object, which the parameter +/// took before dialogs existed, is an empty list. +pub(crate) fn parse_answers(v: Option<&Value>) -> Result, String> { + let list = match v { + None => return Ok(Vec::new()), + Some(Value::Object(o)) if o.is_empty() => return Ok(Vec::new()), + Some(Value::Array(a)) => a, + Some(_) => return Err("`answers` must be a list of answers".into()), + }; + if list.len() > MAX_ANSWERS { + return Err(format!("`answers` has more than {MAX_ANSWERS} answers")); + } + if serde_json::to_vec(list).map_or(true, |b| b.len() > MAX_ANSWERS_BYTES) { + return Err("`answers` is larger than 1 MiB".into()); + } + list.iter().enumerate().map(|(i, a)| answer(a).map_err(|e| format!("`answers[{i}]` {e}"))).collect() +} + +fn answer(a: &Value) -> Result { + let Some(o) = a.as_object() else { return Err("must be an object with `kind` and `value`") }; + if o.len() != 2 || !o.contains_key("value") { + return Err("must have exactly `kind` and `value`"); + } + match (o.get("kind").and_then(Value::as_str), &o["value"]) { + (Some("alert"), Value::String(s)) if s == "ok" => Ok(DialogAnswer::Ok), + (Some("alert"), _) => Err("is an alert's answer, whose `value` must be \"ok\""), + (Some("confirm"), Value::Bool(b)) => Ok(DialogAnswer::Confirm(*b)), + (Some("confirm"), _) => Err("is a confirm's answer, whose `value` must be true or false"), + (Some("prompt"), Value::Null) => Ok(DialogAnswer::Prompt(None)), + (Some("prompt"), Value::String(s)) if s.len() <= MAX_PROMPT_BYTES => Ok(DialogAnswer::Prompt(Some(s.clone()))), + (Some("prompt"), Value::String(_)) => Err("is longer than 16 KiB"), + (Some("prompt"), _) => Err("is a prompt's answer, whose `value` must be a string or null"), + _ => Err("must have `kind` alert, confirm or prompt"), + } +} + +/// The dialogs of one run: the answers left for a run with nobody to ask, the dialog that waits +/// for the person's answer, and the list the report gives. +pub(crate) struct Dialogs { + /// Answers come from the person, not from `answers`. + framed: bool, + /// The realm's [`Realm::wait_flag`]. + can_wait: Rc>, + answers: RefCell>, + waiting: RefCell>, + /// The error that ended the run when an answer could not reach the script. + failed: RefCell>, + records: RefCell>, + record_bytes: Cell, + count: Cell, + truncated: Cell, +} + +impl Dialogs { + pub(crate) fn new(framed: bool, realm: &Realm, answers: Vec) -> Self { + Self { + framed, + can_wait: realm.wait_flag(), + answers: RefCell::new(answers.into()), + waiting: RefCell::default(), + failed: RefCell::default(), + records: RefCell::default(), + record_bytes: Cell::new(0), + count: Cell::new(0), + truncated: Cell::new(false), + } + } + + /// The dialog that waits for its answer. + pub(crate) fn waiting(&self) -> Option { + self.waiting.borrow().clone() + } + + pub(crate) fn is_waiting(&self) -> bool { + self.waiting.borrow().is_some() + } + + /// The error that ended the run when its answer could not reach the script. + pub(crate) fn take_failure(&self) -> Option { + self.failed.borrow_mut().take() + } + + /// How many of `answers` the run did not use. + pub(crate) fn unused(&self) -> usize { + self.answers.borrow().len() + } + + /// Gives the waiting dialog `answer`, which must be for its kind and, for a prompt, at most + /// [`MAX_PROMPT_BYTES`]. → false when it is not, or nothing waits. + pub(crate) fn answer(&self, realm: &mut Realm, answer: DialogAnswer) -> bool { + let fits = match &answer { + DialogAnswer::Prompt(Some(t)) => t.len() <= MAX_PROMPT_BYTES, + _ => true, + }; + let mut waiting = self.waiting.borrow_mut(); + let Some(d) = waiting.as_ref().filter(|d| d.kind == answer.kind() && fits) else { return false }; + self.record(d, answer.json()); + *waiting = None; + if let Err(e) = realm.answer(answer.data()) { + *self.failed.borrow_mut() = Some(e); + } + true + } + + /// The run ends while a dialog waits: the report lists it with the answer `"stop"`. + pub(crate) fn stopped(&self) { + if let Some(d) = self.waiting.borrow_mut().take() { + self.record(&d, json!("stop")); + } + } + + fn record(&self, d: &ScriptDialog, answer: Value) { + self.count.set(self.count.get().saturating_add(1)); + let mut r = Map::new(); + r.insert("kind".into(), json!(d.kind.id())); + r.insert("message".into(), json!(d.message)); + r.insert("title".into(), json!(d.title.as_deref().unwrap_or(DEFAULT_TITLE))); + match d.kind { + DialogKind::Alert => r.insert("errorIcon".into(), json!(d.error_icon)), + DialogKind::Confirm => r.insert("noAsDefault".into(), json!(d.no_as_default)), + DialogKind::Prompt => r.insert("preset".into(), json!(d.preset)), + }; + r.insert("answer".into(), answer); + let r = Value::Object(r); + let bytes = serde_json::to_vec(&r).map_or(usize::MAX, |b| b.len()); + let mut records = self.records.borrow_mut(); + if self.truncated.get() || records.len() >= MAX_RECORDS || self.record_bytes.get().saturating_add(bytes) > MAX_RECORD_BYTES { + self.truncated.set(true); + return; + } + self.record_bytes.set(self.record_bytes.get() + bytes); + records.push(r); + } + + /// Adds `dialogs`, `dialogCount` and `dialogsTruncated` to a report. + pub(crate) fn report(&self, report: &mut Map) { + report.insert("dialogs".into(), Value::Array(self.records.borrow().clone())); + report.insert("dialogCount".into(), json!(self.count.get())); + report.insert("dialogsTruncated".into(), json!(self.truncated.get())); + } +} + +/// Adds `alert`, `confirm` and `prompt` to the realm. +pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { + use Arg::{Bool, Text, TextOrUndefined}; + let call = |kind: DialogKind| -> HostFunction { + let h = host.clone(); + Rc::new(move |args| open(&h, 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))?; + realm.define_host_function("prompt", vec![Text, Text, TextOrUndefined], call(DialogKind::Prompt)) +} + +fn text(args: &[Data], i: usize) -> Option { + match args.get(i) { + Some(Data::String(s)) => Some(s.clone()), + _ => None, + } +} + +fn flag(args: &[Data], i: usize) -> bool { + matches!(args.get(i), Some(Data::Bool(true))) +} + +/// A call of `alert`, `confirm` or `prompt`. +fn open(host: &Host, kind: DialogKind, args: &[Data]) -> Result { + let name = kind.id(); + host.charge_call(args.iter().map(super::data_size).sum())?; + let Some(message) = text(args, 0) else { return Err(HostError::Type(format!("{name}: give a message"))) }; + let (title, error_icon, no_as_default, preset) = match kind { + DialogKind::Alert => (text(args, 1), flag(args, 2), false, String::new()), + DialogKind::Confirm => (text(args, 2), false, flag(args, 1), String::new()), + DialogKind::Prompt => { + let Some(preset) = text(args, 1) else { return Err(HostError::Type(format!("{name}: give the text the field starts with"))) }; + (text(args, 2), false, false, preset) + } + }; + if message.len() > MAX_MESSAGE_BYTES { + return Err(HostError::Range(format!("{name}: the message is longer than 16 KiB"))); + } + if title.as_ref().is_some_and(|t| t.len() > MAX_TITLE_BYTES) { + return Err(HostError::Range(format!("{name}: the title is longer than 256 bytes"))); + } + if preset.len() > MAX_PROMPT_BYTES { + return Err(HostError::Range(format!("{name}: the text the field starts with is longer than 16 KiB"))); + } + let dialog = ScriptDialog { kind, message, title, error_icon, no_as_default, preset }; + let d = &host.dialogs; + if d.framed { + if !d.can_wait.get() { + return Err(HostError::Error(format!( + "{name}: a dialog cannot open inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code" + ))); + } + *d.waiting.borrow_mut() = Some(dialog); + return Err(HostError::Wait); + } + let next = d.answers.borrow_mut().pop_front(); + match next { + Some(a) if a.kind() == kind => { + d.record(&dialog, a.json()); + Ok(a.data()) + } + Some(a) => { + d.record(&dialog, json!("missing")); + Err(HostError::Abort(format!("{name}: the next answer in `answers` is for {}, not {name}", a.kind().id()))) + } + None => { + d.record(&dialog, json!("missing")); + Err(HostError::Abort(format!("{name}: `answers` has no answer left for this dialog"))) + } + } +} diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs new file mode 100644 index 000000000..3e6b00584 --- /dev/null +++ b/crates/engine/src/script/dollar.rs @@ -0,0 +1,307 @@ +//! The members of a script's `$` object besides `$.write`, `$.writeln`, `$.evalFile`, `$.fileName` +//! and `$.includePath`, from the Dollar object +//! page (), and +//! 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. +//! +//! 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 +//! 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 +//! [`MAX_ENV_BYTES`] bytes of letters, digits and `_ . - @`; and `$.sleep` waits at most +//! [`MAX_SLEEP_MS`] milliseconds, between two frames of the app, and does not wait on the web, +//! which has no clock. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem}; + +use super::{Host, function}; + +/// The longest `$.sleep`, in milliseconds. +pub const MAX_SLEEP_MS: f64 = 1_000.0; +/// The environment variables `$.getenv` reads and `$.setenv` sets. +pub const ENV_NAMES: &[&str] = &["LANG", "LC_MESSAGES", "LC_ALL", "OS"]; +/// The most bytes of an environment value. +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. +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 locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language, +/// with its region after `_` where the code gives one (`pt_BR`; `zh_CN` for `zh-hans` and `zh_TW` +/// for `zh-hant`); `en` for a code that is not a language. +pub(crate) fn ui_locale(code: &str) -> String { + let mut parts = code.split(['-', '_']); + let language = parts.next().unwrap_or_default().to_ascii_lowercase(); + if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_lowercase()) { + return "en".into(); + } + let region = match parts.next().map(str::to_ascii_lowercase).as_deref() { + Some("hans") => Some("CN".to_string()), + Some("hant") => Some("TW".to_string()), + Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_lowercase()) => Some(r.to_ascii_uppercase()), + _ => None, + }; + region.map_or(language.clone(), |r| format!("{language}_{r}")) +} + +/// A locale a script gives `$.locale`, as `ll` or `ll_RR`: a language of two or three letters and +/// an optional region of two letters or three digits, joined by `_` or `-`, in any case. +fn script_locale(text: &str) -> Option { + let mut parts = text.split(['-', '_']); + let language = parts.next()?; + let region = parts.next(); + if parts.next().is_some() || !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) { + return None; + } + let language = language.to_ascii_lowercase(); + match region { + None => Some(language), + Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_alphabetic()) => Some(format!("{language}_{}", r.to_ascii_uppercase())), + Some(r) if r.len() == 3 && r.bytes().all(|b| b.is_ascii_digit()) => Some(format!("{language}_{r}")), + Some(_) => None, + } +} + +/// The languages whose numbers are written with a decimal comma (VectorCraft's own list). +const DECIMAL_COMMA: &[&str] = &[ + "bg", "ca", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hr", "hu", "id", "it", "lt", "lv", "nb", "nl", "no", "pl", "pt", "ro", "ru", "sk", + "sl", "sr", "sv", "tr", "uk", "vi", +]; + +/// The character `locale` writes before a number's fraction. +pub(crate) fn decimal_point(locale: &str) -> &'static str { + let language = locale.split('_').next().unwrap_or_default(); + if DECIMAL_COMMA.contains(&language) { "," } else { "." } +} + +/// The platform suffix of a localization object's names on this system. +pub(crate) fn platform() -> Option<&'static str> { + if cfg!(target_arch = "wasm32") { + None + } else if cfg!(windows) { + Some("Win") + } else if cfg!(target_os = "macos") { + Some("Mac") + } else { + Some("Unix") + } +} + +/// `$.os`: the system, without its version. +fn os() -> &'static str { + if cfg!(target_arch = "wasm32") { + "Web" + } else if cfg!(windows) { + "Windows" + } else if cfg!(target_os = "macos") { + "macOS" + } else if cfg!(target_os = "linux") { + "Linux" + } else if cfg!(target_os = "freebsd") { + "FreeBSD" + } else { + "Unix" + } +} + +/// Is `value` one an environment value may be? +fn env_value_ok(value: &str) -> bool { + value.len() <= MAX_ENV_BYTES && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b'@')) +} + +/// The values of [`ENV_NAMES`] in the process's environment, each left out when it is not one an +/// environment value may be. +fn process_env() -> Vec<(&'static str, Option)> { + ENV_NAMES.iter().map(|&n| (n, std::env::var(n).ok().filter(|v| env_value_ok(v)))).collect() +} + +fn one_string(args: &[Data], name: &str) -> Result { + match args.first() { + Some(Data::String(s)) => Ok(s.clone()), + _ => Err(HostError::Type(format!("$.{name}: give a name, as a string"))), + } +} + +fn value(v: Data) -> HostItem { + HostItem::Value(v) +} + +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)> { + 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())), + set: function(move |mut a| { + set(if a.is_empty() { Data::Undefined } else { a.swap_remove(0) })?; + Ok(Data::Undefined) + }), + }; + let fail = |message: &'static str| HostItem::Function(function(move |_| Err(HostError::Error(message.to_string())))); + + let locale = { + let (l, s) = (localization.clone(), localization.clone()); + property( + Rc::new(move || text(l.locale())), + Rc::new(move |v| { + let locale = match v { + Data::Null | Data::Undefined => host_locale.clone(), + Data::String(t) if t.is_empty() => host_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())), + }; + s.set_locale(&locale); + Ok(()) + }), + ) + }; + let localize = { + let (l, s) = (localization.clone(), localization.clone()); + property( + Rc::new(move || Data::Bool(l.enabled())), + Rc::new(move |v| match v { + Data::Bool(on) => { + s.set_enabled(on); + Ok(()) + } + _ => Err(HostError::Type("$.localize: give true or false".into())), + }), + ) + }; + let flags = { + let (l, s) = (localization.clone(), localization.clone()); + property( + Rc::new(move || Data::Number(if l.enabled() { LOCALIZE_FLAG } else { 0.0 })), + Rc::new(move |v| match v { + Data::Number(n) if n == 0.0 || n == LOCALIZE_FLAG => { + s.set_enabled(n == LOCALIZE_FLAG); + Ok(()) + } + Data::Number(_) => Err(HostError::Range("$.flags: only 512, which turns localization on, or 0 can be set".into())), + _ => Err(HostError::Type("$.flags: give a number".into())), + }), + ) + }; + let decimal = { + let l = localization.clone(); + getter(Rc::new(move || text(decimal_point(&l.locale())))) + }; + let app_encoding = property( + Rc::new(|| text("UTF-8")), + Rc::new(|v| match v { + Data::String(t) if t.eq_ignore_ascii_case("utf-8") || t.eq_ignore_ascii_case("utf8") => Ok(()), + Data::String(_) => Err(HostError::Range("$.appEncoding: scripts here read and write UTF-8 only".into())), + _ => Err(HostError::Type("$.appEncoding: give an encoding's name, as a string".into())), + }), + ); + let mem_cache = + property(Rc::new(|| Data::Number(0.0)), Rc::new(|_| Err(HostError::Error("$.memCache: the memory cache size cannot be set".into())))); + let hires_timer = { + let (h, last) = (host.clone(), timer.clone()); + getter(Rc::new(move || { + let Some(now) = h.elapsed() else { return Data::Number(0.0) }; + let since = now.saturating_sub(last.replace(now)); + #[allow(clippy::cast_precision_loss)] + Data::Number(since.as_micros() as f64) + })) + }; + let getenv = { + let (h, env) = (host.clone(), env.clone()); + function(move |a| { + let name = one_string(&a, "getenv")?; + h.charge_call(name.len() as u64 + 2)?; + let env = env.borrow(); + let found = env.iter().find(|(n, _)| *n == name).and_then(|(_, v)| v.clone()); + Ok(found.map_or(Data::Null, Data::String)) + }) + }; + let setenv = { + let (h, env) = (host.clone(), env.clone()); + function(move |a| { + let name = one_string(&a, "setenv")?; + let Some(Data::String(v)) = a.get(1) else { return Err(HostError::Type("$.setenv: give a value, as a string".into())) }; + h.charge_call((name.len() + v.len()) as u64 + 4)?; + let mut env = env.borrow_mut(); + let Some(slot) = env.iter_mut().find(|(n, _)| *n == name) else { + return Err(HostError::Error(format!("$.setenv: only {} can be set", ENV_NAMES.join(", ")))); + }; + if !env_value_ok(v) { + return Err(HostError::Range(format!("$.setenv: a value has at most {MAX_ENV_BYTES} letters, digits and _ . - @"))); + } + slot.1 = Some(v.clone()); + Ok(Data::Undefined) + }) + }; + let sleep = { + let h = host.clone(); + function(move |a| { + let ms = match a.first() { + Some(Data::Number(n)) if (0.0..=MAX_SLEEP_MS).contains(n) => *n, + Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))), + _ => return Err(HostError::Type("$.sleep: give a number of milliseconds".into())), + }; + h.charge_call(8)?; + h.sleep(Duration::from_secs_f64(ms / 1000.0))?; + Ok(Data::Undefined) + }) + }; + let about = text(format!("VectorCraft script engine {version}")); + + vec![ + ("about".into(), HostItem::Function(function(move |_| Ok(about.clone())))), + ("appEncoding".into(), app_encoding), + ("bp".into(), fail("$.bp: scripts here have no debugger")), + ("build".into(), value(text(version))), + ("buildDate".into(), value(Data::Null)), + ("colorPicker".into(), fail("$.colorPicker: scripts cannot show a dialog yet")), + ("decimalPoint".into(), decimal), + ("engineName".into(), value(text("VectorCraft"))), + ("error".into(), HostItem::Script(ScriptItem::LastError)), + ("flags".into(), flags), + ("gc".into(), HostItem::Function(function(|_| Ok(Data::Undefined)))), + ("getenv".into(), HostItem::Function(getenv)), + ("global".into(), HostItem::Script(ScriptItem::Global)), + ("hiresTimer".into(), hires_timer), + ("level".into(), value(Data::Number(0.0))), + ("line".into(), HostItem::Script(ScriptItem::Line)), + ("locale".into(), locale), + ("localize".into(), localize), + ("memCache".into(), mem_cache), + ("os".into(), value(text(os()))), + ("screens".into(), value(Data::Array(Vec::new()))), + ("setenv".into(), HostItem::Function(setenv)), + ("sleep".into(), HostItem::Function(sleep)), + ("stack".into(), HostItem::Script(ScriptItem::Stack)), + ("strict".into(), value(Data::Bool(info.strict))), + ("version".into(), value(text(version))), + ] +} + +const BAD_LOCALE: &str = "$.locale: give a locale such as \"en\" or \"de_DE\", or null for the interface's"; diff --git a/crates/engine/src/script/files.rs b/crates/engine/src/script/files.rs new file mode 100644 index 000000000..3f5381499 --- /dev/null +++ b/crates/engine/src/script/files.rs @@ -0,0 +1,1001 @@ +//! `File` and `Folder`: a script's objects for files and folders, as the help pages describe them +//! (, +//! and +//! ). The classes are +//! built-ins of the realm ([`Realm::define_file_classes`]); the host functions here make every +//! path check and every read. +//! +//! An object holds an absolute path: a relative path is taken from `Folder.current`, `~` is the home +//! folder, `%xx` escapes are read as UTF-8, and on Windows both `/` and `\` separate names and +//! `/c/…` names drive C. Naming a path does not look anything up. +//! +//! VectorCraft's own rules: +//! +//! - A script reads only inside its [`ScriptScope`]: a script run from a file reads in that file's +//! folder and the folders below it, and a script given as text reads nothing. A path whose names +//! lie outside, a Windows network or device path (`\\host\share`, `\\?\`, `\\.\`), and a path that +//! leads out through a link are refused before or instead of opening anything: `exists` is false, +//! the dates are `null`, `getFiles` gives `null`, and `error` is [`ACCESS_DENIED`]. The automation +//! read roots apply as well, so a run with roots in force reads only inside both. +//! - `File(path)` without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a +//! `File` for an existing file, only where the path may be read. +//! - Opening reads the whole file, at most [`MAX_FILE_BYTES`], checked from its size before any of +//! it is read; a run reads at most [`MAX_RUN_BYTES`] in all and ends past that, and has at most +//! [`MAX_OPEN`] files open at once. +//! - The encoding of an opened file is the one its byte order mark gives (UTF-8, UTF-16LE or +//! UTF-16BE), otherwise the one the script set, otherwise UTF-8 when the bytes are valid UTF-8, +//! otherwise CP1252. `read`, `readln` and `readch` count characters and `seek` and `tell` bytes; +//! `readln` and `readch` take CR, LF, CRLF and LFCR as one line end, and `readch` gives a line end +//! as `"\n"`. A character the encoding cannot decode fails the read with [`UNDECODABLE`] and +//! leaves the position where it was. +//! - `getFiles` lists at most [`MAX_ENTRIES`] entries, sorted by name, and looks at most +//! [`crate::file_access::MAX_LISTED`] names; a mask matches `*` and `?` without regard to case, +//! in at most [`MAX_MASK_STEPS`] steps for one listing. +//! - A path longer than [`MAX_PATH_CHARS`] characters is refused before it is split into names. +//! - Writing (`open` with `w`, `e` or `a`, `write`, `copy`, `remove`, `rename`, `create` and the +//! property setters), the file and folder dialogs and `execute` are not available: they return +//! `false` or `null` and set `error`. `execute` is counted in the report's `fileExecutes`. +//! - In a wasm build every lookup fails with [`NO_WEB`]. + +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +use vectorcraft_js::{Data, HostError, HostItem, Realm, RunError}; + +use super::{Host, bump}; +use crate::file_access::{ScriptRead, ScriptScope}; + +/// The most bytes of one file a script opens. +pub const MAX_FILE_BYTES: usize = 8 << 20; +/// The most bytes of files one run opens, all of them counted together. +pub const MAX_RUN_BYTES: usize = 32 << 20; +/// The most entries `getFiles` gives. +pub const MAX_ENTRIES: usize = 2048; +/// The most files a run has open at once. +pub const MAX_OPEN: usize = 256; +/// The most characters of a path a script gives. +pub const MAX_PATH_CHARS: usize = 32_767; +/// The most steps one `getFiles` listing spends matching its mask against names. +pub const MAX_MASK_STEPS: usize = 1 << 24; + +pub(crate) const ACCESS_DENIED: &str = "Script file access denied"; +pub(crate) const TOO_LARGE: &str = "Script file exceeds 8 MiB"; +pub(crate) const NOT_OPEN: &str = "Script file is not open"; +pub(crate) const UNDECODABLE: &str = "Script file cannot be decoded"; +pub(crate) const NOT_REGULAR: &str = "Script file is not a regular file"; +pub(crate) const NOT_FOUND: &str = "Script file does not exist"; +pub(crate) const READ_FAILED: &str = "Script file could not be read"; +pub(crate) const LISTING_TOO_LARGE: &str = "Script folder has more than 2,048 entries"; +pub(crate) const BAD_MODE: &str = "Script file open mode must be r, w, e or a"; +pub(crate) const NO_WRITES: &str = "Script file writing is not available"; +pub(crate) const NO_PICKER: &str = "Script file dialogs are not available"; +pub(crate) const NO_EXECUTE: &str = "Script files cannot be opened in another application"; +pub(crate) const NO_WEB: &str = "Script file access is unavailable on the web"; +pub(crate) const TOO_MANY_OPEN: &str = "Script has 256 files open"; +pub(crate) const PATH_TOO_LONG: &str = "Script file path exceeds 32,767 characters"; +pub(crate) const MASK_TOO_COSTLY: &str = "Script folder mask takes too many steps to match"; +const OVER_RUN_BYTES: &str = "the script opened more file data than one run may open (32 MiB)"; + +/// The script's file state: its scope, where `Folder.current` starts, the files it has open and how +/// much it has read. +pub(crate) struct Disk { + scope: ScriptScope, + home: String, + start: String, + open: HashMap, + next: u32, + temps: u32, + bytes: usize, +} + +impl Disk { + /// The state of a run whose own files are read in `scope`. + 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 } + } + + /// The path `text` names from the folder `base`, a platform path; `text`'s URI escapes are + /// decoded when `uri` is set. + fn pathname(&self, text: &str, base: &str, uri: bool) -> Pathname { + let root = Pathname::root(WINDOWS); + let home = Pathname::parse(&self.home, &root, &root, WINDOWS, false); + let base = Pathname::parse(base, &home, &home, WINDOWS, false); + Pathname::parse(text, &base, &home, WINDOWS, uri) + } + + /// The path `path` as the scope takes it, or why it is refused by its form alone. + fn checked<'a>(&self, path: &'a str) -> Result<&'a Path, &'static str> { + if cfg!(target_arch = "wasm32") { + return Err(NO_WEB); + } + if too_long(path) { + return Err(PATH_TOO_LONG); + } + let p = Path::new(path); + if !p.is_absolute() || (WINDOWS && (path.starts_with(r"\\") || path.starts_with("//"))) { + return Err(ACCESS_DENIED); + } + Ok(p) + } + + fn stat(&self, path: &str) -> Result, &'static str> { + match self.scope.stat(self.checked(path)?) { + Ok(e) => Ok(Some(e)), + Err(ScriptRead::Missing) => Ok(None), + Err(ScriptRead::Refused | ScriptRead::TooLarge) => Err(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => Err(READ_FAILED), + } + } + + fn open_file(&mut self, path: &str, encoding: &str) -> Result { + let fail = |m: &str| Err(HostError::Error(m.to_string())); + if self.open.len() >= MAX_OPEN { + return fail(TOO_MANY_OPEN); + } + let entry = match self.stat(path) { + Ok(Some(e)) => e, + Ok(None) => return fail(NOT_FOUND), + Err(m) => return fail(m), + }; + if !entry.file { + return fail(NOT_REGULAR); + } + if entry.len > MAX_FILE_BYTES as u64 { + return fail(TOO_LARGE); + } + if self.bytes.saturating_add(entry.len as usize) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + let bytes = match self.scope.read(Path::new(path), MAX_FILE_BYTES) { + Ok((_, bytes)) => bytes, + Err(ScriptRead::TooLarge) => return fail(TOO_LARGE), + Err(ScriptRead::Missing) => return fail(NOT_FOUND), + Err(ScriptRead::Refused) => return fail(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => return fail(READ_FAILED), + }; + if self.bytes.saturating_add(bytes.len()) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + 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); + self.open.insert(id, file); + Ok(id) + } +} + +/// Whether paths are read as Windows reads them. +const WINDOWS: bool = cfg!(windows); + +/// Does `path` have more than [`MAX_PATH_CHARS`] characters? +fn too_long(path: &str) -> bool { + path.len() > MAX_PATH_CHARS && path.chars().nth(MAX_PATH_CHARS).is_some() +} + +/// The home folder, as the environment gives it. +fn home_folder() -> String { + let var = if WINDOWS { "USERPROFILE" } else { "HOME" }; + std::env::var(var).unwrap_or_else(|_| if WINDOWS { r"C:\".to_string() } else { "/".to_string() }) +} + +/// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none. +fn locations(home: &str) -> Data { + 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 (user_data, app_data, common, system, trash) = if WINDOWS { + (env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None) + } else if cfg!(target_os = "macos") { + ( + Some(join(home, "Library/Application Support")), + Some("/Library/Application Support".to_string()), + Some("/Library/Application Support".to_string()), + Some("/System".to_string()), + Some(join(home, ".Trash")), + ) + } else { + let data = env("XDG_DATA_HOME").unwrap_or_else(|| join(home, ".local/share")); + (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()), + ("commonFiles", common), + ("desktop", Some(join(home, "Desktop"))), + ("myDocuments", Some(join(home, "Documents"))), + ("startup", exe), + ("system", system), + ("temp", Some(temp)), + ("trash", trash), + ("userData", user_data), + ]; + Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect()) +} + +/// The start of an absolute path. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Root { + /// `/`. + Unix, + /// A Windows drive letter, in upper case. + Drive(char), + /// A Windows share: `\\host\share`. + Share(String, String), + /// A Windows `\\?\` or `\\.\` path, kept as given. + Device(String), +} + +/// An absolute path: its root and its names, with `.` and `..` resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Pathname { + root: Root, + names: Vec, +} + +impl Pathname { + fn root(windows: bool) -> Self { + Self { root: if windows { Root::Drive('C') } else { Root::Unix }, names: Vec::new() } + } + + /// The path `text` names, relative ones taken from `base` and `~` from `home`, with its URI + /// escapes decoded when `uri` is set. + fn parse(text: &str, base: &Pathname, home: &Pathname, windows: bool, uri: bool) -> Self { + let decoded = if uri { decode(text) } else { text.to_string() }; + let t = if windows { decoded.replace('\\', "/") } else { decoded }; + let (start, rest): (Pathname, &str) = if windows && (t.starts_with("//?/") || t.starts_with("//./")) { + return Self { root: Root::Device(t.replace('/', "\\")), names: Vec::new() }; + } else if let Some(r) = t.strip_prefix("//").filter(|_| windows) { + let mut parts = r.splitn(3, '/'); + let host = parts.next().unwrap_or_default().to_string(); + let share = parts.next().unwrap_or_default().to_string(); + (Self { root: Root::Share(host, share), names: Vec::new() }, parts.next().unwrap_or_default()) + } else if windows && t.len() >= 2 && t.as_bytes()[0].is_ascii_alphabetic() && t.as_bytes()[1] == b':' { + (Self { root: Root::Drive(t.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, t.get(2..).unwrap_or_default()) + } else if let Some(r) = t.strip_prefix('/') { + let (first, after) = r.split_once('/').unwrap_or((r, "")); + if windows && first.len() == 1 && first.as_bytes()[0].is_ascii_alphabetic() { + (Self { root: Root::Drive(first.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, after) + } else if windows { + let root = match &base.root { + Root::Drive(_) | Root::Share(..) => base.root.clone(), + _ => Root::Drive('C'), + }; + (Self { root, names: Vec::new() }, r) + } else { + (Self { root: Root::Unix, names: Vec::new() }, r) + } + } else if t == "~" || t.starts_with("~/") { + (home.clone(), t.get(1..).unwrap_or_default()) + } else { + (base.clone(), t.as_str()) + }; + let mut out = start; + for name in rest.split('/') { + match name { + "" | "." => {} + ".." => { + out.names.pop(); + } + n => out.names.push(n.to_string()), + } + } + out + } + + /// The path as the platform writes it. + fn fs(&self) -> String { + match &self.root { + Root::Unix => format!("/{}", self.names.join("/")), + Root::Drive(c) => format!("{c}:\\{}", self.names.join("\\")), + Root::Share(host, share) => self.names.iter().fold(format!(r"\\{host}\{share}"), |s, n| format!("{s}\\{n}")), + Root::Device(raw) => raw.clone(), + } + } + + /// The path in URI notation, each name escaped. + fn uri(&self) -> String { + let start = match &self.root { + Root::Unix => String::new(), + Root::Drive(c) => format!("/{}", c.to_ascii_lowercase()), + Root::Share(host, share) => format!("//{}/{}", encode(host), encode(share)), + Root::Device(raw) => return raw.replace('\\', "/"), + }; + let names: String = self.names.iter().map(|n| format!("/{}", encode(n))).collect(); + if start.is_empty() && names.is_empty() { "/".to_string() } else { start + &names } + } + + fn parent(&self) -> Option { + let mut p = self.clone(); + p.names.pop().map(|_| p) + } + + /// `self` relative to the folder `base`, in URI notation; the whole URI when they have different + /// roots. + fn relative_to(&self, base: &Pathname, windows: bool) -> String { + if self.root != base.root { + return self.uri(); + } + let same = |a: &String, b: &String| if windows { a.to_lowercase() == b.to_lowercase() } else { a == b }; + let common = self.names.iter().zip(&base.names).take_while(|(a, b)| same(a, b)).count(); + let mut parts: Vec = vec!["..".to_string(); base.names.len() - common]; + parts.extend(self.names.iter().skip(common).map(|n| encode(n))); + if parts.is_empty() { ".".to_string() } else { parts.join("/") } + } + + /// The parts of `File` and `Folder` objects that come from the path alone. + fn data(&self) -> Data { + let s = |v: String| Data::String(v); + Data::Object(vec![ + ("fs".into(), s(self.fs())), + ("uri".into(), s(self.uri())), + ("name".into(), s(self.names.last().map(|n| encode(n)).unwrap_or_default())), + ("display".into(), s(self.names.last().cloned().unwrap_or_default())), + ("path".into(), s(self.parent().map(|p| p.uri()).unwrap_or_default())), + ("parent".into(), self.parent().map_or(Data::Null, |p| Data::String(p.fs()))), + ]) + } +} + +/// `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) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// `s` with its `%xx` escapes read as UTF-8 bytes; `s` as it is when they do not make UTF-8. +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = |c: u8| (c as char).to_digit(16); + match (b[i], b.get(i + 1).copied().and_then(hex), b.get(i + 2).copied().and_then(hex)) { + (b'%', Some(h), Some(l)) => { + out.push((h * 16 + l) as u8); + i += 3; + } + (c, ..) => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// A `getFiles` mask, where `*` stands for any run of characters and `?` for one, without regard +/// to case, read once for a whole listing. +struct Mask { + /// The mask in lower case, each run of `*` written as one. + chars: Vec, + /// How many of its characters are not `*`: a name with fewer characters does not match. + fixed: usize, +} + +impl Mask { + fn new(mask: &str) -> Self { + let mut chars: Vec = Vec::new(); + for c in mask.to_lowercase().chars() { + if !(c == '*' && chars.last() == Some(&'*')) { + chars.push(c); + } + } + let fixed = chars.iter().filter(|c| **c != '*').count(); + Self { chars, fixed } + } + + /// Does `name` match, taking a step from `steps` for each character compared? `None` when the + /// steps run out first. + fn matches(&self, name: &str, steps: &mut usize) -> Option { + let n: Vec = name.to_lowercase().chars().collect(); + if n.len() < self.fixed { + return Some(false); + } + matched(&self.chars, &n, steps) + } +} + +/// Does `n` match the lower-case mask `m`? `None` when `steps` run out first. +fn matched(m: &[char], n: &[char], steps: &mut usize) -> Option { + let (mut i, mut j, mut star, mut mark) = (0usize, 0usize, None, 0usize); + while j < n.len() { + *steps = steps.checked_sub(1)?; + match m.get(i) { + Some('*') => { + star = Some(i); + mark = j; + i += 1; + } + Some(c) if *c == '?' || *c == n[j] => { + i += 1; + j += 1; + } + _ => match star { + Some(s) => { + i = s + 1; + mark += 1; + j = mark; + } + None => return Some(false), + }, + } + } + Some(m[i..].iter().all(|c| *c == '*')) +} + +/// A character encoding an opened file is read in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Encoding { + Utf8, + Ascii, + Binary, + Latin1, + Cp1252, + Utf16Le, + Utf16Be, +} + +/// The characters CP1252 gives bytes 0x80 to 0x9F; the five it leaves undefined stand for +/// themselves. +const CP1252_HIGH: [u16; 32] = [ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, + 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +]; + +impl Encoding { + fn parse(name: &str) -> Option { + Some(match name.trim().to_ascii_uppercase().as_str() { + "UTF-8" | "UTF8" => Self::Utf8, + "ASCII" | "US-ASCII" => Self::Ascii, + "BINARY" => Self::Binary, + "ISO-8859-1" | "ISO8859-1" | "LATIN1" | "LATIN-1" => Self::Latin1, + "CP1252" | "WINDOWS-1252" => Self::Cp1252, + "UTF-16LE" | "UTF16LE" => Self::Utf16Le, + "UTF-16" | "UTF16" | "UTF-16BE" | "UTF16BE" | "UCS-2" => Self::Utf16Be, + _ => return None, + }) + } + + fn name(self) -> &'static str { + match self { + Self::Utf8 => "UTF-8", + Self::Ascii => "ASCII", + Self::Binary => "BINARY", + Self::Latin1 => "ISO-8859-1", + Self::Cp1252 => "CP1252", + Self::Utf16Le => "UTF-16LE", + Self::Utf16Be => "UTF-16BE", + } + } +} + +/// An opened file: its bytes, the byte position and the encoding it is read in. +struct Open { + bytes: Vec, + pos: usize, + encoding: Encoding, + name: String, +} + +impl Open { + /// `bytes` opened with the encoding `asked` (empty: none asked); `None` when `asked` is not an + /// encoding and the file has no byte order mark. + fn new(bytes: Vec, asked: &str) -> Option { + let (encoding, pos) = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + (Encoding::Utf8, 3) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + (Encoding::Utf16Le, 2) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + (Encoding::Utf16Be, 2) + } else if !asked.is_empty() { + (Encoding::parse(asked)?, 0) + } else if std::str::from_utf8(&bytes).is_ok() { + (Encoding::Utf8, 0) + } else { + (Encoding::Cp1252, 0) + }; + Some(Self { bytes, pos, encoding, name: encoding.name().to_string() }) + } + + /// The character at byte `at` and its length in bytes; `Ok(None)` at the end, `Err` where the + /// encoding cannot decode it. + fn char_at(&self, at: usize) -> Result, ()> { + let Some(&b) = self.bytes.get(at) else { return Ok(None) }; + let encoding = self.encoding; + let unit = |i: usize| -> Option { + let pair = self.bytes.get(i..i + 2)?; + Some(if encoding == Encoding::Utf16Le { u16::from_le_bytes([pair[0], pair[1]]) } else { u16::from_be_bytes([pair[0], pair[1]]) }) + }; + let c = match encoding { + Encoding::Ascii if b < 0x80 => (b as char, 1), + Encoding::Ascii => return Err(()), + Encoding::Binary | Encoding::Latin1 => (b as char, 1), + Encoding::Cp1252 => { + let code = if (0x80..0xA0).contains(&b) { u32::from(CP1252_HIGH[usize::from(b - 0x80)]) } else { u32::from(b) }; + (char::from_u32(code).ok_or(())?, 1) + } + Encoding::Utf8 => { + let len = match b { + 0x00..=0x7F => 1, + 0xC2..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF4 => 4, + _ => return Err(()), + }; + let s = self.bytes.get(at..at + len).and_then(|s| std::str::from_utf8(s).ok()).ok_or(())?; + (s.chars().next().ok_or(())?, len) + } + Encoding::Utf16Le | Encoding::Utf16Be => { + let first = unit(at).ok_or(())?; + if (0xD800..0xDC00).contains(&first) { + let second = unit(at + 2).filter(|u| (0xDC00..0xE000).contains(u)).ok_or(())?; + let code = 0x10000 + ((u32::from(first) - 0xD800) << 10) + (u32::from(second) - 0xDC00); + (char::from_u32(code).ok_or(())?, 4) + } else { + (char::from_u32(u32::from(first)).ok_or(())?, 2) + } + } + }; + Ok(Some(c)) + } + + /// Up to `max` characters from the position (all of them for `None`). + fn read(&mut self, max: Option) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + let mut n = 0; + while max.is_none_or(|m| n < m) { + let Some((c, len)) = self.char_at(at)? else { break }; + out.push(c); + at += len; + n += 1; + } + self.pos = at; + Ok(out) + } + + /// The position after a line end that began with `c` and continues at `at` (CR, LF, CRLF or + /// LFCR). A character there that cannot be decoded is left for the next read. + fn past_line_end(&self, c: char, at: usize) -> usize { + let other = if c == '\r' { '\n' } else { '\r' }; + match self.char_at(at) { + Ok(Some((n, len))) if n == other => at + len, + _ => at, + } + } + + /// The characters up to the next line end, which is passed over. + fn read_line(&mut self) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + while let Some((c, len)) = self.char_at(at)? { + at += len; + if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + break; + } + out.push(c); + } + self.pos = at; + Ok(out) + } + + /// The next character; a line end is one `"\n"`. + fn read_char(&mut self) -> Result { + let Some((c, len)) = self.char_at(self.pos)? else { return Ok(String::new()) }; + let mut at = self.pos + len; + let c = if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + '\n' + } else { + c + }; + self.pos = at; + Ok(c.to_string()) + } +} + +/// `{text}` for a read that worked, `{error}` for one that did not. +fn text_result(r: Result) -> Data { + match r { + Ok(t) => Data::Object(vec![("text".into(), Data::String(t))]), + Err(()) => error(UNDECODABLE), + } +} + +fn error(message: &str) -> Data { + Data::Object(vec![("error".into(), Data::String(message.to_string()))]) +} + +fn arg_text(a: &[Data], i: usize) -> String { + match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + } +} + +fn arg_number(a: &[Data], i: usize) -> Option { + match a.get(i) { + Some(Data::Number(n)) => Some(*n), + _ => None, + } +} + +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(); + HostItem::Function(Rc::new(move |a| { + 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()))?; + Ok(r) + })) +} + +fn handle(a: &[Data]) -> u32 { + arg_number(a, 0).filter(|n| n.is_finite() && *n >= 0.0 && *n <= f64::from(u32::MAX)).map_or(0, |n| n as u32) +} + +fn with_open(host: &Host, a: &[Data], f: impl FnOnce(&mut Open) -> Data) -> Data { + match host.disk.borrow_mut().open.get_mut(&handle(a)) { + Some(file) => f(file), + None => error(NOT_OPEN), + } +} + +/// The first two arguments as path text, refused with a `RangeError` when either is too long. +fn two_paths(a: &[Data]) -> Result<(String, String), HostError> { + let (path, base) = (arg_text(a, 0), arg_text(a, 1)); + if too_long(&path) || too_long(&base) { + return Err(HostError::Range(PATH_TOO_LONG.to_string())); + } + Ok((path, base)) +} + +fn parse(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let platform = matches!(a.get(2), Some(Data::Bool(true))); + let disk = host.disk.borrow(); + Ok(disk.pathname(&path, &base, !platform).data()) +} + +/// The platform path `a[0]` relative to the platform path `a[1]`. +fn relative(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let disk = host.disk.borrow(); + let base = disk.pathname(&base, &disk.start, false); + Ok(Data::String(disk.pathname(&path, &disk.start, false).relative_to(&base, WINDOWS))) +} + +fn stat(host: &Host, a: &[Data]) -> Result { + Ok(match host.disk.borrow().stat(&arg_text(a, 0)) { + Err(m) => error(m), + Ok(None) => Data::Object(vec![("exists".into(), Data::Bool(false))]), + Ok(Some(e)) => Data::Object(vec![ + ("exists".into(), Data::Bool(true)), + ("dir".into(), Data::Bool(e.dir)), + ("file".into(), Data::Bool(e.file)), + ("length".into(), Data::Number(e.len as f64)), + ("created".into(), millis(e.created)), + ("modified".into(), millis(e.modified)), + ("hidden".into(), Data::Bool(e.hidden)), + ("readonly".into(), Data::Bool(e.readonly)), + ("alias".into(), Data::Bool(e.link)), + ]), + }) +} + +fn open(host: &Host, a: &[Data]) -> Result { + match host.disk.borrow_mut().open_file(&arg_text(a, 0), &arg_text(a, 1)) { + Ok(id) => Ok(Data::Object(vec![("id".into(), Data::Number(f64::from(id)))])), + Err(HostError::Error(m)) => Ok(error(&m)), + Err(e) => Err(e), + } +} + +fn close(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow_mut().open.remove(&handle(a)).is_some())) +} + +fn read(host: &Host, a: &[Data]) -> Result { + let max = arg_number(a, 1).filter(|n| *n >= 0.0).map(|n| n as usize); + Ok(with_open(host, a, |f| text_result(f.read(max)))) +} + +fn readln(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_line()))) +} + +fn readch(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_char()))) +} + +fn seek(host: &Host, a: &[Data]) -> Result { + let (Some(pos), Some(mode)) = (arg_number(a, 1), arg_number(a, 2)) else { return Err(HostError::Type(BAD_SEEK.into())) }; + Ok(with_open(host, a, |f| { + let base = match mode as u8 { + 1 => f.pos as f64, + 2 => f.bytes.len() as f64, + _ => 0.0, + }; + let to = base + pos.trunc(); + if !(0.0..=f.bytes.len() as f64).contains(&to) { + return Data::Bool(false); + } + f.pos = to as usize; + Data::Bool(true) + })) +} + +const BAD_SEEK: &str = "File.seek: takes a position and a mode"; + +fn tell(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::Number(f.pos as f64))) +} + +fn eof(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow().open.get(&handle(a)).is_none_or(|f| f.pos >= f.bytes.len()))) +} + +fn encoding(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::String(f.name.clone()))) +} + +fn set_encoding(host: &Host, a: &[Data]) -> Result { + let name = arg_text(a, 1); + Ok(with_open(host, a, |f| match Encoding::parse(&name) { + Some(e) => { + f.encoding = e; + f.name = e.name().to_string(); + Data::Object(vec![]) + } + None => error(UNDECODABLE), + })) +} + +fn list(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let mask = Mask::new(&arg_text(a, 1)); + let checked = match disk.checked(&path) { + Ok(p) => p, + Err(m) => return Ok(error(m)), + }; + let steps = std::cell::Cell::new(MAX_MASK_STEPS); + let spent = std::cell::Cell::new(false); + let keep = |name: &str| { + let mut left = steps.get(); + let kept = mask.matches(name, &mut left); + steps.set(left); + spent.set(spent.get() || kept.is_none()); + kept.unwrap_or(false) + }; + let listed = disk.scope.list(checked, MAX_ENTRIES, keep); + if spent.get() { + return Ok(error(MASK_TOO_COSTLY)); + } + let found = match listed { + Ok(found) => found, + Err(ScriptRead::TooLarge) => return Ok(error(LISTING_TOO_LARGE)), + Err(ScriptRead::Missing) => return Ok(error(NOT_FOUND)), + Err(ScriptRead::Refused) => return Ok(error(ACCESS_DENIED)), + Err(ScriptRead::Failed(_)) => return Ok(error(READ_FAILED)), + }; + let entries = found + .into_iter() + .map(|(name, dir)| { + let fs = Path::new(&path).join(&name).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + }) + .collect(); + Ok(Data::Object(vec![("entries".into(), Data::Array(entries))])) +} + +fn resolve(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let target = match disk.stat(&path) { + Err(m) => return Ok(error(m)), + Ok(Some(e)) if e.link => disk.checked(&path).ok().and_then(|p| disk.scope.locate(p).ok()), + Ok(_) => None, + }; + Ok(match target { + Some(real) => { + let dir = real.is_dir(); + let fs = crate::cmd::findfiles::plain(real).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + } + None => Data::Object(vec![("fs".into(), Data::Null)]), + }) +} + +fn execute(host: &Host, _: &[Data]) -> Result { + bump(&host.counts.file_executes); + Ok(error(NO_EXECUTE)) +} + +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())) +} + +/// 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()) + }; + let value = |s: &str| HostItem::Value(Data::String(s.to_string())); + let text = HostItem::Object(vec![ + ("notOpen".into(), value(NOT_OPEN)), + ("noWrites".into(), value(NO_WRITES)), + ("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") { + "Macintosh" + } else { + "Unix" + }; + let items = vec![ + ("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)), + ]; + realm.define_file_classes(items) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(text: &str, base: &str, windows: bool) -> Pathname { + let root = Pathname::root(windows); + let home = Pathname::parse(if windows { r"C:\Users\me" } else { "/home/me" }, &root, &root, windows, false); + let base = Pathname::parse(base, &home, &home, windows, false); + Pathname::parse(text, &base, &home, windows, true) + } + + #[test] + fn windows_paths_take_both_separators_drives_and_uri_names() { + let f = p(r"C:\art\one two.txt", r"C:\work", true); + assert_eq!((f.fs(), f.uri()), (r"C:\art\one two.txt".to_string(), "/c/art/one%20two.txt".to_string())); + assert_eq!(p("/d/x/y.txt", r"C:\work", true).fs(), r"D:\x\y.txt"); + assert_eq!(p("c:/a/./b/../c", r"C:\work", true).fs(), r"C:\a\c"); + assert_eq!(p("sub/f.txt", r"C:\work", true).fs(), r"C:\work\sub\f.txt"); + assert_eq!(p("~/notes.txt", r"C:\work", true).fs(), r"C:\Users\me\notes.txt"); + assert_eq!(p(r"\\host\share\x.txt", r"C:\work", true).fs(), r"\\host\share\x.txt"); + assert_eq!(p(r"\\?\C:\x.txt", r"C:\work", true).root, Root::Device(r"\\?\C:\x.txt".into())); + assert_eq!(p("caf%C3%A9.txt", r"C:\w", true).fs(), "C:\\w\\caf\u{e9}.txt"); + assert_eq!(p("..\\..\\..\\..", r"C:\w", true).fs(), r"C:\"); + assert_eq!(p("C:", r"D:\w", true).uri(), "/c"); + } + + #[test] + fn unix_paths_keep_backslashes_in_names() { + assert_eq!(p(r"a\b.txt", "/work", false).fs(), r"/work/a\b.txt"); + assert_eq!(p("/x/../y", "/work", false).fs(), "/y"); + assert_eq!(p("~", "/work", false).fs(), "/home/me"); + assert_eq!(p("/", "/work", false).uri(), "/"); + } + + #[test] + fn relative_uris_climb_to_the_common_folder() { + let base = p("/a/b", "/", false); + assert_eq!(p("/a/b/c d.txt", "/", false).relative_to(&base, false), "c%20d.txt"); + assert_eq!(p("/a/x.txt", "/", false).relative_to(&base, false), "../x.txt"); + assert_eq!(p("/a/b", "/", false).relative_to(&base, false), "."); + assert_eq!(p("D:/x", "C:/", true).relative_to(&p("C:/a", "C:/", true), true), "/d/x"); + } + + #[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!(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"); + } + + #[test] + fn masks_match_stars_and_question_marks_without_case() { + let matches = |mask: &str, name: &str| Mask::new(mask).matches(name, &mut { usize::MAX }) == Some(true); + assert!(matches("*.JSX", "a.jsx")); + assert!(matches("a?c*", "abcdef")); + assert!(!matches("*.txt", "a.jsx")); + assert!(matches("*", "")); + assert!(matches("*a*b", "xaxxb")); + assert!(matches("a***b", "ab")); + assert!(!matches("?", "")); + } + + #[test] + fn masks_stop_when_their_steps_run_out() { + let mask = Mask::new(&format!("*{}b", "a".repeat(100))); + let name = "a".repeat(200); + let mut steps = 1000; + assert_eq!(mask.matches(&name, &mut steps), None); + assert_eq!(steps, 0); + let mut steps = 1000; + assert_eq!(Mask::new("*.jsx").matches("a.jsx", &mut steps), Some(true)); + assert!(steps > 990); + } + + #[test] + fn decoding_counts_characters_and_takes_four_line_ends() { + let mut f = Open::new(b"\xEF\xBB\xBFa\xC3\xA9\r\nb\n\rc\rd\ne".to_vec(), "").unwrap(); + assert_eq!((f.name.as_str(), f.pos), ("UTF-8", 3), "the byte order mark is passed over"); + assert_eq!(f.read(Some(2)).unwrap(), "a\u{e9}"); + assert_eq!(f.pos, 6, "two characters, three bytes"); + assert_eq!(f.read_line().unwrap(), "", "CRLF is one line end"); + assert_eq!(f.read_line().unwrap(), "b", "LFCR is one line end"); + assert_eq!(f.read_line().unwrap(), "c"); + assert_eq!(f.read_char().unwrap(), "d"); + assert_eq!(f.read_char().unwrap(), "\n"); + assert_eq!(f.read(None).unwrap(), "e"); + assert_eq!(f.read_char().unwrap(), ""); + let mut b = Open::new(vec![0xE8, 0x41], "BINARY").unwrap(); + assert_eq!(b.read(None).unwrap(), "\u{e8}A"); + let mut a = Open::new(vec![0x41, 0xE8], "ascii").unwrap(); + assert!(a.read(None).is_err()); + assert_eq!(a.pos, 0, "a failed read leaves the position"); + let mut c = Open::new(vec![0x80, 0xE9], "").unwrap(); + assert_eq!(c.name, "CP1252"); + assert_eq!(c.read(None).unwrap(), "\u{20ac}\u{e9}"); + let mut u = Open::new(vec![0xFF, 0xFE, 0x3D, 0xD8, 0x00, 0xDE, 0x41, 0x00], "").unwrap(); + assert_eq!(u.name, "UTF-16LE"); + assert_eq!(u.read(None).unwrap(), "\u{1f600}A"); + assert!(Open::new(b"x".to_vec(), "KLINGON").is_none()); + let mut l = Open::new(vec![b'a', b'\n', 0xE8, b'\r', 0xE8], "ASCII").unwrap(); + assert_eq!(l.read_line().unwrap(), "a", "a line end before a byte ASCII cannot read still ends the line"); + assert!(l.read_line().is_err()); + assert_eq!(l.pos, 2); + l.pos = 3; + assert_eq!(l.read_char().unwrap(), "\n"); + assert_eq!(l.pos, 4); + } +} diff --git a/crates/engine/src/script/folder.rs b/crates/engine/src/script/folder.rs new file mode 100644 index 000000000..d071195df --- /dev/null +++ b/crates/engine/src/script/folder.rs @@ -0,0 +1,302 @@ +//! The Scripts folder: the `.js` and `.jsx` files File › Scripts lists, with each subfolder as a +//! submenu ([`list`]), and the read of one of them when it is chosen ([`read`]). +//! +//! A listing reads names and file types only, never a script's text. It follows no link: a symbolic +//! link or a Windows junction is left out, whether it is a file, a folder or the Scripts folder +//! itself, so a listed script is a plain file in plain folders under the Scripts folder. Names that +//! start with a dot, files and folders Windows marks hidden or system, names that are not Unicode +//! and names longer than [`MAX_NAME_BYTES`] are left out too, and so is a folder with no script in +//! it. +//! +//! The values here are VectorCraft's own limits: [`MAX_ENTRIES`], [`MAX_SCRIPTS`], [`MAX_FOLDERS`], +//! [`MAX_DEPTH`], [`MAX_NAME_BYTES`], [`MAX_LABEL_CHARS`] and [`MAX_SCAN`]. A listing that reaches +//! one of them, or that cannot read a subfolder or an entry of a folder, keeps what it found and +//! says so ([`Listing::truncated`]). + +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use crate::cmd::scripts::runs; + +/// The most folder entries one listing looks at, in the Scripts folder and its subfolders together. +pub const MAX_ENTRIES: usize = 2048; +/// The most scripts one listing holds. +pub const MAX_SCRIPTS: usize = 512; +/// The most subfolders one listing reads. +pub const MAX_FOLDERS: usize = 128; +/// How many levels of subfolders a listing reads below the Scripts folder. +pub const MAX_DEPTH: usize = 4; +/// The longest file or folder name a listing takes, in UTF-8 bytes. +pub const MAX_NAME_BYTES: usize = 255; +/// The most characters of a label; a longer one ends in `…`. +pub const MAX_LABEL_CHARS: usize = 128; +/// How long one listing may take before it stops with what it found. +pub const MAX_SCAN: Duration = Duration::from_millis(250); + +/// The limits of one listing. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub entries: usize, + pub scripts: usize, + pub folders: usize, + pub depth: usize, + pub time: Duration, +} + +impl Default for Limits { + fn default() -> Self { + Limits { entries: MAX_ENTRIES, scripts: MAX_SCRIPTS, folders: MAX_FOLDERS, depth: MAX_DEPTH, time: MAX_SCAN } + } +} + +/// One row of a listing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Entry { + /// A script: its label (the file name without its extension) and the file's path as the folder + /// gave it. + Script { label: String, path: PathBuf }, + /// A subfolder with at least one script in it or below it: its label (the folder's name) and + /// its rows. + Folder { label: String, entries: Vec }, +} + +/// What a Scripts folder holds. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Listing { + /// The rows of the folder itself, sorted ([`list_with`]). + pub entries: Vec, + /// Some scripts may be missing: a limit was reached or a subfolder could not be read. + pub truncated: bool, + /// The folder is there and could not be read, or is a link. + pub unreadable: bool, +} + +impl Listing { + /// Every script with the labels of the folders it is in, in menu order. + pub fn scripts(&self) -> Vec<(Vec<&str>, &str, &Path)> { + fn walk<'a>(entries: &'a [Entry], folders: &mut Vec<&'a str>, out: &mut Vec<(Vec<&'a str>, &'a str, &'a Path)>) { + for e in entries { + match e { + Entry::Script { label, path } => out.push((folders.clone(), label, path)), + Entry::Folder { label, entries } => { + folders.push(label); + walk(entries, folders, out); + folders.pop(); + } + } + } + } + let mut out = vec![]; + walk(&self.entries, &mut vec![], &mut out); + out + } +} + +/// What the folder `root` holds, within the default [`Limits`]. A folder that does not exist gives +/// an empty listing. +pub fn list(root: &Path) -> Listing { + let start = std::time::Instant::now(); + list_with(root, &Limits::default(), &mut || start.elapsed()) +} + +/// What the folder `root` holds, within `limits`; `elapsed` says how long the listing has taken. +/// Each level is sorted by label without regard to case, then by label, then by file name, with +/// scripts and subfolders mixed. Two rows of one level never share a label unless both were cut: +/// a script whose label another row has shows its whole file name. +pub fn list_with(root: &Path, limits: &Limits, elapsed: &mut dyn FnMut() -> Duration) -> Listing { + let unreadable = Listing { unreadable: true, ..Listing::default() }; + match std::fs::symlink_metadata(root) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Listing::default(), + Err(_) => return unreadable, + Ok(m) if m.file_type().is_symlink() || !m.is_dir() => return unreadable, + Ok(_) => {} + } + // Paths leave as text (menu params, `script.listInstalled`). + if root.to_str().is_none() { + return unreadable; + } + let mut scan = Scan { limits, elapsed, visited: 0, scripts: 0, folders: 0, truncated: false, stopped: false }; + match scan.level(root, 0) { + Some(entries) => Listing { entries, truncated: scan.truncated, unreadable: false }, + None => unreadable, + } +} + +/// A script or subfolder found in one folder, before the subfolders are read. +struct Found { + name: String, + label: String, + folder: bool, +} + +struct Scan<'a> { + limits: &'a Limits, + elapsed: &'a mut dyn FnMut() -> Duration, + visited: usize, + scripts: usize, + folders: usize, + truncated: bool, + /// The entry or time limit was reached: nothing more is read. + stopped: bool, +} + +impl Scan<'_> { + /// The rows of the folder `dir`, which is `depth` levels below the Scripts folder; `None` when + /// it cannot be read. + fn level(&mut self, dir: &Path, depth: usize) -> Option> { + let mut found: Vec = vec![]; + for entry in std::fs::read_dir(dir).ok()? { + if self.visited >= self.limits.entries || (self.elapsed)() > self.limits.time { + (self.truncated, self.stopped) = (true, true); + break; + } + self.visited += 1; + let Ok(entry) = entry else { + self.truncated = true; + continue; + }; + let Ok(name) = entry.file_name().into_string() else { continue }; + let Ok(kind) = entry.file_type() else { + self.truncated = true; + continue; + }; + if name.len() > MAX_NAME_BYTES || name.starts_with('.') || kind.is_symlink() || hidden(&entry) { + continue; + } + if kind.is_dir() { + found.push(Found { label: shown(&name), name, folder: true }); + } else if kind.is_file() && runs(&name) { + if self.scripts >= self.limits.scripts { + self.truncated = true; + continue; + } + self.scripts += 1; + let stem = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); + found.push(Found { label: shown(stem), name, folder: false }); + } + } + // A script whose label another row of this folder has shows its whole file name, until no + // label is shared. Each pass gives at least one more script its whole file name, and names + // are unique within a folder, so this ends within `found.len()` passes. + loop { + let mut count = std::collections::HashMap::::new(); + for f in &found { + *count.entry(f.label.clone()).or_default() += 1; + } + let mut renamed = false; + for f in found.iter_mut().filter(|f| !f.folder) { + if count.get(&f.label).is_some_and(|n| *n > 1) { + let whole = shown(&f.name); + if f.label != whole { + f.label = whole; + renamed = true; + } + } + } + if !renamed { + break; + } + } + found.sort_by(|a, b| (a.label.to_lowercase(), &a.label, &a.name).cmp(&(b.label.to_lowercase(), &b.label, &b.name))); + let mut out = Vec::with_capacity(found.len()); + for f in found { + let path = dir.join(&f.name); + if !f.folder { + out.push(Entry::Script { label: f.label, path }); + continue; + } + if self.stopped { + continue; + } + if depth >= self.limits.depth || self.folders >= self.limits.folders { + self.truncated = true; + continue; + } + self.folders += 1; + match self.level(&path, depth + 1) { + Some(entries) if entries.is_empty() => {} + Some(entries) => out.push(Entry::Folder { label: f.label, entries }), + None => self.truncated = true, + } + } + Some(out) + } +} + +/// `name` as a menu shows it: control and bidirectional formatting characters written as escapes, +/// and at most [`MAX_LABEL_CHARS`] characters. +fn shown(name: &str) -> String { + vectorcraft_plugins::permissions::display_text(name, MAX_LABEL_CHARS) +} + +/// Does Windows mark `entry` hidden or system? +fn hidden(entry: &std::fs::DirEntry) -> bool { + entry.metadata().is_ok_and(|m| hidden_meta(&m)) +} + +/// Does Windows mark the file or folder of `m` hidden or system? +#[cfg(windows)] +fn hidden_meta(m: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + const HIDDEN_OR_SYSTEM: u32 = 0x2 | 0x4; + m.file_attributes() & HIDDEN_OR_SYSTEM != 0 +} + +/// Elsewhere a hidden file is one whose name starts with a dot, which the listing leaves out by name. +#[cfg(not(windows))] +fn hidden_meta(_: &std::fs::Metadata) -> bool { + false +} + +/// Is `path` a folder (`folder`) or a file, and not a link? With `shown`, also not one Windows +/// marks hidden or system, which a listing leaves out. +fn plain(path: &Path, folder: bool, shown: bool) -> bool { + std::fs::symlink_metadata(path) + .is_ok_and(|m| !m.file_type().is_symlink() && (if folder { m.is_dir() } else { m.is_file() }) && !(shown && hidden_meta(&m))) +} + +/// Is `path` a script a listing of `root` can hold now: a `.js` or `.jsx` file at most +/// [`MAX_DEPTH`] folders below `root`, where `root`, each folder on the way and the file are there +/// and none is a link, and neither the file nor a folder below `root` is marked hidden or system? +pub fn holds(root: &Path, path: &Path) -> bool { + let Ok(rest) = path.strip_prefix(root) else { return false }; + let names: Option> = rest + .components() + .map(|c| match c { + Component::Normal(name) => name.to_str().filter(|n| !n.starts_with('.') && n.len() <= MAX_NAME_BYTES), + _ => None, + }) + .collect(); + let Some(names) = names else { return false }; + let Some((file, folders)) = names.split_last() else { return false }; + if folders.len() > MAX_DEPTH || !runs(file) || !plain(root, true, false) { + return false; + } + let mut at = root.to_path_buf(); + for folder in folders { + at.push(folder); + if !plain(&at, true, true) { + return false; + } + } + at.push(file); + plain(&at, false, true) +} + +/// The first `max` bytes of the script at `path`, or `None` when [`holds`] does not hold for it +/// before the read and again after it, or it cannot be read. The two checks leave no time in which +/// a link stood in for the file or one of its folders for the whole read; a program that swaps one +/// in and back out between a check and the open can still have another file read. +pub fn read(root: &Path, path: &Path, max: usize) -> Option> { + if !holds(root, path) { + return None; + } + let file = std::fs::File::open(path).ok()?; + if !file.metadata().is_ok_and(|m| m.is_file()) { + return None; + } + let mut bytes = vec![]; + file.take(max as u64).read_to_end(&mut bytes).ok()?; + holds(root, path).then_some(bytes) +} diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs new file mode 100644 index 000000000..6cf0c60be --- /dev/null +++ b/crates/engine/src/script/include.rs @@ -0,0 +1,447 @@ +//! A script's preprocessor lines, read before it runs, as the public guide lists them +//! (): +//! `#include` and `//@include` put the text of another file in place of the line, `#includepath` +//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks +//! of `$.strict` (: +//! a write to a read-only property is a runtime error, and objects that take no new properties +//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file +//! `$.evalFile` runs. +//! +//! VectorCraft's own rules: +//! +//! - A line counts only at the start of a line (blanks before it), outside strings and comments, as +//! the lexer reads it, and each file must be a whole script on its own. +//! - An included file is looked for beside the file that includes it, then in each `#includepath` +//! folder in turn (a relative one is relative to that same file's folder), then in each folder of +//! 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. +//! - 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 +//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5 +//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the +//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if +//! it has one, and otherwise the caller's. +//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files, +//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up +//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has +//! ended; one that includes itself, directly or not, is an error. +//! +//! The expanded source keeps a [`SourceMap`], so an error is reported at the line and column of +//! the file it is in. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use vectorcraft_js::{DirectiveName, Options}; + +use crate::file_access::{ScriptRead, ScriptScope}; + +/// The most files nested inside each other, below the script. +pub const MAX_DEPTH: usize = 16; +/// The most files a run includes or evaluates. +pub const MAX_FILES: usize = 64; +/// The most bytes of source a run reads, its own included. +pub const MAX_TOTAL_BYTES: usize = 32 << 20; +/// The most paths a run looks up for its files. +pub const MAX_PROBES: usize = 1024; +/// The longest value a preprocessor line may have, in bytes. +const MAX_VALUE_BYTES: usize = 4096; + +/// Where the lines of an expanded source came from: file 0 is the file expanded, the others the +/// files it included. +#[derive(Clone, Debug, Default)] +pub(crate) struct SourceMap { + files: Vec>, + segments: Vec, +} + +/// From line `out_line` of the expanded source on, the lines are those of file `file` from line +/// `line`; on `out_line` itself the columns are `shift` units further right. +#[derive(Clone, Copy, Debug)] +struct Segment { + out_line: u32, + file: usize, + line: u32, + shift: u32, +} + +impl SourceMap { + /// Line `line`, column `column` of the expanded source → the file it is in (its index and name) + /// and the line and column there. + pub(crate) fn locate(&self, line: u32, column: u32) -> (usize, Arc, u32, u32) { + let at = self.segments.partition_point(|s| s.out_line <= line); + let Some(seg) = at.checked_sub(1).and_then(|i| self.segments.get(i)) else { + return (0, self.files.first().cloned().unwrap_or_else(|| Arc::from("")), line, column); + }; + let column = if line == seg.out_line { column.saturating_sub(seg.shift).max(1) } else { column }; + let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from("")); + (seg.file, name, seg.line.saturating_add(line - seg.out_line), column) + } +} + +/// How a preprocessor failure ends the run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FailKind { + /// A line that is not a valid preprocessor line, or a file that is not a valid script. + Syntax, + /// A file that is missing, refused or unreadable, or a cycle. + Error, + /// One of the run's limits. + Limit, +} + +/// A preprocessor failure, at line `line`, column `column` of the file `file` (index `index` of +/// the expansion's [`SourceMap`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Failure { + pub kind: FailKind, + pub message: String, + pub index: usize, + pub file: Arc, + pub line: u32, + pub column: u32, +} + +/// 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). +pub(crate) struct Expanded { + pub source: String, + pub map: SourceMap, + pub name: Option, + pub strict: Option, +} + +/// A file being run: its name for people and the folder its relative paths start from (none for a +/// script given as text). +struct Frame { + shown: Arc, + dir: Option, +} + +/// The files of one run: its scope, the `#includepath` list, what it has read and the files open +/// now, innermost last. +pub(crate) struct Files { + scope: ScriptScope, + jsinclude: Vec, + include_path: Vec, + files: usize, + bytes: usize, + probes: usize, + /// The real paths of the files open now, for cycles. + chain: Vec, + /// How many of them are below the script. + levels: usize, + /// The files `$.evalFile` is running, the script first. + frames: Vec, +} + +/// The output being built: its UTF-16 text, the line it is on, the units since that line began, +/// whether its last unit is a carriage return, and what the last `#strict` line so far set. +struct Out { + units: Vec, + line: u32, + column: u32, + after_cr: bool, + map: SourceMap, + strict: Option, +} + +impl Out { + fn emit(&mut self, text: &[u16]) { + for &u in text { + match u { + 0x0A if self.after_cr => self.column = 0, + 0x0A | 0x0D | 0x2028 | 0x2029 => { + self.line = self.line.saturating_add(1); + self.column = 0; + } + _ => self.column = self.column.saturating_add(1), + } + self.after_cr = u == 0x0D; + } + self.units.extend_from_slice(text); + } + + /// The lines from here on come from file `file`, from its line `line`. + fn begin(&mut self, file: usize, line: u32) { + self.map.segments.push(Segment { out_line: self.line, file, line, shift: self.column }); + } + + /// Ends the line the output is on, unless it has just ended. + fn end_line(&mut self) { + if self.after_cr || !matches!(self.units.last(), Some(0x0A | 0x2028 | 0x2029)) { + self.emit(&[0x0A]); + } + } +} + +/// A file's text read as a script: a byte order mark is left out, and compiled scripts and bytes +/// that are not UTF-8 are refused. +fn decode(bytes: &[u8], value: &str) -> Result { + if bytes.starts_with(b"@JSXBIN@") { + return Err(format!("script file is compiled: {value}")); + } + let text = std::str::from_utf8(bytes).map_err(|_| format!("script file is not UTF-8 text: {value}"))?; + Ok(text.strip_prefix('\u{feff}').unwrap_or(text).to_string()) +} + +/// `value` without the single or double quotes around it, or `None` when a quote is not closed. +fn unquote(value: &str) -> Option<&str> { + match value.chars().next() { + Some(q @ ('"' | '\'')) => value.get(1..).and_then(|v| v.strip_suffix(q)), + _ => Some(value), + } +} + +/// The folder the file at `path` is in, without Windows' `\\?\` prefix: under that prefix `..` and +/// `/` in the names joined to it would be read as plain characters. +fn folder_of(path: &Path) -> Option { + path.parent().map(|p| crate::cmd::findfiles::plain(p.to_path_buf())) +} + +/// Does the path `p` have a `..` name, with `/` or `\` between names? +fn has_parent(p: &str) -> bool { + p.split(['/', '\\']).any(|name| name == "..") +} + +/// A real path as people read it. +fn display(path: &Path) -> Arc { + Arc::from(crate::cmd::findfiles::plain(path.to_path_buf()).to_string_lossy().as_ref()) +} + +impl Files { + /// The files of a run of the script at `path` (`None` for a script given as text), with the + /// `JSINCLUDE` folders `jsinclude`. + pub(crate) fn new(path: Option<&str>, jsinclude: Vec) -> Self { + let real = path.and_then(|p| std::fs::canonicalize(p).ok()); + let dir = real.as_deref().and_then(folder_of); + Self { + scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(), + jsinclude, + include_path: Vec::new(), + files: 0, + bytes: 0, + probes: 0, + chain: real.into_iter().collect(), + levels: 0, + frames: vec![Frame { shown: Arc::from(path.unwrap_or_default()), dir }], + } + } + + /// The `JSINCLUDE` folders of this process: absolute folders separated by `;`. + pub(crate) fn jsinclude_env() -> Vec { + #[cfg(test)] + if let Some(list) = super::TEST_JSINCLUDE.with(|t| t.borrow().clone()) { + return list; + } + std::env::var("JSINCLUDE").map(|v| v.split(';').map(str::trim).filter(|p| !p.is_empty()).map(PathBuf::from).collect()).unwrap_or_default() + } + + /// The file being run now, as `$.fileName` gives it (empty for a script given as text). + pub(crate) fn file_name(&self) -> String { + self.frames.last().map(|f| f.shown.to_string()).unwrap_or_default() + } + + /// The `#includepath` list, as `$.includePath` gives it. + pub(crate) fn include_path(&self) -> String { + self.include_path.join(";") + } + + /// The folders the run's own files are read from. + pub(crate) fn scope(&self) -> &ScriptScope { + &self.scope + } + + /// The script's own text, `text`, with its preprocessor lines read. + pub(crate) fn expand_script(&mut self, text: &str) -> Result { + self.bytes = self.bytes.saturating_add(text.len()); + let shown = self.frames.first().map(|f| f.shown.clone()).unwrap_or_else(|| Arc::from("")); + let dir = self.frames.first().and_then(|f| f.dir.clone()); + self.expand(shown, dir, text, true) + } + + /// The file `path` names for `$.evalFile`, relative to the file being run now, read and with its + /// preprocessor lines read. It becomes the file being run until [`Files::end_eval`]. + pub(crate) fn eval_file(&mut self, path: &str) -> Result { + let current = self.frames.last().map(|f| f.shown.clone()).unwrap_or_else(|| Arc::from("")); + let fail = |kind, message: String| Failure { kind, message, index: 0, file: current.clone(), line: 0, column: 0 }; + let dir = self.frames.last().and_then(|f| f.dir.clone()); + let (real, bytes) = self.open(path, dir.as_deref(), false).map_err(|(kind, message)| fail(kind, message))?; + let text = self.admit(&real, bytes, path).map_err(|(kind, message)| fail(kind, message))?; + let shown = display(&real); + self.chain.push(real.clone()); + self.levels += 1; + self.frames.push(Frame { shown: shown.clone(), dir: folder_of(&real) }); + let r = self.expand(shown, folder_of(&real), &text, false); + if r.is_err() { + self.end_eval(); + } + r + } + + /// The file `$.evalFile` ran has ended. + pub(crate) fn end_eval(&mut self) { + if self.frames.len() > 1 { + self.frames.pop(); + self.chain.pop(); + self.levels = self.levels.saturating_sub(1); + } + } + + /// Checks a file read at `real` against the run's limits and the files open now, and decodes + /// it. `value` is the path as the script wrote it. + fn admit(&mut self, real: &Path, bytes: Vec, value: &str) -> Result { + if self.chain.iter().any(|p| p == real) { + return Err((FailKind::Error, format!("include cycle: {value}"))); + } + if self.levels >= MAX_DEPTH { + return Err((FailKind::Limit, format!("include depth limit ({MAX_DEPTH}) reached"))); + } + if self.files >= MAX_FILES { + return Err((FailKind::Limit, format!("include file limit ({MAX_FILES}) reached"))); + } + if self.bytes.saturating_add(bytes.len()) > MAX_TOTAL_BYTES { + return Err((FailKind::Limit, "script source limit reached (32 MiB)".to_string())); + } + self.files += 1; + self.bytes += bytes.len(); + decode(&bytes, value).map_err(|m| (FailKind::Error, m)) + } + + /// Finds and reads the file `value` names, from the file in folder `dir`; with `search`, also in + /// the `#includepath` and `JSINCLUDE` folders. + fn open(&mut self, value: &str, dir: Option<&Path>, search: bool) -> Result<(PathBuf, Vec), (FailKind, String)> { + let path = Path::new(value); + let refused = || (FailKind::Error, format!("script file read refused: {value}")); + // A script given as text has no folder, and so no file it may read. + let Some(dir) = dir.filter(|_| !has_parent(value)) else { return Err(refused()) }; + // Each candidate, and whether the scope's refusal passes it over. + let mut candidates: Vec<(Option, bool)> = vec![(Some(dir.join(path)), false)]; + if search && !path.is_absolute() { + candidates.extend(self.include_path.iter().map(|p| ((!has_parent(p)).then(|| dir.join(p).join(path)), false))); + candidates.extend(self.jsinclude.iter().filter(|p| p.is_absolute()).map(|p| (Some(p.join(path)), true))); + } + for (candidate, passed_over_when_refused) in candidates { + let Some(candidate) = candidate else { return Err(refused()) }; + self.probes += 1; + if self.probes > MAX_PROBES { + return Err((FailKind::Limit, format!("include search limit ({MAX_PROBES} paths) reached"))); + } + match self.scope.read(&candidate, crate::script::MAX_SOURCE_BYTES) { + Ok(found) => return Ok(found), + Err(ScriptRead::Missing) => {} + Err(ScriptRead::Refused) if passed_over_when_refused => {} + Err(ScriptRead::Refused) => return Err(refused()), + Err(ScriptRead::TooLarge) => return Err((FailKind::Limit, format!("script file is too large: {value}"))), + Err(ScriptRead::Failed(e)) => return Err((FailKind::Error, format!("cannot read script file {value}: {e}"))), + } + } + Err((FailKind::Error, format!("include file not found: {value}"))) + } + + /// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`, + /// 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 source = String::from_utf16(&out.units).map_err(|_| Failure { + kind: FailKind::Syntax, + message: "the script is not valid text".into(), + index: 0, + file: out.map.files.first().cloned().unwrap_or_else(|| Arc::from("")), + line: 0, + column: 0, + })?; + Ok(Expanded { source, map: out.map, name, strict: out.strict }) + } + + /// 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> { + let index = out.map.files.len(); + out.map.files.push(shown.clone()); + let units: Vec = text.encode_utf16().collect(); + out.begin(index, 1); + // A source with neither `#` nor `@` has no preprocessor line. + if !units.iter().any(|&u| u == u16::from(b'#') || u == u16::from(b'@')) { + out.emit(&units); + return Ok(()); + } + let fail = |kind, message: String, line, column| Failure { kind, message, index, file: shown.clone(), line, column }; + let options = Options { preprocessor_lines: true, max_source_units: MAX_TOTAL_BYTES, ..Options::default() }; + let program = vectorcraft_js::parse_utf16(&units, &options).map_err(|e| fail(FailKind::Syntax, e.message, e.line, e.column))?; + let mut cursor = 0usize; + for d in &program.directives { + let (start, end) = (d.span.start as usize, d.span.end as usize); + let line_start = + 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()); + cursor = end; + let label = format!("{}{}", d.form, d.name); + if d.value.len() > MAX_VALUE_BYTES { + return Err(at(FailKind::Syntax, format!("the value of {label} is too long"))); + } + let value = unquote(&d.value).ok_or_else(|| at(FailKind::Syntax, format!("unterminated quote in {label}")))?; + match d.known { + Some(DirectiveName::Include) => { + if value.is_empty() { + return Err(at(FailKind::Syntax, format!("{label} needs a file name"))); + } + 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); + self.chain.pop(); + self.levels -= 1; + r?; + out.end_line(); + out.begin(index, d.line); + } + Some(DirectiveName::IncludePath) => { + self.include_path = value.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect(); + } + Some(DirectiveName::Script) => { + if value.is_empty() { + return Err(at(FailKind::Syntax, format!("{label} needs a name"))); + } + if script && index == 0 { + *name = Some(value.to_string()); + } + } + Some(DirectiveName::Target) => { + if value.is_empty() { + return Err(at(FailKind::Syntax, format!("{label} needs a value"))); + } + } + Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() { + "off" => out.strict = Some(false), + "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}"))), + None if d.name.eq_ignore_ascii_case("engine") => { + return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))); + } + None => return Err(at(FailKind::Syntax, format!("unknown script directive: {label}"))), + } + } + out.emit(units.get(cursor..).unwrap_or_default()); + Ok(()) + } +} diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 35e93c1ad..8d223f53c 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -20,7 +20,31 @@ //! The values here are VectorCraft's own limits: [`MAX_SOURCE_BYTES`], [`DEFAULT_FUEL`], //! [`MAX_FUEL`], [`SLICE_FUEL`], [`HEAP_BYTES`], [`MAX_HOST_CALLS`], [`GROWTH_OBJECTS`], //! [`GROWTH_BYTES`], [`MAX_CONTINUES`], [`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], [`MAX_ARGS_BYTES`], -//! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default. +//! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default, +//! and the console's ([`console`]). +//! +//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the +//! place of their lines, and an error in one is reported at its own file, line and column. +//! `$.evalFile(path)` runs another file in the same global environment and gives its last value, +//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list. +//! +//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line +//! there too. The session's console gets a start line, the run's output, its uncaught error, an end +//! line and a report line; the run's report holds its own output and error, its time, its host +//! calls by kind and its slowest commands. The rest of `$`, and the global `localize` and +//! `toSource`, are in [`dollar`]. +//! +//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the +//! 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`]). + +pub mod console; +pub mod dialogs; +pub mod dollar; +pub mod include; + +pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog}; use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -36,6 +60,9 @@ use crate::{ Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit, }; +pub mod files; +pub mod folder; + /// The most bytes of source a run takes, as a file or as `source`. pub const MAX_SOURCE_BYTES: usize = 8 << 20; /// The instructions a run may execute when `maxFuel` is left out. @@ -72,7 +99,9 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30; /// The version `vc.api.version` gives. pub const API_VERSION: &str = "1.0"; /// The features `vc.api.has` knows. -pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args"]; +pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs", "dollar"]; +/// The most commands a run's report lists in `slowestCommands`. +pub const SLOWEST_COMMANDS: usize = 5; /// What the step of a run's one undo step is called. pub const STEP_LABEL: &str = "Run Script"; @@ -121,10 +150,18 @@ pub(crate) fn refused_text(id: &str) -> String { /// A request to run a script. pub(crate) struct Request { + /// The run's name; unless `name_given`, a `#script` line in the script replaces it. pub name: String, + pub name_given: bool, + /// The file the script was read from, which its included files are read beside. + pub file: Option, pub source: String, pub args: Map, pub fuel: u64, + /// The locale the run starts with (`$.locale`). + pub locale: String, + /// The answers to the dialogs of a run with nobody to ask, in order. + pub answers: Vec, } thread_local! { @@ -136,21 +173,27 @@ thread_local! { static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// A session's hold on its running job: while one exists the lease is held. Dropping it ends the job. +/// It keeps the run's number and its name as console lines give it, so the run's console entry can +/// end when the job is gone. #[derive(Debug)] -pub struct JobKey(u64); +pub struct JobKey { + id: u64, + run: u64, + name: Arc, +} impl Drop for JobKey { fn drop(&mut self) { let _ = JOBS.try_with(|jobs| { if let Ok(mut jobs) = jobs.try_borrow_mut() { - jobs.remove(&self.0); + jobs.remove(&self.id); } }); } } fn with_job(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { - JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.0).map(f)) + JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f)) } /// The state a script changes that a rollback puts back. @@ -446,13 +489,177 @@ struct Host { log: RefCell>, log_bytes: Cell, written: RefCell>, + /// The run's number in its session, and the script's name, which its console lines carry. + run: u64, + name: Arc, + /// The run's own output and error, for its report. + console: RefCell, + /// The line `$.write` has not ended yet. + pending: RefCell, + counts: Counts, + /// Each command `vc.run` ran: its calls, its total time and its longest call, in nanoseconds. + commands: RefCell>, + /// The time the run's slices took, in nanoseconds. + run_nanos: Cell, + /// `alert`, `confirm` and `prompt`: their answers and the list the report gives. + dialogs: dialogs::Dialogs, + /// The files the run reads: its includes and `$.evalFile`'s. + files: RefCell, + /// What `File` and `Folder` have open and have read. + disk: RefCell, + /// Where the lines of the script's expanded source came from. + script_map: RefCell, + /// The same for each source `$.evalFile` gave, in order. + eval_maps: RefCell>, + /// The file, line and column of each error `$.evalFile` raised for a file it could not run, in + /// order: the error's mark ([`HostError::Marked`]) is its index. + eval_failures: RefCell, u32, u32)>>, + /// When the run started, where the platform has a clock. + started: Option, + /// The time since [`Host::started`] at which a `$.sleep` ends. + wake_at: Cell>, + /// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]). + can_wait: Rc>, + /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). + eval_strict: Cell>, +} + +/// A run's host calls by kind, as its report gives them in `hostCallCounts`. +#[derive(Default)] +struct Counts { + vc_run: Cell, + document_reads: Cell, + command_list_reads: Cell, + vc_log: Cell, + console_writes: Cell, + /// Calls of `File` and `Folder` that reach the host ([`files`]). + file_calls: Cell, + /// `execute` calls, which open nothing. + file_executes: Cell, +} + +impl Counts { + fn json(&self) -> Value { + json!({ + "vcRun": self.vc_run.get(), + "documentReads": self.document_reads.get(), + "commandListReads": self.command_list_reads.get(), + "vcLog": self.vc_log.get(), + "consoleWrites": self.console_writes.get(), + "fileCalls": self.file_calls.get(), + "fileExecutes": self.file_executes.get(), + }) + } +} + +fn bump(c: &Cell) { + c.set(c.get().saturating_add(1)); +} + +/// Now, where the platform has a clock (not in a wasm build). +fn clock() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + Some(std::time::Instant::now()) + } + #[cfg(target_arch = "wasm32")] + { + None + } +} + +/// Whole milliseconds, rounded down. +fn millis(nanos: u128) -> u64 { + u64::try_from(nanos / 1_000_000).unwrap_or(u64::MAX) +} + +/// The [`SLOWEST_COMMANDS`] commands of `times` that took longest in all, longest first and then by +/// id. +pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec { + let mut all: Vec<(&&str, &(u64, u128, u128))> = times.iter().collect(); + all.sort_by(|a, b| b.1.1.cmp(&a.1.1).then_with(|| a.0.cmp(b.0))); + all.into_iter() + .take(SLOWEST_COMMANDS) + .map(|(id, &(calls, total, max))| console::Slow { id: (*id).to_string(), calls, total_ms: millis(total), max_ms: millis(max) }) + .collect() } impl Host { + /// Adds a line to the run's console and, during a slice, to the session's. + fn line(&self, entry: console::Entry, cut: bool) { + let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry }; + if let Ok(mut s) = self.session.try_borrow_mut() { + s.script_console.push(line.clone(), cut); + } + self.console.borrow_mut().push(line, cut); + } + + /// `$.write` and `$.writeln`: `text`, then with `end` a line break. + fn write(&self, text: &str, end: bool) { + let mut pending = self.pending.borrow_mut(); + pending.write(text, |line, cut| self.line(console::Entry::Output(line), cut)); + if end { + pending.end_line(|line, cut| self.line(console::Entry::Output(line), cut)); + } + } + + /// The time since the run started; none without a clock. + fn elapsed(&self) -> Option { + #[cfg(test)] + if TEST_NO_CLOCK.with(Cell::get) { + return None; + } + #[cfg(test)] + if let Some(d) = TEST_ELAPSED.with(Cell::get) { + return Some(d); + } + self.started.map(|s| s.elapsed()) + } + + /// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock + /// it does not wait. A getter, a conversion, a built-in's callback or a file `$.evalFile` runs + /// cannot stop at the call, so a wait there is an error, with or without a clock. + fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> { + if d.is_zero() { + return Ok(()); + } + if !self.can_wait.get() { + return Err(HostError::Error( + "$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code" + .into(), + )); + } + if let Some(now) = self.elapsed() { + self.wake_at.set(Some(now.saturating_add(d))); + self.pause.set(true); + } + Ok(()) + } + + /// The time left of a `$.sleep`, or none when the run is not asleep. + fn asleep(&self) -> Option { + let wake = self.wake_at.get()?; + let left = self.elapsed().map_or(std::time::Duration::ZERO, |now| wake.saturating_sub(now)); + if left.is_zero() { + self.wake_at.set(None); + return None; + } + Some(left) + } + fn over(&self, limit: &str) -> HostError { HostError::Abort(limit.to_string()) } + /// `error`, marked as an error at `file`, `line` and `column`, the place [`located`] gives when + /// the run ends with it uncaught. + fn placed(&self, error: HostError, file: Arc, line: u32, column: u32) -> HostError { + let mut places = self.eval_failures.borrow_mut(); + let Ok(mark) = u32::try_from(places.len()) else { return error }; + places.push((file, line, column)); + HostError::Marked { error: Box::new(error), mark } + } + fn charge_call(&self, bytes: u64) -> std::result::Result<(), HostError> { let calls = self.calls.get() + 1; self.calls.set(calls); @@ -502,6 +709,8 @@ struct Job { /// The documents open when the run began, by [`DocState::uid`]. start_uids: Vec, name: String, + /// The file-access scope `script.run` came in, which every slice runs in. + scope: crate::file_access::ScopeHandle, } /// The `Data` for a JSON value, within `limits`. When it does not fit, the error is the place in @@ -704,11 +913,20 @@ fn vc_run(host: &Rc, args: Vec) -> std::result::Result done, // A bug is not something a script can handle, and neither is a request to stop. @@ -776,10 +994,107 @@ fn vc_log(host: &Host, args: Vec) -> std::result::Result return Err(HostError::Range(LOG_FULL.to_string())); } host.log_bytes.set(host.log_bytes.get() + bytes); - host.log.borrow_mut().push(to_json(entry)); + let value = to_json(entry); + let text = match &value { + Value::String(s) => s.clone(), + v => v.to_string(), + }; + host.log.borrow_mut().push(value); + each_line(&text, |line, cut| host.line(console::Entry::Log(line), cut)); Ok(Data::Undefined) } +/// Calls `done` with each console line of `text` and whether that line was cut, one at a time. +fn each_line(text: &str, mut done: impl FnMut(String, bool)) { + let mut pending = console::Pending::default(); + pending.write(text, &mut done); + pending.end_line(done); +} + +/// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)` +/// gives them, one after another, and `$.writeln(...values)` does the same and ends the line. +/// `$.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)> { + let writer = |end: bool| { + let h = host.clone(); + HostItem::TextFunction(function(move |a| { + 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); + h.write(&text, end); + Ok(Data::Undefined) + })) + }; + let getter = |read: fn(&include::Files) -> String| { + let h = host.clone(); + HostItem::Getter(function(move |_| { + 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(); + 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()), + } + }; + let mut items = vec![ + ("write".into(), writer(false)), + ("writeln".into(), writer(true)), + ("evalFile".into(), eval_file), + ("fileName".into(), getter(include::Files::file_name)), + ("includePath".into(), getter(include::Files::include_path)), + ]; + items.extend(dollar::members(host, info, localization)); + items +} + +/// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being +/// run, with its preprocessor lines read, which the interpreter then runs. `timeout`, in +/// milliseconds, is taken and not waited on: the file is read at once. +fn eval_file(host: &Host, args: Vec) -> std::result::Result { + let Some(Data::String(path)) = args.first().filter(|p| !matches!(p, Data::String(s) if s.is_empty())) else { + return Err(HostError::Type(EVAL_NO_PATH.to_string())); + }; + match args.get(1) { + None | Some(Data::Undefined) => {} + Some(Data::Number(ms)) if ms.is_finite() && *ms >= 0.0 => {} + Some(Data::Number(_)) => return Err(HostError::Range(EVAL_TIMEOUT.to_string())), + Some(_) => return Err(HostError::Type(EVAL_TIMEOUT.to_string())), + } + if args.len() > 2 { + return Err(HostError::Type(EVAL_ARGUMENTS.to_string())); + } + host.charge_call(path.len() as u64 + 2)?; + let expanded = host.files.borrow_mut().eval_file(path).map_err(|f| { + let message = if f.line > 0 { format!("{}:{}:{}: {}", f.file, f.line, f.column, f.message) } else { f.message }; + let error = match f.kind { + include::FailKind::Limit => HostError::Abort(message), + include::FailKind::Syntax => HostError::Syntax(message), + include::FailKind::Error => HostError::Error(message), + }; + if f.line > 0 { host.placed(error, f.file, f.line, f.column) } else { error } + })?; + // A file without preprocessor lines is parsed here first, so its syntax error names its place. + let options = vectorcraft_js::Options { max_source_units: include::MAX_TOTAL_BYTES, ..vectorcraft_js::Options::default() }; + if let Err(e) = vectorcraft_js::parse(&expanded.source, &options) { + host.files.borrow_mut().end_eval(); + let (_, file, line, column) = expanded.map.locate(e.line, e.column); + let message = format!("{file}:{line}:{column}: {}", e.message); + return Err(host.placed(HostError::Syntax(message), file, line, column)); + } + host.eval_maps.borrow_mut().push(expanded.map); + host.eval_strict.set(expanded.strict); + Ok(Data::String(expanded.source)) +} + fn function(f: impl Fn(Vec) -> std::result::Result + 'static) -> HostFunction { Rc::new(f) } @@ -793,6 +1108,7 @@ fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { let h = host.clone(); function(move |_| { h.charge_call(64)?; + bump(&h.counts.document_reads); vc_document(&h) }) }; @@ -802,6 +1118,7 @@ fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { let size = data_size(&list); function(move |_| { h.charge_call(size)?; + bump(&h.counts.command_list_reads); Ok((*list).clone()) }) }; @@ -809,6 +1126,7 @@ fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { let h = host.clone(); function(move |a| { h.charge_call(data_size(a.first().unwrap_or(&Data::Undefined)))?; + bump(&h.counts.vc_log); vc_log(&h, a) }) }; @@ -838,22 +1156,69 @@ fn kind_id(kind: &RunErrorKind) -> &'static str { } /// `message`, with what to do about a failure that comes from a script written for another program. -fn explained(kind: &RunErrorKind, message: &str, source: &str, line: u32) -> String { - const HOST_OBJECTS: &[&str] = - &["app", "$", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; +fn explained(kind: &RunErrorKind, message: &str) -> String { + const HOST_OBJECTS: &[&str] = &["app", "Window", "BridgeTalk", "activeDocument", "documents"]; let names_host_object = message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o))); - let directive = - *kind == RunErrorKind::Syntax && line > 0 && source.lines().nth(line as usize - 1).is_some_and(|l| l.trim_start().starts_with('#')); if *kind == RunErrorKind::Thrown && names_host_object { format!("{message} (scripts here use the `vc` object; objects other applications give their scripts are not available)") - } else if directive { - format!("{message} (preprocessor lines such as `#target` and `#include` are not read yet)") } else { message.to_string() } } +/// The error of a run that stopped in its preprocessor lines (`f`), as the realm gives its own. +fn preprocessor_error(f: include::Failure) -> (RunError, Option>) { + let (kind, message) = match f.kind { + include::FailKind::Syntax if f.line > 0 => { + (RunErrorKind::Syntax, format!("SyntaxError: {} (line {}, column {})", f.message, f.line, f.column)) + } + include::FailKind::Syntax => (RunErrorKind::Syntax, format!("SyntaxError: {}", f.message)), + include::FailKind::Error => (RunErrorKind::Thrown, format!("Error: {}", f.message)), + include::FailKind::Limit => (RunErrorKind::Limit(LimitKind::Host), f.message), + }; + let file = (f.index > 0).then_some(f.file); + (RunError { kind, message, line: f.line, column: f.column }, file) +} + +/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark` +/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name +/// unless it is the script's own. +fn located(host: &Host, tag: u32, mark: Option, e: &RunError) -> (RunError, Option>) { + let map = |m: &include::SourceMap, own: bool| { + let (index, file, line, column) = m.locate(e.line, e.column); + let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; + (RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file)) + }; + // An error `$.evalFile` raised for a file it could not run, and no script caught, is at that + // file's place. + if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { + let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; + return (RunError { kind: e.kind.clone(), message, line, column }, Some(file)); + } + if e.line == 0 { + return (e.clone(), 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), + }, + _ => (e.clone(), None), + } +} + +/// A syntax error's `message`, which ends with its place `(line L, column C)`, with the place +/// given as `line`, `column`. +fn relocated(message: &str, old_line: u32, old_column: u32, line: u32, column: u32) -> String { + let old = format!(" (line {old_line}, column {old_column})"); + match message.strip_suffix(&old) { + Some(m) => format!("{m} (line {line}, column {column})"), + None => message.to_string(), + } +} + impl Session { /// Is a script running? While one is, the commands that change anything are refused. pub fn script_running(&self) -> bool { @@ -871,6 +1236,19 @@ impl Session { self.script_report.as_ref() } + /// The Script Console: the lines of this session's runs, oldest first. + pub fn script_console(&self) -> &console::Console { + &self.script_console + } + + /// Empties the Script Console and sets its counts to zero. The last run's report, and the + /// report of a running script, keep their lines. → the lines removed. + pub fn clear_script_console(&mut self) -> usize { + let n = self.script_console.lines().len(); + self.script_console.clear(); + n + } + /// The error text of the last run when it failed: its name, place, message and kind, and the /// files it left written. pub fn script_failure_text(&self) -> Option { @@ -883,6 +1261,32 @@ impl Session { !self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false) } + /// The time left of the running script's `$.sleep`, or none when it is not asleep. A run asked + /// to stop is not asleep: the next [`Session::script_pump`] ends it. + pub fn script_sleep_left(&self) -> Option { + if self.script_stop.is_set() { + return None; + } + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.asleep())).flatten() + } + + /// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's + /// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it. + pub fn script_dialog(&self) -> Option { + if self.script_stop.is_set() { + return None; + } + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.dialogs.waiting())).flatten() + } + + /// Gives the dialog the running script waits at its answer; the next [`Session::script_pump`] + /// goes on from the call. → false when nothing waits, the answer is for another kind of + /// dialog, or a prompt's text is longer than [`dialogs::MAX_PROMPT_BYTES`]. + pub fn script_answer(&mut self, answer: DialogAnswer) -> bool { + let Some(k) = self.script_key.as_ref().filter(|_| !self.script_stop.is_set()) else { return false }; + with_job(k, |j| j.host.dialogs.answer(&mut j.realm, answer)).unwrap_or(false) + } + /// The name of the running script. pub fn script_name(&self) -> Option { self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone())) @@ -915,12 +1319,8 @@ impl Session { /// ([`Self::script_report`]). False with none running. pub fn script_abandon(&mut self) -> bool { let Some(key) = self.script_key.take() else { return false }; - let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { - drop(key); - raise_ids(self); - self.script_stop.clear(); - self.reset_tool(); - cmd::compositefonts::sync(self); + let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else { + self.script_gone(key, console::Ending::Stopped); return true; }; drop(key); @@ -930,6 +1330,19 @@ impl Session { true } + /// Ends the run whose job is no longer held: its console entry ends with `ending`, with no + /// report line, since its time and host calls went with the job, and the session's state is + /// put back as at the end of any run. + fn script_gone(&mut self, key: JobKey, ending: console::Ending) { + let line = console::ConsoleLine { run: key.run, name: key.name.clone(), entry: console::Entry::End(ending) }; + self.script_console.push(line, false); + drop(key); + raise_ids(self); + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + } + /// A script can't start while a document holds a drag in progress or an undo group, which its /// one undo step could not take in. fn settle_for_script(&mut self) -> Result<()> { @@ -943,8 +1356,8 @@ impl Session { Ok(()) } - /// Begins a run of `request`. - pub(crate) fn script_start(&mut self, request: Request) -> Result<()> { + /// Begins a run of `request`. → the run's name. + pub(crate) fn script_start(&mut self, request: Request) -> Result { self.settle_for_script()?; // In a build where a fault ends the program, every unsaved document has a copy first. if self.script_copy_first && self.docs.iter().any(DocState::is_dirty) && cmd::recovery::save_copies(self).is_err() { @@ -955,11 +1368,19 @@ impl Session { self.script_high_water.retain(|uid, _| open.contains(uid)); self.script_stop.clear(); self.script_report = None; + let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env()); + let expanded = files.expand_script(&request.source); + let name = match &expanded { + 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() }; - let options = Options { max_source_units: MAX_SOURCE_BYTES.saturating_mul(2), ..Options::default() }; + 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); realm.share_interrupt(&self.script_stop); realm.set_data_limits(DataLimits::default()); + self.script_runs += 1; let host = Rc::new(Host { session: Rc::new(RefCell::new(Session::new())), pause: realm.pause_flag(), @@ -976,35 +1397,83 @@ impl Session { log: RefCell::new(Vec::new()), log_bytes: Cell::new(0), written: RefCell::new(Vec::new()), + run: self.script_runs, + name: console::line_name(&name), + console: RefCell::default(), + pending: RefCell::default(), + counts: Counts::default(), + commands: RefCell::default(), + run_nanos: Cell::new(0), + dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers), + disk: RefCell::new(files::Disk::new(files.scope())), + files: RefCell::new(files), + script_map: RefCell::default(), + eval_maps: RefCell::default(), + eval_failures: RefCell::default(), + started: clock(), + wake_at: Cell::new(None), + can_wait: realm.wait_flag(), + eval_strict: Cell::new(None), }); 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") })?; - let started = realm.define_host_object("vc", vc_object(&host, args)).and_then(|()| realm.start(&request.source)); - if let Err(e) = started { - let report = failure_report(&request.name, &e, &request.source, &host, 0); + 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 localization = realm.localization(); + 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)) + .and_then(|()| realm.start(&expanded.source)) + .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) + } + }; + if let Err((e, file)) = started { + 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); self.script_report = Some(report); return Err(EngineError::Other(message)); } - let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name }; + 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 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)); - Ok(()) + self.script_key = Some(JobKey { id, run, name: line_name }); + Ok(name) } /// Advances the running script by one slice. `None` while it is still running; otherwise the /// run has ended: its report, or the error that ended it, with the documents put back. pub fn script_pump(&mut self) -> Option> { let key = self.script_key.take()?; - let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { + let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else { + self.script_gone(key, console::Ending::Internal); return Some(Err(EngineError::Other(JOB_GONE.to_string()))); }; - if job.host.waiting.get() && !self.script_stop.is_set() { - JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + if let Some(e) = job.host.dialogs.take_failure() { + return Some(self.script_failed(job, &e)); + } + if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { + JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); return None; } + // A stop while a dialog waits ends the run there; the interpreter cannot go on from the call. + if job.realm.waits() { + let kind = LimitKind::Interrupted; + let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; + return Some(self.script_failed(job, &e)); + } if vectorcraft_doc::cancel::check().is_err() { self.script_stop.set(); } @@ -1017,16 +1486,30 @@ impl Session { } Ok(Err(e)) => Some(self.script_failed(job, &e)), Ok(Ok(Progress::Paused)) => { - JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); None } - Ok(Ok(Progress::Done(_))) => Some(self.script_commit(job)), + Ok(Ok(Progress::Done(_))) if job.host.dialogs.unused() > 0 => { + let n = job.host.dialogs.unused(); + let kind = LimitKind::Host; + let message = format!("`answers` has {n} answer{} the script did not use", if n == 1 { "" } else { "s" }); + Some(self.script_failed(job, &RunError { kind: RunErrorKind::Limit(kind), message, line: 0, column: 0 })) + } + Ok(Ok(Progress::Done(_))) => { + let r = self.script_commit(job); + self.trim_histories(); + Some(r) + } } } fn script_failed(&mut self, job: Job, e: &RunError) -> Result { - let report = failure_report(&job.name, e, "", &job.host, job.realm.fuel_left()); + job.host.dialogs.stopped(); + let fuel_left = job.realm.fuel_left(); + 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); raise_ids(self); self.script_stop.clear(); @@ -1099,7 +1582,7 @@ impl Session { self.reset_tool(); cmd::compositefonts::sync(self); let host = &job.host; - let report = json!({ + let mut report = json!({ "ok": true, "name": job.name, "changed": changed_names, @@ -1108,6 +1591,7 @@ impl Session { "fuelUsed": job.realm.limits().fuel - job.realm.fuel_left(), "hostCalls": host.calls.get(), }); + close_run(self, host, &mut report, console::Ending::Finished); self.script_report = Some(report.clone()); Ok(report) } @@ -1118,9 +1602,14 @@ impl Session { if let Some(result) = self.script_pump() { return result; } - if self.script_waiting() { + if self.script_waiting() || self.script_dialog().is_some() { self.script_stop.set(); } + // A `$.sleep` waits here, where nothing is drawn, instead of turning the loop over. + #[cfg(not(target_arch = "wasm32"))] + if let Some(left) = self.script_sleep_left() { + std::thread::sleep(left.min(std::time::Duration::from_millis(100))); + } } } } @@ -1138,27 +1627,100 @@ const TOO_MANY_ARGUMENTS: &str = "vc.run: takes a command id and an object of pa const PARAMS_NOT_OBJECT: &str = "vc.run: the parameters must be an object"; const LOG_FULL: &str = "vc.log: the log of this run is full"; const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the script was not run"; +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"; -/// The report of a run that failed. -fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left: u64) -> Value { - json!({ +/// 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 { + let mut report = json!({ "ok": false, "kind": kind_id(&e.kind), - "message": explained(&e.kind, &e.message, source, e.line), + "message": explained(&e.kind, &e.message), "name": name, "line": e.line, "column": e.column, "written": *host.written.borrow(), "log": *host.log.borrow(), "fuelLeft": fuel_left, - }) + "fuelUsed": fuel_used, + "hostCalls": host.calls.get(), + }); + if let (Some(file), Some(o)) = (file, report.as_object_mut()) { + o.insert("file".into(), json!(file)); + } + report +} + +/// How the run that failed with `kind` ended, as its console end line gives it. +fn ending(kind: &RunErrorKind) -> console::Ending { + match kind { + RunErrorKind::Syntax | RunErrorKind::Thrown => console::Ending::Error, + RunErrorKind::Limit(LimitKind::Fuel) => console::Ending::Fuel, + RunErrorKind::Limit(LimitKind::Host) => console::Ending::HostLimit, + RunErrorKind::Limit(LimitKind::Interrupted) => console::Ending::Stopped, + RunErrorKind::Limit(_) => console::Ending::Limit, + RunErrorKind::Internal => console::Ending::Internal, + } +} + +/// Adds a line of `host`'s run to the session's console and to the run's own. +fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { + let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry }; + s.script_console.push(line.clone(), cut); + host.console.borrow_mut().push(line, cut); +} + +/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run +/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report +/// line in the session's console. Adds the run's time, host calls by kind, slowest commands, +/// console and dialogs to `report`. +fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::Ending) { + let mut pending = std::mem::take(&mut *host.pending.borrow_mut()); + if !pending.is_empty() { + let mut last = None; + pending.end_line(|line, cut| last = Some((line, cut))); + if let Some((line, cut)) = last { + line_to(s, host, console::Entry::Output(line), cut); + } + } + if report["ok"] == false { + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let message = report["message"].as_str().unwrap_or_default(); + let text = match report["file"].as_str() { + Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name), + _ if line > 0 => format!("{}:{line}:{column}: {message}", host.name), + _ => format!("{}: location unavailable: {message}", host.name), + }; + each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut)); + } + let timing = clock().is_some(); + let ms = millis(host.run_nanos.get()); + let slow = slowest(&host.commands.borrow()); + let slow_json: Vec = slow.iter().map(|c| json!({"id": c.id, "calls": c.calls, "totalMs": c.total_ms, "maxMs": c.max_ms})).collect(); + let line = |entry| console::ConsoleLine { run: host.run, name: host.name.clone(), entry }; + s.script_console.push(line(console::Entry::End(ending)), false); + let summary = console::Entry::Report { ms: timing.then_some(ms), host_calls: host.calls.get(), slowest: slow }; + s.script_console.push(line(summary), false); + if let Some(o) = report.as_object_mut() { + o.insert("runTimeMs".into(), json!(if timing { ms } else { 0 })); + o.insert("timingAvailable".into(), json!(timing)); + o.insert("hostCallCounts".into(), host.counts.json()); + o.insert("slowestCommands".into(), Value::Array(slow_json)); + o.insert("console".into(), host.console.borrow().report()); + host.dialogs.report(o); + } } /// The text of a failed run's error. fn failure_text(report: &Value) -> String { let s = |k: &str| report[k].as_str().unwrap_or_default(); let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); - let at = if line > 0 { format!(" line {line}, column {column}") } else { String::new() }; + let at = match report["file"].as_str() { + Some(file) if line > 0 => format!(" line {line}, column {column} of {file}"), + _ if line > 0 => format!(" line {line}, column {column}"), + _ => String::new(), + }; let written = report["written"].as_array().map_or(0, Vec::len); let kept = if written > 0 { let paths: Vec<&str> = report["written"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); @@ -1170,21 +1732,29 @@ fn failure_text(report: &Value) -> String { } /// A run's slice: the working copy in, the real session lent to the host functions, one measured -/// slice run, and everything back. A panic in the interpreter is the outer `Err`. +/// slice run in the file-access scope the run began in, and everything back. A panic in the +/// interpreter is the outer `Err`. fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result, String> { let depth = std::mem::replace(&mut s.depth, 0); job.world.swap(s); s.script_in_run = true; let cell = job.host.session.clone(); std::mem::swap(s, &mut *cell.borrow_mut()); + let scope = job.scope.clone(); let realm = &mut job.realm; - let r = guard::catch_panic(|| { - #[cfg(test)] - if PANIC_IN_SLICE.with(Cell::get) { - panic!("a slice failed"); - } - realm.advance(SLICE_FUEL) + let started = clock(); + let r = scope.run(|| { + guard::catch_panic(|| { + #[cfg(test)] + if PANIC_IN_SLICE.with(Cell::get) { + panic!("a slice failed"); + } + realm.advance(SLICE_FUEL) + }) }); + if let Some(started) = started { + job.host.run_nanos.set(job.host.run_nanos.get().saturating_add(started.elapsed().as_nanos())); + } std::mem::swap(s, &mut *cell.borrow_mut()); s.script_in_run = false; job.world.swap(s); @@ -1192,6 +1762,13 @@ fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result bool { + s.script_key.as_ref().is_some_and(|k| JOBS.with(|jobs| jobs.borrow_mut().remove(&k.id)).is_some()) +} + /// The objects a run may add before it is asked to continue or stop. fn growth_objects() -> u64 { #[cfg(test)] @@ -1218,4 +1795,10 @@ 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 `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. + pub(crate) static TEST_ELAPSED: Cell> = const { Cell::new(None) }; + /// Tests: runs have no clock, as in a wasm build. + pub(crate) static TEST_NO_CLOCK: Cell = const { Cell::new(false) }; } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 8a1ef8f69..63fbcd5a7 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -44,6 +44,8 @@ impl Session { fill_active: true, clipboard: Clipboard::default(), journal: Journal::default(), + history_budget: crate::default_history_budget(), + histories_seen: None, tool: vectorcraft_tools::create("selection"), lender: None, held_input: Default::default(), @@ -82,7 +84,9 @@ impl Session { pdf_preset_dir: None, dictionary_file: None, profile_dir: None, + color_settings_file: None, plugin_dir: None, + scripts_dir: None, ignored_plugin_grants: Default::default(), plugin_output: None, plugin_install_approval: None, @@ -132,6 +136,8 @@ impl Session { script_key: None, script_stop: vectorcraft_js::Interrupt::new(), script_report: None, + script_console: Default::default(), + script_runs: 0, script_frames: false, script_high_water: Default::default(), script_in_run: false, @@ -294,8 +300,8 @@ impl Session { cmd::compositefonts::filter_loaded(&mut doc); // Inline graphics in text take their size from their symbols' art (not saved). doc.resolve_inline_art(); - // Guides from files saved before guides had layers go on a layer. - doc.adopt_guides(); + // Ruler guides a reader left in the saved list become nodes on their layers. + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); @@ -322,7 +328,7 @@ impl Session { doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); doc.resolve_inline_art(); - doc.adopt_guides(); + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); // In the back, type set in a font named like a composite font is measured when the document // comes to the front and its own composite fonts are registered, not in the front diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 1020e11a5..1762c9676 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -112,7 +112,7 @@ impl Session { }, Err(e) => Err(e), }; - match result { + let r = match result { Ok(v) => { st.selection.prune(&st.doc); // An edit that selects what it unlocks, shows or pastes leaves locked guides out, @@ -153,7 +153,12 @@ impl Session { cmd::globaledit::follow_history(st); Err(e) } + }; + // An edit a frontend makes itself, outside a command, keeps to the shared budget too. + if r.is_ok() && self.depth == 0 && !self.in_tool_actions { + self.trim_histories(); } + r } /// [`Session::edit`] with the step named `template`, its `{name}` placeholder filled with `name` @@ -394,6 +399,9 @@ impl Session { let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); self.journal.push(preview); self.journal_in_undo_groups(doc, &grouped, journal); + if self.depth == 0 { + self.trim_histories(); + } Ok(()) } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 7dd5d8ae6..abe9121ef 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -99,6 +99,13 @@ impl Session { // the artboards the journal recorded need only a document too, whatever is selected and // whichever tool is chosen. let given = given_ids(spec.params, params); + // The guide commands take ruler guides by index; an object command given one's id is refused. + if let (Some(ids), Some(st)) = (&given, self.active()) + && let Some(guide) = ids.iter().find(|i| st.doc.node(**i).is_some_and(|n| n.is_ruler_guide())) + { + let value = guide.0; + return Err(cmd::bad(id, format!("id {value} is a ruler guide, not an object"))); + } let enabled = if (id == "edit.clear" && params.get("ids").is_some()) || (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some()) { @@ -200,6 +207,7 @@ impl Session { } if self.depth == 0 { self.journal_in_undo_groups(doc, &grouped, journal); + self.trim_histories(); self.after_command(Some(id)); } else if matches!(id, "edit.undo" | "edit.redo") { // A step of `command.batch`: the tool hears of it as it does of the command alone. diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index 58fd90579..d06934926 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -160,6 +160,10 @@ impl Session { *n -= below(*n); } } + // The shared budget, which waits while an undo group is open, applies again once none is. + if self.depth == 0 { + self.trim_histories(); + } } /// When journal entry `n` is a `command.batch` with `steps` noted in canceled undo groups and diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index ae98bdf4f..01ebc4652 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -11,6 +11,9 @@ //! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files //! named after the profile, never the path they came from (a profile in a system folder may //! move or go away). Attaching the folder registers them again. +//! * **Color Settings**: the working spaces, intent and black point compensation +//! `edit.colorSettings` sets, as one JSON file (#6335). Attaching it, after the profiles, makes +//! them the active settings again. //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. //! Attaching the folder installs them again, except in Safe Mode and except the ones the user @@ -132,11 +135,18 @@ fn too_large(max: u64) -> String { format!("larger than {max} bytes") } +/// Whether `old` holds exactly `bytes`. It is read no further than one byte past their length, so a +/// large file is not read into memory. +pub(crate) fn holds(old: impl Read, bytes: &[u8]) -> bool { + let mut read = vec![]; + old.take(bytes.len() as u64 + 1).read_to_end(&mut read).is_ok() && read == bytes +} + /// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them /// already. A failure is logged: remembering is best effort and never fails the command. fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool { let path = dir.join(file); - if std::fs::read(&path).is_ok_and(|old| old == bytes) { + if std::fs::File::open(&path).is_ok_and(|old| holds(old, bytes)) { return true; } let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes)); @@ -293,6 +303,31 @@ impl Session { } } + /// Keep Edit › Color Settings (the working spaces, intent and black point compensation) in the + /// file `path`, and make the settings saved there the active ones (#6335). After + /// [`Session::attach_profile_store`]: they may name a profile it registers. Settings that can't be + /// read, or that name a profile that is gone, leave the defaults with a log record, and the file + /// as it is until the settings change. + pub fn attach_color_settings(&mut self, path: PathBuf) { + if path.exists() { + let restored = read_capped(&path, MAX_PRESET_FILE) + .and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| e.to_string())) + .and_then(|settings| vectorcraft_color::cms::set_active(&settings).map_err(|e| e.to_string())); + if let Err(e) = restored { + log::warn!("not using the saved color settings {}: {e}", path.display()); + } + } + self.color_settings_file = Some(path); + } + + /// Write `settings` to the file [`Session::attach_color_settings`] named; `false` when they + /// didn't go there. + pub(crate) fn store_color_settings(&self, settings: &vectorcraft_color::cms::ColorSettings) -> bool { + let Some(path) = &self.color_settings_file else { return false }; + let (Some(dir), Some(file)) = (path.parent(), path.file_name().and_then(|f| f.to_str())) else { return false }; + serde_json::to_vec_pretty(settings).is_ok_and(|bytes| put(dir, file, &bytes)) + } + /// Keep the plug-ins this session installs in `dir`, take the disabled plug-ins saved there /// ([`DISABLED_FILE`]), and install the stored ones ([`Session::load_stored_plugins`]). pub fn attach_plugin_store(&mut self, dir: PathBuf) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f6e32d719..ca0501c8f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -47,6 +47,7 @@ mod docsetup; mod document_move; mod draw2; mod dynamic_symbols; +mod dynamic_text; mod editcolors; mod effectedit; mod emptytype; @@ -130,6 +131,7 @@ mod place; mod placed_document; mod plugins; mod prefs; +mod prefs_file; mod previewbounds; mod print; mod printadvanced; @@ -149,6 +151,12 @@ mod repeat_edit; mod save; mod saveoptions; mod scalestrokes; +mod script_console; +mod script_dialogs; +mod script_dollar; +mod script_folder; +mod script_include; +mod script_io; mod script_run; mod scripts; mod select_ids_first; @@ -186,6 +194,7 @@ mod toolsettings; mod transformfx; mod transparencygrid; mod trap; +mod turn_direction; mod typearea; mod typeglyphbounds; mod typescale; @@ -209,6 +218,18 @@ fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { NodeId(r["id"].as_u64().unwrap()) } +/// The active document's selected ruler guides, by their `guide.list` indexes. +pub(crate) fn selected_guides(s: &Session) -> Vec { + let st = s.doc().unwrap(); + crate::cmd::guide_indexes(&st.doc, &st.selection.guides) +} + +/// The active document's key guide, by its `guide.list` index. +pub(crate) fn key_guide(s: &Session) -> Option { + let st = s.doc().unwrap(); + st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) +} + #[test] fn create_and_undo_redo() { let mut s = session(); @@ -3243,7 +3264,7 @@ fn reshape_falloff_follows_arc_length_on_open_paths() { .as_u64() .unwrap(), ); - s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "radius": 250.0})).unwrap(); let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect(); assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}"); assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}"); @@ -3251,6 +3272,604 @@ fn reshape_falloff_follows_arc_length_on_open_paths() { assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}"); } +/// An open path for the Reshape tests. +fn reshape_open_path(s: &mut Session, anchors: Value) -> NodeId { + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap()) +} + +/// The anchors of the path's first subpath. +fn reshape_anchors(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.clone() +} + +/// The share of the drag an anchor takes at `u` of the way from an end to the grabbed point. +fn reshape_share(u: f64) -> f64 { + u * u * (3.0 - 2.0 * u) +} + +/// Six anchors 100 apart along a zigzag 300 wide and 80 high. +fn reshape_zigzag() -> Value { + json!([{"x": 0, "y": 0}, {"x": 60, "y": 80}, {"x": 120, "y": 0}, {"x": 180, "y": 80}, {"x": 240, "y": 0}, {"x": 300, "y": 80}]) +} + +#[test] +fn reshape_of_an_end_stretches_an_open_path_to_its_other_end() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + let before = reshape_anchors(&s, id); + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 20.0, "dy": 50.0})).unwrap(); + let after = reshape_anchors(&s, id); + assert_eq!(after.len(), 6); + let moved: Vec = after.iter().zip(&before).map(|(a, b)| a.p - b.p).collect(); + assert_eq!(moved[0], vectorcraft_geom::Vec2::ZERO, "the other end stays in place: {moved:?}"); + for (i, m) in moved.iter().enumerate() { + let share = reshape_share(i as f64 / 5.0); + assert!((m.x - 20.0 * share).abs() < 1e-9 && (m.y - 50.0 * share).abs() < 1e-9, "anchor {i} takes {share} of the drag: {moved:?}"); + } + assert!(moved.windows(2).all(|w| w[0].y < w[1].y), "each anchor moves less than the one after it: {moved:?}"); + // The path has no handles, and gets none. + assert!(after.iter().all(|a| a.h_in == a.p && a.h_out == a.p), "{after:?}"); +} + +#[test] +fn reshape_of_a_middle_anchor_leaves_both_ends_of_an_open_path_in_place() { + let mut s = session(); + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 30, "y": 0}, {"x": 200, "y": 0}, {"x": 230, "y": 0}, {"x": 400, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!(y.len(), 5, "the grab was on an anchor, so none is added"); + assert_eq!((y[0], y[2], y[4]), (0.0, 100.0, 0.0), "{y:?}"); + // Each side is measured from its own end: 30 of 200 on the left, 170 of 200 on the right. + assert!((y[1] - 100.0 * reshape_share(0.15)).abs() < 1e-9, "{y:?}"); + assert!((y[3] - 100.0 * reshape_share(0.85)).abs() < 1e-9, "{y:?}"); +} + +#[test] +fn reshape_keeps_the_handles_of_a_smooth_anchor_in_line() { + let mut s = session(); + let id = reshape_open_path( + &mut s, + json!([ + {"x": 0, "y": 0, "out": [40, 0]}, + {"x": 100, "y": 0, "in": [60, 0], "out": [140, 0], "smooth": true}, + {"x": 200, "y": 0, "in": [160, 0]} + ]), + ); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let a = reshape_anchors(&s, id); + // The control polygon is 200 long and the middle anchor is halfway: it takes half the drag, + // and its handles, 40 either side, take 0.2 and 0.8 of it. + assert!((a[1].p.y - 50.0).abs() < 1e-9 && (a[1].h_in.y - 20.0).abs() < 1e-9 && (a[1].h_out.y - 80.0).abs() < 1e-9, "{a:?}"); + let (to_in, to_out) = (a[1].h_in - a[1].p, a[1].h_out - a[1].p); + assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "the handles stay opposite each other: {a:?}"); + // The end that stays keeps its handle, and the grabbed end's handle moves with it. + assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(40.0, 0.0))); + assert_eq!((a[2].p, a[2].h_in), (Point::new(200.0, 100.0), Point::new(160.0, 100.0))); +} + +/// A `radius` still bounds the reach on an open path, as it did before the path stretched to its ends. +#[test] +fn reshape_with_a_radius_moves_only_the_anchors_within_it() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 50.0, "radius": 150.0})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!(&y[..4], &[0.0, 80.0, 0.0, 80.0], "anchors 200 or more along the path stay: {y:?}"); + assert!(y[4] > 0.0 && y[4] < 50.0 && y[5] == 130.0, "{y:?}"); +} + +#[test] +fn reshape_measures_an_open_path_along_its_control_polygon() { + let mut s = session(); + // The first segment's control polygon is 60 + 100 + 60 long, its chord 100 and its arc 150 to + // 160; the second segment is a line 100 long. + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0, "out": [0, 60]}, {"x": 100, "y": 0, "in": [100, 60]}, {"x": 200, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let a = reshape_anchors(&s, id); + let u: f64 = 220.0 / 320.0; + assert!((a[1].p.y - 100.0 * reshape_share(u)).abs() < 1e-9, "{a:?}"); + // The in handle lies 160 along the polygon, and takes the share on the tangent line at its anchor. + let handle = reshape_share(u) + 6.0 * u * (1.0 - u) * (160.0 / 320.0 - u); + assert!((a[1].h_in.y - 60.0 - 100.0 * handle).abs() < 1e-9, "{a:?}"); + // The end that stays keeps its handle. + assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(0.0, 60.0))); +} + +#[test] +fn reshape_of_a_point_on_a_segment_stretches_an_open_path_from_the_new_anchor() { + let mut s = session(); + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 250.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let p: Vec = reshape_anchors(&s, id).iter().map(|a| a.p).collect(); + assert_eq!(p.len(), 5, "the grab adds an anchor: {p:?}"); + assert_eq!((p[0], p[3], p[4]), (Point::new(0.0, 0.0), Point::new(250.0, 100.0), Point::new(300.0, 0.0)), "{p:?}"); + assert!((p[1].y - 100.0 * reshape_share(0.4)).abs() < 1e-9 && (p[2].y - 100.0 * reshape_share(0.8)).abs() < 1e-9, "{p:?}"); +} + +#[test] +fn reshape_of_an_end_of_a_two_anchor_path_moves_only_that_end() { + let mut s = session(); + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0, "out": [30, 0]}, {"x": 100, "y": 0, "in": [70, 0]}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 100.0, "y": 0.0, "dx": 10.0, "dy": 50.0})).unwrap(); + let a = reshape_anchors(&s, id); + assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(30.0, 0.0))); + assert_eq!((a[1].p, a[1].h_in), (Point::new(110.0, 50.0), Point::new(80.0, 50.0))); +} + +#[test] +fn reshape_keeps_smooth_anchors_on_both_sides_of_the_grabbed_one_in_line() { + let mut s = session(); + // Smooth anchors with handles of unequal length, on a curve, before and after the grabbed anchor. + let id = reshape_open_path( + &mut s, + json!([ + {"x": 0, "y": 0}, + {"x": 100, "y": 50, "in": [80, 40], "out": [160, 80], "smooth": true}, + {"x": 250, "y": 0, "in": [220, 0], "out": [290, 0], "smooth": true}, + {"x": 400, "y": 60, "in": [340, 90], "out": [420, 50], "smooth": true}, + {"x": 500, "y": 0} + ]), + ); + let before = reshape_anchors(&s, id); + s.execute("path.reshape", &json!({"id": id.0, "x": 250.0, "y": 0.0, "dx": 35.0, "dy": -80.0})).unwrap(); + let a = reshape_anchors(&s, id); + let drag = vectorcraft_geom::Vec2::new(35.0, -80.0); + assert_eq!((a[2].p - before[2].p, a[2].h_in - before[2].h_in, a[2].h_out - before[2].h_out), (drag, drag, drag)); + for i in [1, 3] { + let (to_in, to_out) = (a[i].h_in - a[i].p, a[i].h_out - a[i].p); + assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "anchor {i}'s handles stay opposite each other: {a:?}"); + let moved = a[i].p - before[i].p; + assert!( + moved.cross(drag).abs() < 1e-9 && moved.hypot() > 1.0 && moved.hypot() < drag.hypot() - 1.0, + "anchor {i} takes part of the drag: {a:?}" + ); + // The handle on the grabbed anchor's side moves farther than its anchor, and the other one less. + let (h_in, h_out) = ((a[i].h_in - before[i].h_in).hypot(), (a[i].h_out - before[i].h_out).hypot()); + let (nearer, farther) = if i == 1 { (h_out, h_in) } else { (h_in, h_out) }; + assert!(nearer > moved.hypot() + 1.0 && farther < moved.hypot() - 1.0, "anchor {i}: {a:?}"); + } + assert_eq!((a[0].p, a[4].p), (before[0].p, before[4].p)); +} + +#[test] +fn reshape_moves_a_long_handle_against_the_drag_to_keep_its_anchor_smooth() { + let mut s = session(); + let id = + reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 100, "y": 0, "in": [10, 0], "out": [190, 0], "smooth": true}, {"x": 400, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 400.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let a = reshape_anchors(&s, id); + // The anchor is a quarter of the way along and takes 0.15625 of the drag. The tangent line + // there has slope 1.125, and each handle lies 0.225 of the way from the anchor: the in handle + // takes 0.15625 - 0.253125, a share below 0, and the out handle 0.15625 + 0.253125. + assert!((a[1].p.y - 15.625).abs() < 1e-9 && (a[1].h_in.y + 9.6875).abs() < 1e-9 && (a[1].h_out.y - 40.9375).abs() < 1e-9, "{a:?}"); + let (to_in, to_out) = (a[1].h_in - a[1].p, a[1].h_out - a[1].p); + assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "the handles stay opposite each other: {a:?}"); +} + +#[test] +fn reshape_refuses_a_negative_radius() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + let before = reshape_anchors(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + let e = s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 50.0, "radius": -50.0})).unwrap_err(); + assert!(e.to_string().contains("`radius` must be 0 or more"), "{e}"); + assert_eq!(reshape_anchors(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +#[test] +fn reshape_by_no_distance_on_an_anchor_records_no_undo_step() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + let before = reshape_anchors(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + for params in [json!({"id": id.0, "x": 300.0, "y": 80.0}), json!({"id": id.0, "x": 120.0, "y": 0.0, "dx": 0.0, "dy": 0.0, "radius": 40.0})] { + s.execute("path.reshape", ¶ms).unwrap(); + } + assert_eq!(reshape_anchors(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // A drag that moves the anchor is still one step. + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 1.0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); +} + +/// Seven anchors 70 apart along a line, from x 0 to x 420. +fn reshape_line_of_seven() -> Value { + json!([{"x": 0, "y": 0}, {"x": 70, "y": 0}, {"x": 140, "y": 0}, {"x": 210, "y": 0}, {"x": 280, "y": 0}, {"x": 350, "y": 0}, {"x": 420, "y": 0}]) +} + +#[test] +fn reshape_with_two_focal_points_moves_the_anchors_between_them_whole() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + s.execute("path.reshape", &json!({"id": id.0, "x": 280.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "focal": [[0, 2]]})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!(&y[2..5], &[100.0, 100.0, 100.0], "both focal points and the anchor between them: {y:?}"); + assert_eq!((y[0], y[6]), (0.0, 0.0), "the ends stay: {y:?}"); + // Each side stretches from its own end to its nearer focal point, 140 along the path. + assert!((y[1] - 100.0 * reshape_share(0.5)).abs() < 1e-9 && (y[5] - 100.0 * reshape_share(0.5)).abs() < 1e-9, "{y:?}"); +} + +#[test] +fn reshape_of_a_closed_path_selected_whole_moves_all_of_it() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let before = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone(); + s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0, "focal": []})).unwrap(); + let after = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone(); + assert_eq!(after.anchor_count(), before.anchor_count(), "a grab on an anchor adds none"); + let drag = vectorcraft_geom::Vec2::new(15.0, -40.0); + for ((_, _, a), (_, _, b)) in after.anchors().zip(before.anchors()) { + assert_eq!((a.p - b.p, a.h_in - b.h_in, a.h_out - b.h_out), (drag, drag, drag), "{after:?}"); + } +} + +#[test] +fn reshape_leaves_the_anchors_that_are_not_selected_in_place() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "focal": []})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!((y[0], y[1], y[5], y[6]), (0.0, 0.0, 100.0, 0.0), "{y:?}"); + // The stretch runs from the last anchor that is not selected, 280 from the focal point. + for i in 2..5 { + let u = (i as f64 - 1.0) / 4.0; + assert!((y[i] - 100.0 * reshape_share(u)).abs() < 1e-9, "anchor {i} takes {u} of the way: {y:?}"); + } +} + +/// A step with neither `focal` nor `radius`, as one recorded before focal points, plays as it did +/// then: the selection does not matter, and a closed path follows with the default falloff. +#[test] +fn reshape_without_focal_or_radius_keeps_the_earlier_rule() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!((y[0], y[5], y[6]), (0.0, 100.0, 0.0), "{y:?}"); + // Anchor 1 is not selected and still takes its share, measured from the end 350 away. + for i in 1..5 { + let u = i as f64 / 5.0; + assert!((y[i] - 100.0 * reshape_share(u)).abs() < 1e-9, "anchor {i} takes {u} of the way: {y:?}"); + } + // `focal: null` is the same as leaving it out. + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "focal": null})).unwrap(); + let pts: Vec = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect(); + assert!(pts.contains(&Point::new(0.0, -40.0)), "{pts:?}"); + assert!(pts.contains(&Point::new(100.0, 100.0)), "the far corner stays: {pts:?}"); +} + +#[test] +fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_stay() { + let mut s = session(); + let id = NodeId( + s.execute( + "path.create", + &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 200, "y": 100}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + // Anchors 5, 0, 1 and 2 are selected; 3 and 4 stay. The walk runs 4, 5, 0, 1, 2, 3. + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 5], [0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0, "focal": []})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!((y[0], y[3], y[4]), (-50.0, 100.0, 100.0), "{y:?}"); + // From anchor 4 to the focal point is 200: anchor 5 is halfway. From anchor 3 back to it is + // 300: anchor 2 is a third of the way and anchor 1 two thirds. + let want = [(5, 100.0 + -50.0 * reshape_share(0.5)), (2, -50.0 * reshape_share(1.0 / 3.0)), (1, -50.0 * reshape_share(2.0 / 3.0))]; + for (i, w) in want { + assert!((y[i] - w).abs() < 1e-9, "anchor {i}: {y:?}"); + } +} + +#[test] +fn reshape_of_a_segment_keeps_the_selected_anchors_and_selects_the_new_one() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 245.0, "y": 0.0, "dx": 0.0, "dy": 60.0, "focal": [[0, 5]]})).unwrap(); + let a = reshape_anchors(&s, id); + assert_eq!(a.len(), 8, "the grab adds an anchor between 210 and 280"); + let selected: Vec<(usize, usize)> = s.doc().unwrap().selection.partial(id).unwrap().iter().copied().collect(); + assert_eq!(selected, vec![(0, 3), (0, 4), (0, 5), (0, 6)], "the same points stay selected, with the new one"); + // The focal point given before the anchor was added still names the anchor at 350. + let y: Vec = a.iter().map(|a| a.p.y).collect(); + assert_eq!(&y[4..7], &[60.0, 60.0, 60.0], "{y:?}"); + assert_eq!((y[0], y[1], y[2], y[7]), (0.0, 0.0, 0.0, 0.0), "{y:?}"); +} + +#[test] +fn reshape_of_a_segment_with_a_selected_anchor_index_past_the_end_does_not_panic() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + let past = usize::MAX; + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, past]], "mode": "set"})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 175.0, "y": 0.0, "dx": 0.0, "dy": 10.0})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 105.0, "y": 0.0, "dx": 0.0, "dy": 10.0})).unwrap(); + assert_eq!(reshape_anchors(&s, id).len(), 9); +} + +#[test] +fn reshape_of_a_segment_keeps_a_selected_segment_as_its_two_halves() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 2], [0, 4]], "mode": "set"})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 175.0, "y": 0.0, "dx": 0.0, "dy": 0.0})).unwrap(); + let st = s.doc().unwrap(); + let segments: Vec<(usize, usize)> = st.selection.segments[&id].iter().copied().collect(); + assert_eq!(segments, vec![(0, 2), (0, 3), (0, 5)]); + let anchors: Vec<(usize, usize)> = st.selection.partial(id).unwrap().iter().copied().collect(); + assert_eq!(anchors, vec![(0, 2), (0, 3), (0, 4), (0, 5), (0, 6)], "the new anchor moves with the segment's ends"); +} + +#[test] +fn reshape_moves_only_the_subpaths_with_a_focal_point() { + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"})).unwrap()["id"].as_u64().unwrap()); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0, "focal": []})).unwrap(); + let pd = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone(); + let y: Vec> = pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| a.p.y).collect()).collect(); + assert_eq!(y[1], vec![100.0, 100.0, 100.0], "{y:?}"); + assert_eq!((y[0][0], y[0][2]), (0.0, 40.0), "{y:?}"); + // A focal point on the other subpath stretches that one too. + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 40.0, "dx": 0.0, "dy": 10.0, "focal": [[1, 1]]})).unwrap(); + let pd = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone(); + assert_eq!(pd.subpaths[1].anchors.iter().map(|a| a.p.y).collect::>(), vec![100.0, 110.0, 100.0]); +} + +#[test] +fn reshape_refuses_a_focal_point_that_is_not_an_anchor() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + let before = reshape_anchors(&s, id); + for (focal, want) in [ + (json!([[0, 7]]), "`focal` names [0, 7], which is not an anchor of the path"), + (json!([[1, 0]]), "`focal` names [1, 0], which is not an anchor of the path"), + (json!([[0, -1]]), "invalid anchor index -1"), + (json!({"0": 1}), "`focal` must be an array of [subpath, anchor] pairs"), + ] { + let e = s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": focal})).unwrap_err(); + assert!(e.to_string().contains(want), "{e}"); + } + assert_eq!(reshape_anchors(&s, id), before); + let steps = s.doc().unwrap().history.undo.len(); + // An empty `focal` chooses the focal-point rule too, so it cannot go with `radius` either. + for focal in [json!([[0, 2]]), json!([])] { + let e = + s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": focal, "radius": 50.0})).unwrap_err(); + assert!(e.to_string().contains("`focal` and `radius` cannot be used together"), "{focal}: {e}"); + } + assert_eq!(reshape_anchors(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +/// The line of seven, selected whole, with the Reshape tool picked. +fn reshape_tool_on_a_line(s: &mut Session) -> NodeId { + let id = reshape_open_path(s, reshape_line_of_seven()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("reshape", ViewInfo::default()).unwrap(); + id +} + +/// Presses at `a`, drags to `b` unless it is `a` (a click), and releases, with Shift when `shift`. +fn reshape_gesture(s: &mut Session, a: (f64, f64), b: (f64, f64), shift: bool) { + let v = ViewInfo::default(); + let mods = vectorcraft_tools::Mods { shift, ..vectorcraft_tools::Mods::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, a.0, a.1).with_mods(mods), v).unwrap(); + if a != b { + s.pointer(&PointerEvent::new(PointerKind::Drag, b.0, b.1).with_mods(mods), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, b.0, b.1).with_mods(mods), v).unwrap(); +} + +/// Where the Reshape tool draws its highlighted focal points. +fn reshape_highlights(s: &mut Session) -> Vec { + s.overlays(ViewInfo::default()) + .into_iter() + .filter_map(|o| match o { + vectorcraft_tools::Overlay::Anchor { p, filled: true, .. } => Some(p), + _ => None, + }) + .collect() +} + +#[test] +fn reshape_tool_shift_click_adds_focal_points_that_a_drag_moves_together() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + let steps = s.doc().unwrap().history.undo.len(); + reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false); + reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), true); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 0.0), Point::new(280.0, 0.0)]); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click on an anchor is not an edit"); + reshape_gesture(&mut s, (280.0, 0.0), (280.0, 100.0), false); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!(&y[2..5], &[100.0, 100.0, 100.0], "{y:?}"); + assert!((y[1] - 50.0).abs() < 1e-9 && (y[5] - 50.0).abs() < 1e-9 && y[0] == 0.0 && y[6] == 0.0, "{y:?}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "the drag is one step"); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 100.0), Point::new(280.0, 100.0)], "the focal points stay highlighted"); +} + +#[test] +fn reshape_tool_shift_click_on_a_focal_point_removes_it_and_a_click_starts_again() { + for (clicks, want) in [ + // Shift-click 140 again: only 280 is a focal point. + (vec![(140.0, false), (280.0, true), (140.0, true)], vec![Point::new(280.0, 0.0)]), + // A click without Shift: only 350 is a focal point. + (vec![(140.0, false), (280.0, true), (350.0, false)], vec![Point::new(350.0, 0.0)]), + ] { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + for (x, shift) in clicks { + reshape_gesture(&mut s, (x, 0.0), (x, 0.0), shift); + } + assert_eq!(reshape_highlights(&mut s), want); + let at = (want[0].x, 0.0); + reshape_gesture(&mut s, at, (at.0, 100.0), false); + let full = reshape_anchors(&s, id).iter().filter(|a| a.p.y == 100.0).count(); + assert_eq!(full, 1, "only the dragged focal point moves whole: {:?}", reshape_anchors(&s, id)); + } +} + +#[test] +fn reshape_tool_focal_point_stays_its_anchor_when_another_anchor_moves_onto_it() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), false); + // Anchor 2 now lies where the focal point, anchor 4, lies. + s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 2, "x": 280.0, "y": 0.0})).unwrap(); + // A Shift-click adds the last anchor, and a drag from it moves it and anchor 4 whole. + reshape_gesture(&mut s, (420.0, 0.0), (420.0, 0.0), true); + reshape_gesture(&mut s, (420.0, 0.0), (420.0, 100.0), false); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert!(y[4] == 100.0 && y[6] == 100.0 && y[2] < 100.0, "{y:?}"); +} + +#[test] +fn reshape_tool_anchor_added_before_a_focal_point_keeps_it_highlighted() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), false); + // A Shift-click on the segment from 70 to 140 adds anchor 2, before the focal point. + reshape_gesture(&mut s, (105.0, 0.0), (105.0, 0.0), true); + assert_eq!(reshape_anchors(&s, id).len(), 8); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(280.0, 0.0), Point::new(105.0, 0.0)]); + // A drag from the added anchor moves both focal points whole. + reshape_gesture(&mut s, (105.0, 0.0), (105.0, 40.0), false); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert!(y[2] == 40.0 && y[5] == 40.0, "{y:?}"); +} + +#[test] +fn reshape_tool_focal_point_moved_by_another_edit_is_not_passed_to_another_anchor() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), false); + // The focal point, anchor 4, moves away, and anchor 2 takes its old place. + s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 4, "x": 280.0, "y": 50.0})).unwrap(); + s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 2, "x": 280.0, "y": 0.0})).unwrap(); + assert_eq!(reshape_highlights(&mut s), vec![]); + // A Shift-click adds the last anchor, and a drag from it moves it alone as the focal point. + reshape_gesture(&mut s, (420.0, 0.0), (420.0, 0.0), true); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(420.0, 0.0)]); + reshape_gesture(&mut s, (420.0, 0.0), (420.0, 100.0), false); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert!(y[6] == 100.0 && y[2] < 100.0, "{y:?}"); +} + +#[test] +fn reshape_tool_drag_from_an_anchor_that_is_not_highlighted_moves_only_it_whole() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false); + reshape_gesture(&mut s, (350.0, 0.0), (350.0, 100.0), false); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert!(y[2] < 100.0 && y[5] == 100.0, "{y:?}"); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(350.0, 100.0)]); +} + +#[test] +fn reshape_tool_click_on_a_segment_adds_a_highlighted_anchor() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + let steps = s.doc().unwrap().history.undo.len(); + reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false); + reshape_gesture(&mut s, (245.0, 0.0), (245.0, 0.0), true); + assert_eq!(reshape_anchors(&s, id).len(), 8); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "adding the anchor is one step"); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 0.0), Point::new(245.0, 0.0)]); + reshape_gesture(&mut s, (245.0, 0.0), (245.0, 60.0), false); + let a = reshape_anchors(&s, id); + assert_eq!((a[2].p.y, a[3].p.y, a[4].p.y), (60.0, 60.0, 60.0), "{a:?}"); + // Undo takes back the drag, then the added anchor. The highlights stay where the drag left + // them, where no anchor is now, so none shows. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(reshape_anchors(&s, id).len(), 7); + assert!(reshape_highlights(&mut s).is_empty()); +} + +#[test] +fn reshape_tool_escape_during_a_drag_keeps_the_path_and_its_focal_points() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + let before = reshape_anchors(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false); + reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), true); + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), v).unwrap(); + assert_ne!(reshape_anchors(&s, id), before, "the preview moves the path"); + s.tool_key(ToolKey::Escape, vectorcraft_tools::Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), v).unwrap(); + assert_eq!(reshape_anchors(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 0.0), Point::new(280.0, 0.0)]); +} + +#[test] +fn reshape_tool_shift_drag_from_an_anchor_that_is_not_highlighted_moves_only_it() { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false); + // Shift keeps the drag's angle; the highlighted anchor at 140 does not move whole with it. + reshape_gesture(&mut s, (350.0, 0.0), (350.0, 100.0), true); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert!(y[5] == 100.0 && y[2] < 100.0, "{y:?}"); + assert_eq!(reshape_highlights(&mut s), vec![Point::new(350.0, 100.0)]); +} + +#[test] +fn reshape_tool_click_on_a_curved_segment_highlights_the_anchor_it_adds() { + // Points on the curve from 0 to 300, away from its anchors, each clicked from off it on a + // new copy of the path. + let c = vectorcraft_geom::CubicBez::new(Point::new(0.0, 0.0), Point::new(40.0, -90.0), Point::new(230.0, 110.0), Point::new(300.0, 0.0)); + for k in 1..60 { + let t = 0.05 + 0.015 * f64::from(k); + for off in [(0.3, -0.2), (-2.1, 1.7), (2.6, 2.2)] { + let mut s = session(); + let id = NodeId( + s.execute( + "path.create", + &json!({"anchors": [{"x": 0, "y": 0, "out": [40, -90]}, {"x": 300, "y": 0, "in": [230, 110]}, {"x": 600, "y": 0}]}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("reshape", ViewInfo::default()).unwrap(); + let q = vectorcraft_geom::ParamCurve::eval(&c, t); + reshape_gesture(&mut s, (q.x + off.0, q.y + off.1), (q.x + off.0, q.y + off.1), false); + let a = reshape_anchors(&s, id); + assert_eq!(a.len(), 4, "t {t}, {off:?}"); + assert_eq!(reshape_highlights(&mut s), vec![a[1].p], "t {t}, {off:?}"); + } + } +} + +#[test] +fn reshape_tool_leaves_a_locked_or_hidden_path_alone() { + for command in ["object.lock", "object.hide"] { + let mut s = session(); + let id = reshape_tool_on_a_line(&mut s); + let before = reshape_anchors(&s, id); + s.execute(command, &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).ok(); + reshape_gesture(&mut s, (420.0, 0.0), (420.0, 0.0), false); + reshape_gesture(&mut s, (420.0, 0.0), (420.0, 60.0), false); + assert_eq!(reshape_anchors(&s, id), before, "{command}"); + assert!(reshape_highlights(&mut s).is_empty(), "{command}"); + } +} + #[test] fn graph_tool_drag_creates_a_graph_and_asks_for_data() { let mut s = session(); diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 8d7023727..8d12c8371 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -647,6 +647,28 @@ fn edits_change_the_active_row_of_the_first_selected_object() { assert!(!e.contains("internal error"), "{e}"); } +/// After an aligned Remove Item or Duplicate Item, the active row follows the rows that changed in +/// the first selected object, also when that object paints above the others. +#[test] +fn aligned_remove_and_duplicate_keep_the_active_row_of_the_first_selected_object() { + let mut s = session(); + let (_, b) = reversed_pair(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // b's red fill + // a's row 0 is b's white fill, row 1, so b's red fill stays at row 0. + run(&mut s, "appearance.removeItem", json!({"indices": [0], "aligned": true})); + assert_eq!(node(&s, b).appearance.items.len(), 2); + assert_eq!(s.appearance_item(), Some(0)); + assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#ff0000"); + + let mut s = session(); + let (_, b) = reversed_pair(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's white fill, a's row 0 + // The copy goes above b's white fill, at 2; the active row stays on the original. + run(&mut s, "appearance.duplicateItem", json!({"indices": [0], "aligned": true})); + assert_eq!(node(&s, b).appearance.items.len(), 4); + assert_eq!(s.appearance_item(), Some(1)); +} + /// `effect.apply`'s reply names the new effect by its index in the object whose row `item` is, /// also when the objects painted after it already have effects there. #[test] diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index f5ac96d50..491a47108 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -112,8 +112,31 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Notched Ribbon", "Double Line", "Wave", "Teardrop", "Striped Ribbon"] ); assert_eq!(libs[0]["count"].as_u64(), Some(5)); + let trails = run(&mut s, "brush.library", json!({"library": "game-trails"})); + assert_eq!( + trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), + ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] + ); + assert_eq!(libs[4]["count"].as_u64(), Some(4)); + let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); + assert_eq!( + beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), + ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] + ); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); + let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); + assert_eq!(strs(&added["added"]), ["Laser Bolt"]); // One kind of brush per library, every brush drawing something. - for (id, kind) in [("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter")] { + for (id, kind) in [ + ("art-ribbons", "art"), + ("calligraphic-nibs", "calligraphic"), + ("pattern-borders", "pattern"), + ("scatter-shapes", "scatter"), + ("game-trails", "art"), + ("energy-beams", "art"), + ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); for b in &lib.brushes { diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index 638b7a1f4..b86cf89ee 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1321,6 +1321,106 @@ fn break_link_counts_the_instances_mask_art() { assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }), "it stays an instance"); } +/// A hidden, a locked and a plain instance of one symbol in a group, the group selected. +fn group_with_hidden_and_locked_instances(s: &mut Session) -> (NodeId, String) { + let (shown, name, _) = make_symbol(s); + let hidden = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap()); + let locked = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 100})).unwrap()["id"].as_u64().unwrap()); + select(s, &[shown, hidden, locked]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + doc.node_mut(hidden).unwrap().visible = false; + doc.node_mut(locked).unwrap().locked = true; + select(s, &[group]); + (group, name) +} + +/// The visibility and lock state of the children of `group`, in paint order. +fn shown_and_locked(s: &Session, group: NodeId) -> Vec<(bool, bool)> { + let g = node(s, group); + let NodeKind::Group { children, .. } = &g.kind else { panic!("a group: {}", g.kind_label()) }; + assert!(children.iter().all(|c| !matches!(c.kind, NodeKind::SymbolInstance { .. })), "every instance is expanded"); + children.iter().map(|c| (c.visible, c.locked)).collect() +} + +/// Break Link to Symbol on a selected group keeps a hidden instance's art hidden and a locked +/// instance's art locked, and selects only the art that can be selected. +#[test] +fn break_link_keeps_hidden_and_locked_instances_hidden_and_locked() { + let mut s = session(); + let (group, _) = group_with_hidden_and_locked_instances(&mut s); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 3, "{r}"); + assert_eq!(shown_and_locked(&s, group), vec![(true, false), (false, false), (true, true)]); + let d = &s.doc().unwrap().doc; + let selected: Vec = s.doc().unwrap().selection.objects.to_vec(); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert!(selected.iter().all(|id| d.node(*id).is_some_and(|n| n.visible && !n.locked)), "{selected:?}"); +} + +/// Break Link to Symbol on a selected group does not select the art of a shown, unlocked instance +/// that sits in a hidden or a locked group inside it. +#[test] +fn break_link_leaves_art_in_hidden_or_locked_groups_unselected() { + let mut s = session(); + let (shown, name, _) = make_symbol(&mut s); + let subgroup = |s: &mut Session, y: i64| { + let id = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 300, "y": y})).unwrap()["id"].as_u64().unwrap()); + select(s, &[id]); + NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()) + }; + let hidden = subgroup(&mut s, 200); + let locked = subgroup(&mut s, 100); + select(&mut s, &[shown, hidden, locked]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + doc.node_mut(hidden).unwrap().visible = false; + doc.node_mut(locked).unwrap().locked = true; + select(&mut s, &[group]); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 3, "{r}"); + let d = &s.doc().unwrap().doc; + let selected: Vec = s.doc().unwrap().selection.objects.to_vec(); + assert_eq!(selected.len(), 1, "{selected:?}"); + let a = d.ancestry(selected[0]).unwrap(); + assert!(!a.contains(&hidden) && !a.contains(&locked), "{a:?}"); +} + +/// Break Link to Symbol on an instance just placed on a locked artboard selects the art that +/// replaces it, which stays new art there. The art of an older instance there, named by id, is not +/// selected. +#[test] +fn break_link_keeps_new_art_on_a_locked_artboard_selected() { + let mut s = session(); + let (old, name, _) = make_symbol(&mut s); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + select(&mut s, &[old]); + s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap(); + assert!(s.doc().unwrap().selection.objects.is_empty(), "moved onto the locked artboard"); + let inst = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 1100, "y": 100})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [inst], "placed art is selected"); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + let art: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(art.len(), 1, "{r}"); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), art, "the art that replaces it"); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), art, "the art stays editable"); + let r = s.execute("symbol.breakLink", &json!({"ids": [old.0]})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the older instance's art"); +} + +/// Delete Symbol, expanding the instances, keeps a hidden instance's art hidden and a locked +/// instance's art locked. +#[test] +fn delete_symbol_keeps_hidden_and_locked_instances_hidden_and_locked() { + let mut s = session(); + let (group, name) = group_with_hidden_and_locked_instances(&mut s); + s.execute("symbol.delete", &json!({"name": name})).unwrap(); + assert_eq!(shown_and_locked(&s, group), vec![(true, false), (false, false), (true, true)]); +} + #[test] fn delete_duplicate_and_replace_symbols() { let mut s = session(); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 158cf21ad..3ed51e3c3 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3584,6 +3584,37 @@ fn a_trace_run_hands_its_trace_to_the_command() { assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}"); } +/// A plug-in run that calls Image Trace and then fails leaves a trace handed in for the command in +/// place, as it leaves the document. +#[test] +fn a_failed_plugin_run_keeps_a_handed_in_trace() { + use vectorcraft_plugins::wat as tpl; + let (id, cmd, args) = ("test.engine.calltrace", "imageTrace.make", r#"{"params":{"ignoreWhite":true}}"#); + let mut s = session(); + s.allow_plugin_grants_from_requests(); + image_doc(&mut s); + let p: Value = serde_json::from_str(args).unwrap(); + let run = cmd::TraceRun::prepare(&s, cmd, &p).unwrap(); + run.run(&|_, _| true).unwrap().hand_in(&mut s); + let grant = json!(["doc.read", "doc.write", "commands"]); + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{grant},"commandAllowlist":["{cmd}"]}}"#); + let imports = format!( + r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32))) + (data (i32.const 0x2000) "{}") (data (i32.const 0x2100) "{}")"#, + tpl::string(cmd), + tpl::string(args) + ); + let first = + format!("(drop (call $vc_call (i32.const 0x2000) (i32.const {}) (i32.const 0x2100) (i32.const {}))) unreachable", cmd.len(), args.len()); + let wasm = wat::parse_str(tpl::with_imports(&manifest, &imports, &first, "{}")).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true})).unwrap(); + assert!(s.execute("plugin.run", &json!({"id": id, "ids": []})).is_err()); + assert!(s.trace_hand_off.is_some(), "the trace is still handed in"); + assert!(s.execute(cmd, &p).is_ok()); + assert!(s.trace_hand_off.is_none(), "taken"); + s.execute("plugin.remove", &json!({"id": id})).unwrap(); +} + #[test] fn image_trace_ignore_background_keeps_the_color_inside_the_art() { // A black field, a light panel on it and a black ring on the panel. diff --git a/crates/engine/src/tests/constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs index 6a8317cfe..a93965eb0 100644 --- a/crates/engine/src/tests/constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -78,7 +78,7 @@ fn constrain_angle_turns_the_arrow_keys() { let g = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap(); s.execute("guide.select", &json!({"indexes": [g]})).unwrap(); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); - let pos = s.doc().unwrap().doc.guides[g as usize].pos; + let pos = s.doc().unwrap().doc.ruler_guides()[g as usize].pos; assert!(close(pos, 60.0), "a vertical guide moves 10 right: {pos}"); } @@ -167,7 +167,7 @@ fn constrain_angle_keeps_guides_selected_with_art_on_the_page_axes() { let h = s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("guide.select", &json!({"indexes": [v, h], "toggle": true})).unwrap(); - let pos = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); let at = centre(&s, id); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); assert_eq!(centre(&s, id) - at, Vec2::new(0.0, -10.0), "the art moves along the turned axis"); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index c0946a465..fcf9b6363 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1058,11 +1058,88 @@ fn scissors_splits_open_path_in_two() { assert!(near(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 100.0))); assert!(near(ps[1].1.subpaths[0].anchors[0].p, Point::new(150.0, 100.0))); assert_eq!(ps[1].1.subpaths[0].anchors[1].p, Point::new(300.0, 100.0)); - assert_eq!(s.doc().unwrap().selection.objects.len(), 2); + // One of the two new end points is selected: a Direct Selection drag from the cut pulls the + // second piece's end away and leaves the first piece as it was. + assert_eq!(s.doc().unwrap().selection.objects, [ps[1].0]); + assert_eq!(selected_anchors(&s, ps[1].0), [(0, 0)]); + drag(&mut s, "directSelection", &[(150.0, 100.0), (150.0, 115.0), (150.0, 130.0)], Mods::default()); + assert_eq!(path(&s, id), ps[0].1); + let moved = anchor_points(&s, ps[1].0); + assert!(near(moved[0], Point::new(150.0, 130.0)) && moved[1] == Point::new(300.0, 100.0), "{moved:?}"); // Clicking an end point is refused without panicking. assert!(s.execute("path.split", &json!({"id": id.0, "subpath": 0, "anchor": 0})).is_err()); } +/// An ellipse cut once with the Scissors tool, at an anchor or along a segment, opens with its two +/// ends on top of each other and one of them selected. A short Direct Selection drag from there, +/// plain or with Shift, moves that end alone with its handles, in one undo step, so the open path +/// becomes one turn that spirals out; every other anchor stays where it was. +#[test] +fn scissors_cut_ellipse_end_drags_alone() { + let mods = [Mods::default(), Mods { shift: true, ..Mods::default() }]; + for at_anchor in [true, false] { + for m in mods { + let mut s = session(); + drag(&mut s, "ellipse", &[(100.0, 100.0), (200.0, 200.0), (300.0, 200.0)], Mods::default()); + let id = paths(&s)[0].0; + let ellipse = path(&s, id); + let cut = if at_anchor { + ellipse.subpaths[0].anchors[0].p + } else { + use kurbo::ParamCurve; + ellipse.subpaths[0].segment(1).eval(0.5) + }; + click(&mut s, "scissors", cut.x, cut.y); + let opened = path(&s, id); + let sp = &opened.subpaths[0]; + assert!(!sp.closed && near(sp.anchors[0].p, cut) && near(sp.anchors[sp.anchors.len() - 1].p, cut), "{sp:?}"); + assert_eq!(s.doc().unwrap().selection.objects, [id]); + assert_eq!(selected_anchors(&s, id), [(0, 0)]); + // Shift is pressed once the drag is under way: held at the press it toggles the anchor. + let v = view(); + s.select_tool("directSelection", v).unwrap(); + let undo = undo_steps(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, cut.x, cut.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 4.0, cut.y + 4.0).with_mods(m), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap(); + assert_eq!(undo_steps(&s), undo + 1); + let after = path(&s, id); + let (old, new) = (&sp.anchors, &after.subpaths[0].anchors); + assert_eq!(new.len(), old.len()); + assert_eq!(new[1..], old[1..], "only the dragged end moves (at anchor: {at_anchor}, {m:?})"); + let d = Point::new(cut.x + 8.0, cut.y + 8.0) - cut; + assert!(near(new[0].p, old[0].p + d) && near(new[0].h_out, old[0].h_out + d) && near(new[0].h_in, new[0].p), "{:?}", new[0]); + } + } +} + +/// The Scissors tool on a path of several subpaths: the cut end stays selected in the subpath it +/// opened, and a second cut of that subpath selects the end of the new piece. +#[test] +fn scissors_cut_in_a_compound_path_selects_the_cut_end() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let square = + |x: f64| json!({"closed": true, "anchors": [{"x": x, "y": 100}, {"x": x + 100.0, "y": 100}, {"x": x + 100.0, "y": 200}, {"x": x, "y": 200}]}); + s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [square(100.0), square(300.0)]})).unwrap(); + click(&mut s, "scissors", 400.0, 100.0); + let pd = path(&s, id); + assert!(pd.subpaths[0].closed && !pd.subpaths[1].closed && pd.subpaths[1].anchors[0].p == Point::new(400.0, 100.0), "{pd:?}"); + assert_eq!(selected_anchors(&s, id), [(1, 0)]); + // Cut again at the opened subpath's corner (400, 200): its second piece follows it. + click(&mut s, "scissors", 400.0, 200.0); + let pd = path(&s, id); + assert_eq!(pd.subpaths.len(), 3); + assert_eq!(pd.subpaths[2].anchors[0].p, Point::new(400.0, 200.0)); + assert_eq!(selected_anchors(&s, id), [(2, 0)]); + drag(&mut s, "directSelection", &[(400.0, 200.0), (410.0, 210.0), (420.0, 220.0)], Mods::default()); + let after = path(&s, id); + assert_eq!(after.subpaths[2].anchors[0].p, Point::new(420.0, 220.0)); + assert_eq!(after.subpaths[1], pd.subpaths[1]); + assert_eq!(after.subpaths[0], pd.subpaths[0]); +} + #[test] fn knife_cuts_rect_into_two_pieces() { let mut s = session(); @@ -2303,6 +2380,82 @@ fn add_anchor_point_selects_the_path_it_was_added_to() { assert!(selected_anchors(&s, kept).is_empty(), "a path selected as a whole stays so"); } +fn path_from(s: &mut Session, d: &str) -> NodeId { + NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap()) +} + +fn selected_segments(s: &Session, id: NodeId) -> Vec<(usize, usize)> { + s.doc().unwrap().selection.segments.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default() +} + +fn selected_points(s: &Session, id: NodeId) -> Vec { + let pd = path(s, id); + selected_anchors(s, id).iter().filter_map(|&(si, ai)| pd.subpaths.get(si)?.anchors.get(ai)).map(|a| a.p).collect() +} + +#[test] +fn add_anchor_point_keeps_the_same_anchors_selected() { + // The direct-selected anchors after the one added are numbered one higher, so the selection + // stays on the same points; one undo step restores the path and the selection. + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 5]], "mode": "set"})).unwrap(); + let before = selected_points(&s, id); + let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap(); + assert_eq!(r["anchor"], 3); + assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 6)], "the anchor at 350 is now number 6"); + assert_eq!(selected_points(&s, id), before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7); + assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 5)]); +} + +#[test] +fn add_anchor_point_on_a_selected_segment_keeps_both_halves_and_the_new_anchor_selected() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0"); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 2], [0, 4]], "mode": "set"})).unwrap(); + s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap(); + assert_eq!(selected_segments(&s, id), [(0, 2), (0, 3), (0, 5)]); + assert_eq!(selected_anchors(&s, id), [(0, 2), (0, 3), (0, 4), (0, 5), (0, 6)], "the new anchor is selected with the segment's ends"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7); + assert_eq!(selected_segments(&s, id), [(0, 2), (0, 4)]); +} + +#[test] +fn add_anchor_point_on_the_closing_segment_keeps_it_selected_as_two_halves() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L100 0 L100 100 L0 100 Z"); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 3]], "mode": "set"})).unwrap(); + let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 3, "t": 0.5})).unwrap(); + assert_eq!(r["anchor"], 4); + assert_eq!(selected_segments(&s, id), [(0, 3), (0, 4)]); + assert_eq!(selected_anchors(&s, id), [(0, 0), (0, 3), (0, 4)]); + assert_eq!(selected_points(&s, id), [Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(0.0, 50.0)]); +} + +#[test] +fn add_anchor_point_renumbers_only_the_subpath_it_was_added_to() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [1, 1], [1, 2]], "mode": "set"})).unwrap(); + let before = selected_points(&s, id); + s.execute("path.insertAnchor", &json!({"id": id.0, "subpath": 1, "segment": 0, "t": 0.5})).unwrap(); + assert_eq!(selected_anchors(&s, id), [(0, 2), (1, 2), (1, 3)]); + assert_eq!(selected_points(&s, id), before); +} + +#[test] +fn add_anchor_point_with_a_selected_anchor_index_past_the_end_does_not_panic() { + // `select.anchors` takes any index, so a selected one can name no anchor. + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, usize::MAX]], "mode": "set"})).unwrap(); + s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.5})).unwrap(); + assert_eq!(selected_anchors(&s, id), [(0, 2), (0, usize::MAX)]); +} + fn selected_anchors(s: &Session, id: NodeId) -> Vec<(usize, usize)> { s.doc().unwrap().selection.anchors.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default() } diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index eab2f3067..f4ae97bb1 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -903,6 +903,29 @@ fn gradients_and_patterns_on_a_picked_object_are_taken() { assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); } +/// A stop selected on the whole instance's gradient is not selected once a part of its art is +/// picked, so the Gradient tool does not take Delete for it, and is selected again when the whole +/// instance is. +#[test] +fn picking_a_part_leaves_no_gradient_stop_selected() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": g})).unwrap(); + s.fill_active = true; + let view = ViewInfo::default(); + s.select_tool("gradient", view).unwrap(); + s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); + assert!(s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view)); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.selected_stop(), None); + assert!(!s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view)); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); +} + #[test] fn opacity_and_blend_on_a_picked_object_go_through_transparency_set() { let mut s = session(); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs new file mode 100644 index 000000000..80ad1f1af --- /dev/null +++ b/crates/engine/src/tests/dynamic_text.rs @@ -0,0 +1,250 @@ +//! Type › Dynamic Text (`type.dynamicText`): type on a path scaled by one factor to fill the span +//! between its brackets, fitted again after each edit, and kept at its drawn sizes when it is +//! turned off or threaded. +//! + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeKind, TextObject}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// 12 pt type on the 400 pt line from (100, 300) to (500, 300), from its start, selected. +fn on_line(s: &mut Session, text: &str) -> NodeId { + let pid = s.execute("path.create", &json!({ "d": "M100 300 L500 300" })).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": text, "size": 12, "at": [100, 300]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + id +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +fn fit(s: &Session, id: NodeId) -> f64 { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).fit_scale +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn err(r: Result) -> String { + r.unwrap_err().to_string() +} + +#[test] +fn dynamic_text_turns_on_and_off_and_toggles() { + let mut s = session(); + let id = on_line(&mut s, "Grow"); + let before = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id.0]))); + let f = r["fitScale"].as_f64().unwrap(); + assert!(f > 2.0, "short text on a long path grows: {f}"); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + assert!(text(&s, id).dynamic_text); + // The bounds the engine keeps are the fitted layout's. + let t = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(t.cached_bounds, Some(lay.bounds)); + // Already on: nothing changes and no undo step is recorded. + let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(r["on"], json!(true)); + assert_eq!(undo_len(&s), before + 1); + // Toggled off: the text keeps the sizes it was drawn at, in one undo step. + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!((r["on"].clone(), r["fitScale"].clone()), (json!(false), json!(1.0))); + assert_eq!(undo_len(&s), before + 2); + let off = text(&s, id); + assert!(!off.dynamic_text); + assert!((off.runs[0].style.size - 12.0 * f).abs() < 1e-9, "{} vs {}", off.runs[0].style.size, 12.0 * f); + // Undo brings Dynamic Text back with the stored size. + s.execute("edit.undo", &json!({})).unwrap(); + let back = text(&s, id); + assert!(back.dynamic_text && back.runs[0].style.size == 12.0); + let r = s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(r["on"], json!(false)); + let r = s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(r["on"], json!(false)); +} + +#[test] +fn dynamic_text_refuses_bad_params_and_other_type() { + let mut s = session(); + let id = on_line(&mut s, "Grow"); + let before = undo_len(&s); + assert!(err(s.execute("type.dynamicText", &json!({"on": "yes"}))).contains("on")); + assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on")); + assert_eq!(undo_len(&s), before); + assert!(!text(&s, id).dynamic_text); + // Point type and area type alone: refused. + let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + let area = + s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].as_u64().unwrap(); + for other in [point, area] { + s.execute("select.set", &json!({"ids": [other]})).unwrap(); + assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path")); + } + let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [empty]})).unwrap(); + assert!(s.execute("type.dynamicText", &json!({})).is_err()); + // With point type selected too, only the type on a path changes. + let before = undo_len(&s); + s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap(); + let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert!(!text(&s, NodeId(point)).dynamic_text); + assert_eq!(undo_len(&s), before + 1); +} + +#[test] +fn dynamic_text_fits_again_after_each_edit() { + let mut s = session(); + let id = on_line(&mut s, "Grow"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let start = fit(&s, id); + // Longer text draws smaller. + s.execute("text.setText", &json!({"ids": [id.0], "text": "Grow and grow again"})).unwrap(); + let longer = fit(&s, id); + assert!(longer < start * 0.5, "{longer} vs {start}"); + // An end bracket nearer the start draws it smaller still. + s.execute("type.pathOptions", &json!({"ids": [id.0], "end": 0.5})).unwrap(); + let half = fit(&s, id); + assert!(half < longer * 0.6, "{half} vs {longer}"); + s.execute("type.pathOptions", &json!({"ids": [id.0], "end": null})).unwrap(); + // A longer path draws it larger. + s.execute("text.reshapePath", &json!({"id": id.0, "anchors": [[0, 1]], "dx": 200, "dy": 0})).unwrap(); + let reshaped = fit(&s, id); + assert!(reshaped > longer * 1.3, "{reshaped} vs {longer}"); + // Each fit keeps the cached bounds in step with the layout. + let t = text(&s, id); + assert_eq!(t.cached_bounds, Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds)); + // Scaling the object: the fit is in document points, so the drawn size stays. + let drawn = |s: &Session| { + let t = text(s, id); + t.runs[0].style.size * t.style_scale().unwrap().points * fit(s, id) + }; + let size = drawn(&s); + s.execute("object.scale", &json!({"ids": [id.0], "sx": 50, "sy": 50})).unwrap(); + let smaller = drawn(&s); + assert!(smaller < size * 0.6, "the path is half as long: {smaller} vs {size}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).overflow); +} + +#[test] +fn threading_turns_dynamic_text_off_at_its_drawn_sizes() { + let mut s = session(); + let id = on_line(&mut s, "Threaded story"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let f = fit(&s, id); + let frame = s.execute("shape.rectangle", &json!({"x": 100, "y": 400, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id.0, frame]})).unwrap(); + let before = undo_len(&s); + let r = s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!(r["dynamicTextOff"], json!([id.0])); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + let t = text(&s, id); + assert!(!t.dynamic_text); + assert!((t.runs[0].style.size - 12.0 * f).abs() < 1e-9, "{} vs {}", t.runs[0].style.size, 12.0 * f); + // Undo restores Dynamic Text and the stored size together. + s.execute("edit.undo", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && t.runs[0].style.size == 12.0); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(!text(&s, id).dynamic_text); + // Type on a path in a thread is refused by id, alone or with other type on a path. + let e = err(s.execute("type.dynamicText", &json!({"on": true, "ids": [id.0]}))); + assert!(e.contains("in a thread") && e.contains(&id.0.to_string()), "{e}"); + let free = on_line(&mut s, "Free"); + let e = err(s.execute("type.dynamicText", &json!({"on": true, "ids": [free.0, id.0]}))); + assert!(e.contains("in a thread") && e.contains(&id.0.to_string()), "{e}"); + assert!(!text(&s, id).dynamic_text && !text(&s, free).dynamic_text); + // A thread made without any Dynamic Text says nothing about it. + let a = on_line(&mut s, "Plain"); + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let r = s.execute("text.thread.create", &json!({})).unwrap(); + assert!(r.get("dynamicTextOff").is_none(), "{r}"); +} + +#[test] +fn text_thread_add_turns_dynamic_text_off() { + let mut s = session(); + let id = on_line(&mut s, "Threaded story"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let f = fit(&s, id); + let r = s.execute("text.thread.add", &json!({"from": id.0, "rect": [100, 400, 200, 100]})).unwrap(); + assert_eq!(r["dynamicTextOff"], json!([id.0])); + let t = text(&s, id); + assert!(!t.dynamic_text && (t.runs[0].style.size - 12.0 * f).abs() < 1e-9); + let added = text(&s, NodeId(r["id"].as_u64().unwrap())); + assert!(!added.dynamic_text); +} + +#[test] +fn dynamic_text_is_saved_only_when_on_and_reads_back() { + let mut s = session(); + let id = on_line(&mut s, "Saved"); + let data = + |s: &mut Session| s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let off = data(&mut s); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let f = fit(&s, id); + let on = data(&mut s); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "on.vectorcraft", "dataBase64": on})).unwrap(); + let t = first_text(&o); + assert!(text(&o, t).dynamic_text); + assert_eq!(text(&o, t).runs[0].style.size, 12.0); + assert_eq!(fit(&o, t), f); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "off.vectorcraft", "dataBase64": off})).unwrap(); + let t = first_text(&o); + assert!(!text(&o, t).dynamic_text); + // The document summary reports the setting and the factor. + let d = s.execute("document.inspect", &json!({})).unwrap().to_string(); + assert!(d.contains("\"dynamicText\":true"), "{d}"); +} + +#[test] +fn svg_export_writes_the_drawn_size() { + let mut s = session(); + let id = on_line(&mut s, "Grow"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let size = 12.0 * fit(&s, id); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(svg.contains(" = svg + .match_indices("font-size") + .filter_map(|(at, m)| { + let rest = svg[at + m.len()..].trim_start_matches(['=', ':', '"', ' ']); + let end = rest.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(rest.len()); + rest[..end].parse().ok() + }) + .collect(); + assert!(sizes.iter().any(|v| (v - size).abs() < 1e-3), "{size} in {sizes:?}"); + assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); +} + +/// The id of the first text object in `s`'s document. +fn first_text(s: &Session) -> NodeId { + let mut found = None; + s.doc().unwrap().doc.walk(|n| { + if found.is_none() && matches!(n.kind, NodeKind::Text(_)) { + found = Some(n.id); + } + }); + found.unwrap() +} diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 090cb9828..038b8844b 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -75,6 +75,29 @@ fn symbol_instance_tint_survives_expanding_an_object_effect() { assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); } +/// The canvas tints a symbol's art with the instance fill before the instance's color adjustment, +/// and expanding gives the same colors. +#[test] +fn symbol_instance_tint_comes_before_a_color_adjustment_when_expanded() { + let mut s = session(); + square(&mut s); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Tinted Square"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let fill = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": fill, "color": "#00ff00", "opacity": 50})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "item": null, "effect": "adjust.hueSaturation", "params": {"hue": 120}})); + let before = render(&s); + assert_ne!(before.pixel(100, 100), [255, 255, 255, 255], "the symbol is visible"); + assert!(expandable(&s)); + + run(&mut s, "effect.expandAppearance", json!({})); + assert!(!matches!(node(&s, id).kind, NodeKind::SymbolInstance { .. })); + let after = render(&s); + let (b, a) = (before.pixel(100, 100), after.pixel(100, 100)); + assert!(b.iter().zip(a.iter()).all(|(x, y)| x.abs_diff(*y) <= 8), "expanding changed the color from {b:?} to {a:?}"); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + /// An instance fill with an opacity of 0 does not tint the symbol's art, so expanding keeps the /// art's global swatch link. #[test] diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index 2e08b99c8..84e4724fe 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -350,6 +350,31 @@ fn gradients_rasterize_and_flat_areas_stay_vector() { } } +#[test] +fn a_long_rasterized_area_is_placed_whole() { + let _settings = settings_lock(); + let mut s = session(); + // 5569 pt long: more than the renderer's 32,768 pixels a side at the 64 Mpx limit's scale. + let g = rect(&mut s, 10.0, 10.0, 100.0, 5569.0, "#000000"); + run( + &mut s, + "paint.setFill", + json!({"ids": [g], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ); + run(&mut s, "transparency.set", json!({"ids": [g], "opacity": 40})); + let (ids, _, rasterized) = flatten(&mut s, json!({"ids": [g], "lineArtPpi": 1200, "gradientPpi": 1200})); + assert_eq!(rasterized, 1); + let mut placed = vec![]; + node(&s, ids[0]).walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + placed.push((im.height, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))))); + } + }); + let [(height, b)] = placed[..] else { panic!("one image: {placed:?}") }; + assert!(height <= vectorcraft_render::MAX_RASTER_SIDE, "{height} pixels"); + assert!(b.y0 <= 10.0 && b.y1 >= 5579.0, "the image covers the strip: {b:?}"); +} + #[test] fn flatten_says_when_an_image_could_not_be_decoded() { let _settings = settings_lock(); diff --git a/crates/engine/src/tests/guide_clipboard.rs b/crates/engine/src/tests/guide_clipboard.rs index d509dfa20..421151102 100644 --- a/crates/engine/src/tests/guide_clipboard.rs +++ b/crates/engine/src/tests/guide_clipboard.rs @@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value { /// The active document's guides: (vertical, pos, layer). fn guides(s: &Session) -> Vec<(bool, f64, Option)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos, g.layer)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.layer)).collect() } #[test] @@ -31,7 +31,7 @@ fn ruler_guides_copy_and_paste_into_another_document() { let r = run(&mut s, "edit.paste", json!({})); assert_eq!(r["guides"], 2); assert_eq!(guides(&s), [(true, 120.0, Some(layer)), (false, 80.0, Some(layer))], "where they were, not offset"); - assert_eq!(s.doc().unwrap().selection.guides, [0, 1]); + assert_eq!(super::selected_guides(&s), [0, 1]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); run(&mut s, "edit.undo", json!({})); assert!(guides(&s).is_empty()); @@ -104,9 +104,9 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { assert!(s.clipboard.flavour_types(true, true).is_empty(), "nor any other format (the app empties the system clipboard)"); let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [1], "the first paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [1], "the first paste selects its own guide"); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [2], "the second paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [2], "the second paste selects its own guide"); let g = guides(&s); assert_eq!(g.len(), 3, "two pastes, two more guides: {g:?}"); assert_eq!((g[1].2, g[2].2), (Some(layer), Some(layer)), "on the layer new art goes to"); @@ -116,7 +116,7 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { // Hiding that layer hides the guides pasted on it: they follow its visibility (and lock). run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": false})); let st = s.doc().unwrap(); - let shown: Vec = st.doc.guides.iter().map(|g| st.doc.guide_shown(g)).collect(); + let shown: Vec = st.doc.ruler_guides().iter().map(|g| st.doc.guide_shown(g)).collect(); assert_eq!(shown, [true, false, false]); // With an object, the SVG holds the object and no guide. run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": true})); diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index d21d9ec97..279020432 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -10,6 +10,12 @@ use crate::*; /// A session with one document holding `n` rectangles, all selected. fn session_with_rects(n: usize) -> Session { let mut s = Session::new(); + open_with_rects(&mut s, n); + s +} + +/// Open a new document in `s` holding `n` rectangles, all selected. +fn open_with_rects(s: &mut Session, n: usize) { s.execute("file.new", &json!({"width": 2000, "height": 2000})).unwrap(); s.edit("Add", |d, _| { let layer = d.layers[0].id; @@ -22,7 +28,6 @@ fn session_with_rects(n: usize) -> Session { }) .unwrap(); s.execute("select.all", &json!({})).unwrap(); - s } fn first_x(s: &Session) -> f64 { @@ -75,6 +80,203 @@ fn within_the_budget_the_step_limit_still_counts() { assert!(st.history.bytes() < st.history.byte_budget); } +fn moves(s: &mut Session, n: usize) { + for _ in 0..n { + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + } +} + +fn undo_lens(s: &Session) -> Vec { + s.docs.iter().map(|d| d.history.undo.len()).collect() +} + +fn history_bytes(s: &Session) -> usize { + s.docs.iter().map(|d| d.history.bytes()).sum() +} + +#[test] +fn one_document_keeps_its_steps_within_the_session_budget() { + let mut s = session_with_rects(500); + moves(&mut s, 1); + let step = s.doc().unwrap().history.undo.last().unwrap().bytes; + s.history_budget = 3 * step; + moves(&mut s, 20); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), History::MIN_STEPS, "only the last few steps are kept"); + assert_eq!(st.history.byte_budget, default_history_budget(), "the document's own budget is unchanged"); + // A lower budget applies at the next command, even one that adds no step. + s.history_budget = usize::MAX; + moves(&mut s, 4); + assert_eq!(s.doc().unwrap().history.undo.len(), History::MIN_STEPS + 4); + s.history_budget = 3 * step; + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), History::MIN_STEPS); +} + +#[test] +fn open_documents_share_one_budget_and_the_oldest_steps_go_first() { + let n = 300; + let mut s = session_with_rects(n); + moves(&mut s, 1); + let step = s.doc().unwrap().history.undo.last().unwrap().bytes; + // Room for about 22 moves in all the open documents together. + s.history_budget = 22 * step; + moves(&mut s, 11); + assert_eq!(undo_lens(&s), [13], "Add and 12 moves fit in the budget"); + open_with_rects(&mut s, n); + moves(&mut s, 12); + let lens = undo_lens(&s); + assert_eq!(lens[1], 13, "the newer document keeps all of its steps: {lens:?}"); + assert!((History::MIN_STEPS..13).contains(&lens[0]), "the older document's oldest steps went: {lens:?}"); + assert!(history_bytes(&s) <= s.history_budget, "{} bytes kept, budget {}", history_bytes(&s), s.history_budget); + // Closing the older document frees its share: the other keeps more steps than before. + assert!(s.close_document(0)); + assert!(s.set_active(0)); + moves(&mut s, 6); + assert_eq!(undo_lens(&s), [19], "Add and 18 moves fit in the budget alone"); +} + +#[test] +fn an_undo_group_lets_no_other_documents_steps_go_until_it_ends() { + let mut s = session_with_rects(50); + moves(&mut s, 12); + open_with_rects(&mut s, 50); + // The steps kept now fill the budget exactly. + s.history_budget = history_bytes(&s); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(undo_lens(&s), [13, 1]); + // A scrubbed field's edits in the second document, then canceled. + s.begin_undo_group(); + moves(&mut s, 3); + assert_eq!(undo_lens(&s), [13, 2], "the group's step waits for the group to end"); + s.end_undo_group(true); + assert_eq!(undo_lens(&s), [13, 1], "a canceled group leaves the other document's steps"); + // The same edits kept: the other document's oldest steps go. + s.begin_undo_group(); + moves(&mut s, 3); + s.end_undo_group(false); + let lens = undo_lens(&s); + assert_eq!(lens[1], 2); + assert!((History::MIN_STEPS..13).contains(&lens[0]), "the older document's oldest steps went: {lens:?}"); + assert!(history_bytes(&s) <= s.history_budget); + // A scrub through an undo scope: its step counts once the scope ends. + s.history_budget = usize::MAX; + assert!(s.set_active(0)); + moves(&mut s, 6); + assert!(s.set_active(1)); + s.history_budget = history_bytes(&s); + let before = undo_lens(&s)[0]; + let scope = s.begin_undo_scope().unwrap(); + moves(&mut s, 3); + assert_eq!(undo_lens(&s), [before, 3], "the scope's step waits for the scope to end"); + s.end_undo_scope(scope, false); + let lens = undo_lens(&s); + assert!(lens[0] < before, "the older document's oldest steps went once the scope ended: {lens:?}"); + assert!(history_bytes(&s) <= s.history_budget); +} + +#[test] +fn steps_kept_outside_a_command_keep_to_the_shared_budget() { + let mut s = session_with_rects(50); + moves(&mut s, 12); + open_with_rects(&mut s, 50); + assert_eq!(undo_lens(&s), [13, 1]); + // A drag the frontend commits itself. + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap(); + s.history_budget = 1; + assert_eq!(undo_lens(&s), [13, 1]); + s.commit_interaction().unwrap(); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, 2], "the drag's step counts against the other document's"); + // A script run across frames, whose step is kept after `script.run` returns. + s.history_budget = usize::MAX; + assert!(s.set_active(0)); + moves(&mut s, 4); + assert!(s.set_active(1)); + s.script_frames = true; + let source = "for (var i = 0; i < 3000000; i++) {} vc.run('object.move', {dx: 1, dy: 0});"; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap()["running"], true); + s.history_budget = 1; + let mut slices = 0; + let r = loop { + slices += 1; + assert!(slices < 100_000, "the script ends"); + if let Some(r) = s.script_pump() { + break r; + } + }; + assert_eq!(r.unwrap()["ok"], true); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, 3], "the run's step counts against the other document's"); + // An edit a frontend makes itself, outside a command. + s.history_budget = usize::MAX; + assert!(s.set_active(0)); + moves(&mut s, 4); + assert!(s.set_active(1)); + s.history_budget = 1; + s.edit("Add", |d, _| { + let id = d.alloc_id(); + let r = shapes::rectangle(kurbo::Rect::new(0.0, 20.0, 10.0, 30.0)); + d.insert(Some(d.layers[0].id), usize::MAX, Node::path(id, r, Appearance::default_art()))?; + Ok(()) + }) + .unwrap(); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, 4], "the edit's step counts against the other document's"); +} + +#[test] +fn each_document_keeps_its_last_steps_however_small_the_budget() { + let mut s = Session::new(); + // Smaller than any one step. + s.history_budget = 1; + for _ in 0..4 { + open_with_rects(&mut s, 50); + moves(&mut s, 12); + } + assert_eq!(undo_lens(&s), [History::MIN_STEPS; 4]); + // The kept steps undo and redo, and the document now is as it was. + let x = first_x(&s); + assert_eq!(x, 12.0); + for _ in 0..History::MIN_STEPS { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(first_x(&s), x - History::MIN_STEPS as f64); + assert!(s.execute("edit.undo", &json!({})).is_err(), "the older steps went"); + assert_eq!(s.doc().unwrap().history.redo.len(), History::MIN_STEPS); + // A held history keeps all of its steps until it is released. + assert!(s.set_active(0)); + s.docs[0].history.held = true; + moves(&mut s, 3); + assert_eq!(undo_lens(&s)[0], History::MIN_STEPS + 3, "a held history keeps its steps"); + s.docs[0].history.held = false; + s.trim_histories(); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, History::MIN_STEPS, History::MIN_STEPS, 0]); +} + +#[test] +#[cfg(target_pointer_width = "64")] +fn the_default_budget_is_a_quarter_of_installed_memory() { + assert_eq!(history_budget_for(Some(16 << 30)), 4 << 30); + assert_eq!(history_budget_for(Some(64 << 30)), 16 << 30, "no fixed ceiling"); + // On Linux `physical_memory` returns a cgroup memory limit lower than the installed memory in + // its place (the `cgroup_limit_*` tests in vectorcraft-render), so a 2 GiB limit on a 64 GiB + // machine gives the budget of 2 GiB installed. + assert_eq!(history_budget_for(Some(2 << 30)), 512 << 20, "2 GiB, or a 2 GiB cgroup limit"); + assert_eq!(history_budget_for(Some(128 << 20)), 64 << 20, "at least 64 MiB"); + assert_eq!(history_budget_for(Some(1)), 64 << 20, "at least 64 MiB"); + assert_eq!(history_budget_for(Some(0)), 1 << 30, "a reading of 0 is the 1 GiB fallback"); + assert_eq!(history_budget_for(None), 1 << 30, "no reading is the 1 GiB fallback"); +} + +#[test] +#[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] +fn the_default_budget_follows_the_installed_memory_read() { + // Where the memory can't be read (a sandbox that hides it), the budget is the 1 GiB fallback. + let memory = vectorcraft_render::physical_memory(); + assert_eq!(default_history_budget(), history_budget_for(memory), "{memory:?}"); + assert_eq!(History::default().byte_budget, default_history_budget()); + assert_eq!(Session::new().history_budget, default_history_budget(), "the open documents share it"); +} + #[test] fn undo_steps_keep_no_lookup_tables() { let mut s = session_with_rects(200); diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 8e26577f9..1e13c468c 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -436,9 +436,7 @@ fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() { /// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and /// with it null do the same: what the selection's list does for the commands that take the /// selection, and the command's own default for the others (`object.slice.select` deselects the -/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through -/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes -/// nothing. +/// slices). #[test] fn ids_left_out_or_null_take_the_selection_or_the_default() { let (_, [.., d], _) = doc_with_type(); @@ -450,15 +448,7 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { let mut null = params.clone(); null[key] = serde_json::Value::Null; let with_null = outcome(cmd, &null, &[d], slice); - if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") { - let (mut s, ..) = doc_with_type(); - s.execute("select.set", &json!({ "ids": [d] })).unwrap(); - let st = s.doc().unwrap(); - let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids")); - if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) { - failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0)); - } - } else if with_null != left_out { + if with_null != left_out { failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0)); } let mut listed = params.clone(); @@ -477,6 +467,65 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); } +/// The commands that read `ids` through [`cmd::checked_ids_or_selection`] take `ids: null` as left +/// out: with the rectangle apart (`d`) or the type object (`t`) selected, null gives the same +/// result or error, document, selection and undo history as leaving `ids` out. Print Ink Fixes +/// runs in a CMYK document. +#[test] +fn ids_null_takes_the_selection_in_every_command_that_falls_back_to_it() { + let (_, [.., d], t) = doc_with_type(); + let mut failures = vec![]; + let (rect, both, text) = (&[d][..], &[d, t][..], &[t][..]); + let cases = [ + ("addon.arrange.randomizeOpacity", json!({"seed": 1}), rect), + ("addon.arrange.sort", json!({"by": "area", "apply": "x"}), both), + ("addon.cleanup.simplify", json!({}), rect), + ("addon.cleanup.deepUngroup", json!({}), rect), + ("addon.cleanup.rejoin", json!({}), rect), + ("addon.color.nearestSwatch", json!({}), rect), + ("addon.pathtools.cornerSet", json!({"radius": 2}), rect), + ("addon.pathtools.cornerRemove", json!({}), rect), + ("addon.pathtools.shape.convert", json!({}), rect), + ("addon.pathtools.erase", json!({"points": [[0, 20], [200, 20]]}), rect), + ("addon.pathtools.crossings", json!({}), rect), + ("addon.pathtools.expandDashes", json!({}), rect), + ("object.path.smooth", json!({}), rect), + ("object.textWrap.make", json!({}), rect), + ("object.textWrap.release", json!({}), rect), + ("object.textWrap.options", json!({}), rect), + ("variable.bind", json!({"variable": "v"}), rect), + ("variable.unbind", json!({}), rect), + ("addon.cleanup.breakText", json!({}), text), + ("addon.cleanup.combineText", json!({}), text), + ("addon.cleanup.joinText", json!({}), text), + ("addon.cleanup.removeBreaks", json!({"kind": "line"}), text), + ("addon.cleanup.resetTextTransform", json!({}), text), + ("addon.cleanup.alignInPlace", json!({"align": "left"}), text), + ]; + for (cmd, params, selected) in cases { + let left_out = outcome(cmd, ¶ms, selected, false); + let mut null = params.clone(); + null["ids"] = serde_json::Value::Null; + let with_null = outcome(cmd, &null, selected, false); + if with_null != left_out { + failures.push(format!("{cmd}: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + } + let print_fixes = |p: &serde_json::Value| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk", "created": null})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let r = s.execute("addon.color.printFixes", p).map_err(|e| e.to_string()); + let st = s.doc().unwrap(); + (r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len()) + }; + let (left_out, with_null) = (print_fixes(&json!({"inkLimit": 200})), print_fixes(&json!({"inkLimit": 200, "ids": null}))); + if with_null != left_out { + failures.push(format!("addon.color.printFixes: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + /// With no document open, every command that takes `ids` refuses a list with an entry that is not /// a non-negative integer, and a value that is not a list, with the same error as with a document /// open (`effect.list` and `links.findFiles` run without a document). diff --git a/crates/engine/src/tests/keyguide.rs b/crates/engine/src/tests/keyguide.rs index 09acdd726..327c0b4b2 100644 --- a/crates/engine/src/tests/keyguide.rs +++ b/crates/engine/src/tests/keyguide.rs @@ -37,14 +37,14 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { // Click an object, Shift-click the guide, then click the guide again. click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 400.0, true); - assert_eq!((s.doc().unwrap().selection.objects.clone(), s.doc().unwrap().selection.guides.clone()), (vec![a], vec![0])); + assert_eq!((s.doc().unwrap().selection.objects.clone(), super::selected_guides(&s)), (vec![a], vec![0])); click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "the guide is the key"); + assert_eq!(super::key_guide(&s), Some(0), "the guide is the key"); assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["keyGuide"], 0); // Shift-click a third object: the guide stays the key. click(&mut s, 310.0, 260.0, true); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); + assert_eq!(super::key_guide(&s), Some(0)); let (ya, yb) = (bounds(&s, a).y0, bounds(&s, b).y0); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); // Both centred on the guide's x; a vertical guide fixes no y, so they keep theirs. @@ -52,12 +52,12 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { let r = bounds(&s, id); assert!((r.center().x - 500.0).abs() < 1e-6 && (r.y0 - y).abs() < 1e-6, "{id:?}: {r:?}"); } - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 500.0, "the guide stays put"); + assert_eq!(positions(&s)[0], 500.0, "the guide stays put"); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert!((bounds(&s, a).x0 - 500.0).abs() < 1e-6 && (bounds(&s, b).x0 - 500.0).abs() < 1e-6); // A click on the key guide again: no key. click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); } #[test] @@ -96,22 +96,22 @@ fn select_key_takes_only_a_selected_guide_with_objects() { s.execute("select.key", &json!({"guide": 1})).unwrap(); // An object key replaces the guide key, and the other way round. s.execute("select.key", &json!({"id": b.0})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (Some(b), None)); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (Some(b), None)); s.execute("select.key", &json!({"guide": 1})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (None, Some(1))); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (None, Some(1))); // Deleting a guide before it keeps the key on the same guide. s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 600.0); + assert_eq!(super::key_guide(&s), Some(0)); + assert_eq!(positions(&s)[0], 600.0); // Taking the guide out of the selection, or every object, lets the key go. s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); s.execute("select.key", &json!({"guide": 0})).unwrap(); s.execute("select.toggle", &json!({"id": a.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "one object is enough"); + assert_eq!(super::key_guide(&s), Some(0), "one object is enough"); s.execute("select.toggle", &json!({"id": b.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None, "no object to align"); + assert_eq!(super::key_guide(&s), None, "no object to align"); } #[test] @@ -141,12 +141,12 @@ fn selected_guides_align_to_a_clicked_key_object() { click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 300.0, true); click(&mut s, 300.0, 400.0, true); - assert_eq!(s.doc().unwrap().selection.guides, vec![0, 1]); + assert_eq!(super::selected_guides(&s), vec![0, 1]); click(&mut s, 110.0, 110.0, false); assert_eq!(s.doc().unwrap().selection.key, Some(a), "the object is the key"); let before = bounds(&s, a); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); - let guides = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let guides = positions; assert_eq!(guides(&s), [120.0, 110.0], "the vertical guide on the centre's x, the horizontal one on its y"); assert_eq!(bounds(&s, a), before, "the key stays put"); s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); @@ -174,15 +174,15 @@ fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() s.execute("select.key", &json!({"id": b.0})).unwrap(); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert_eq!(bounds(&s, a).x0, 300.0); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // Aligning to the selection leaves guides alone. s.execute("object.align", &json!({"horizontal": "right", "to": "selection"})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // A key guide stays put, and the other vertical guide lines up on it. s.execute("guide.move", &json!({"index": 1, "pos": 700})).unwrap(); s.execute("select.key", &json!({"guide": 1})).unwrap(); s.execute("object.align", &json!({"horizontal": "center"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(), [700.0, 700.0]); + assert_eq!(positions(&s), [700.0, 700.0]); } /// Objects 40 pt wide at `xs` (y 100) and a vertical guide at `pos` that is their key. @@ -208,7 +208,7 @@ fn distribute_with_a_spacing_spaces_the_objects_from_a_key_guide() { for (h, want) in [("left", [330.0, 340.0, 360.0]), ("center", [310.0, 320.0, 340.0]), ("right", [290.0, 300.0, 320.0])] { s.execute("object.distribute", &json!({"horizontal": h, "spacing": 10})).unwrap(); assert_eq!(lefts(&s, &ids), want, "{h}"); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0, "{h}: the guide stays put"); + assert_eq!(positions(&s)[0], 350.0, "{h}: the guide stays put"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(lefts(&s, &ids), [100.0, 300.0, 500.0], "{h}: one undo step"); } @@ -300,14 +300,14 @@ fn guides_alone(vertical: &[f64], horizontal: &[f64]) -> Session { /// Selects the objects `ids` and the ruler guides `guides`, and nothing else. fn select_with_guides(s: &mut Session, ids: &[NodeId], guides: &[usize]) { s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); - let now = s.doc().unwrap().selection.guides.clone(); - let flip: Vec = (0..s.doc().unwrap().doc.guides.len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); + let now = super::selected_guides(s); + let flip: Vec = (0..positions(s).len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); s.execute("guide.select", &json!({"indexes": flip, "toggle": true})).unwrap(); assert_eq!((s.doc().unwrap().selection.objects.len(), s.doc().unwrap().selection.guides.len()), (ids.len(), guides.len())); } fn positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } #[test] diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 9a9aafcba..fefed0ad1 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -368,6 +368,31 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() { assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image"); } +/// A transparent image longer than the renderer draws at its own resolution (32,768 pixels a +/// side) is flattened whole, at a lower resolution. +#[test] +fn a_long_transparent_image_is_flattened_whole() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 4600})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 4600})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&png(2000, 1600, [0, 0, 0, 128])), "at": [50, 50]}); + s.execute("file.place", &p).unwrap(); + // 10 × 4500 pt: about 600 ppi, so 37,500 pixels long. + s.execute("object.setBounds", &json!({"x": 10, "y": 10, "width": 10, "height": 4500, "reference": 0})).unwrap(); + let before = [colour_at(&s.doc().unwrap().doc, 15.0, 100.0), colour_at(&s.doc().unwrap().doc, 15.0, 4400.0)]; + assert!(before.iter().all(|c| c[0] < 200), "the image darkens the red: {before:?}"); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + let after = [colour_at(d, 15.0, 100.0), colour_at(d, 15.0, 4400.0)]; + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}"); + } +} + /// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear /// around it, 72 ppi. fn disk(size: u32, r: f64) -> Vec { diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 951a9ec31..7f70d24ff 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -349,6 +349,41 @@ fn perceptual_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_ assert!(r.warnings.iter().any(|w| w.contains("spot")), "RGB destination: {:?}", r.warnings); } +#[test] +fn classic_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_n_shadings() { + let (mut s, _) = setup(); + run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]})); + let (pdf, warnings) = pdf_of(&s); + assert!(!pdf.contains("/DeviceN") && warnings.iter().any(|w| w.contains("spot")), "an RGB document drops the ink: {warnings:?}"); + // A CMYK document, or a conversion to a CMYK destination, writes the RGB stop in CMYK: a + // DeviceN shading that keeps the ink. + let mut cmyk = doc(&s).clone(); + cmyk.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let plain = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }; + let mut converted = plain.clone(); + converted.settings.output = vectorcraft_pdf::OutputSettings { + conversion: vectorcraft_pdf::ColorConversion::Destination, + destination: vectorcraft_pdf::DOCUMENT_CMYK.into(), + ..Default::default() + }; + for (what, d, opts) in [("CMYK document", &cmyk, &plain), ("CMYK destination", doc(&s), &converted)] { + let r = vectorcraft_pdf::export_with_report(d, opts).unwrap(); + let pdf = String::from_utf8_lossy(&r.bytes); + assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Ink]/DeviceCMYK"), "{what}: a DeviceN shading with Ink"); + assert!(!r.warnings.iter().any(|w| w.contains("spot")), "{what}: {:?}", r.warnings); + } + // A conversion to an RGB destination keeps the RGB stop in RGB, in a CMYK document too. + let mut to_rgb = plain.clone(); + to_rgb.settings.output = vectorcraft_pdf::OutputSettings { + conversion: vectorcraft_pdf::ColorConversion::Destination, + destination: vectorcraft_color::cms::SRGB.into(), + ..Default::default() + }; + let r = vectorcraft_pdf::export_with_report(&cmyk, &to_rgb).unwrap(); + assert!(!String::from_utf8_lossy(&r.bytes).contains("/DeviceN"), "RGB destination: no DeviceN shading"); + assert!(r.warnings.iter().any(|w| w.contains("spot")), "RGB destination: {:?}", r.warnings); +} + #[test] fn spot_gradients_drawn_as_images_warn() { let (mut s, _) = setup(); diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 08fc120e0..9fc790efa 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -945,6 +945,34 @@ fn mesh_point_colour_move_and_undo() { assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 99, "color": "#000000"})).is_err()); } +/// #2752: a Mesh tool click on a star, solid or gradient filled, gives a mesh with every point of +/// the star on its border, in one undo step that gives the star back. +#[test] +fn a_mesh_tool_click_keeps_a_stars_points() { + for gradient in [false, true] { + let mut s = session(); + let a = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 200, "radius2": 80, "points": 5})).unwrap()); + if gradient { + s.execute("paint.setFill", &json!({"ids": [a.0], "gradient": {"kind": "linear"}})).unwrap(); + } else { + fill(&mut s, a, "#0000ff"); + } + let star = node(&s, a).path_data().unwrap().clone(); + sel(&mut s, &[a]); + let undos = undo_len(&s); + s.execute("object.mesh.create", &json!({"at": [300, 300]})).unwrap(); + assert_eq!(undo_len(&s), undos + 1); + let m = mesh_of(&s, a); + let border = m.outline().to_bezpath(); + for anchor in &star.subpaths[0].anchors { + assert!(m.points.iter().any(|q| q.p.distance(anchor.p) < 1e-6), "{:?} is not a mesh point", anchor.p); + assert!(vectorcraft_geom::Shape::bounding_box(&border).inflate(1e-6, 1e-6).contains(anchor.p)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, a).path_data(), Some(&star)); + } +} + /// A mesh point's `color` with an alpha part (`#rrggbbaa` or `#rgba`) is refused and changes /// nothing, also when `opacity` is given and when Add Mesh Line takes it; `#rrggbb` still sets it. #[test] diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index e59f76457..e9a30c57e 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1461,6 +1461,42 @@ fn rasterize_replaces_selection_with_image() { assert_eq!(selected(&s), vec![img]); } +/// Rasterize refuses art that would be longer than the renderer draws (32,768 pixels a side), and +/// the art stays as it was; at a resolution that fits, the image covers all of it. +#[test] +fn rasterize_refuses_an_image_longer_than_the_renderer_draws() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 100.0, 5569.0); + sel(&mut s, &[a]); + let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap(); + let before = (undo_len(&s), node(&s, a)); + let e = s.execute("object.rasterize", &json!({"ppi": 600, "padding": 0})).unwrap_err().to_string(); + assert!(e.contains("too large"), "{e}"); + assert_eq!((undo_len(&s), node(&s, a)), before); + let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap()); + let n = node(&s, img); + let NodeKind::Image(im) = &n.kind else { panic!() }; + assert!(im.height <= vectorcraft_render::MAX_RASTER_SIDE, "{} pixels", im.height); + let b = n.geometric_bounds().unwrap(); + assert!(close(b.y0, vb.y0) && b.y1 >= vb.y1, "{b:?} covers {vb:?}"); +} + +/// Art that snaps to exactly 32,768 pixels a side is rasterized, wherever it is on the canvas. +#[test] +fn rasterize_takes_art_that_snaps_to_32768_pixels_a_side() { + let scale = 400.0 / 72.0; + for y in [100_000.0, 250_000.0, 333_333.3, 777_777.7] { + let mut s = session(); + let probe = rect(&mut s, 10.0, y, 100.0, 5898.0); + let vb = s.doc().unwrap().doc.bounds_of(&[probe], true).unwrap(); + let h = 5898.0 + (32_767.8 / scale - vb.height()); + let a = rect(&mut s, 200.0, y, 100.0, h); + sel(&mut s, &[a]); + let r = s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap_or_else(|e| panic!("at y = {y}: {e}")); + assert_eq!(r["height"].as_u64(), Some(u64::from(vectorcraft_render::MAX_RASTER_SIDE)), "at y = {y}: {r}"); + } +} + /// Rasterize refuses a resolution given as text or as another value that is not a number, and the /// selected art stays as it was. #[test] @@ -1514,6 +1550,44 @@ fn crop_image_to_rect() { assert!(s.execute("object.cropImage", &json!({})).is_err()); } +/// 100 × 80 px of a busy pattern, opened as an image and scaled to 2 pt a pixel; returns the session, the image and its pixels. +fn busy_image_at_two_points_a_pixel() -> (Session, NodeId, image::RgbaImage) { + let mut px = image::RgbaImage::new(100, 80); + for (x, y, p) in px.enumerate_pixels_mut() { + *p = image::Rgba([(x * 37 % 256) as u8, (y * 91 % 256) as u8, ((x ^ y) * 13 % 256) as u8, 255]); + } + let mut png = Vec::new(); + px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let img = selected(&s)[0]; + s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap(); + (s, img, px) +} + +#[test] +fn crop_image_to_rect_copies_the_pixels_without_resampling() { + let (mut s, img, px) = busy_image_at_two_points_a_pixel(); + // Pixels (10, 5) to (40, 25), as points: on the pixel grid, so the cut copies them as they are. + let r = s.execute("object.cropImage", &json!({"rect": [20, 10, 60, 40]})).unwrap(); + assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 20), "{r}"); + let b = bounds(&s, img); + assert!(close(b.x0, 20.0) && close(b.y0, 10.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}"); + let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") }; + let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(out, image::imageops::crop_imm(&px, 10, 5, 30, 20).to_image()); +} + +#[test] +fn crop_image_to_a_rect_between_pixels_keeps_the_rect() { + let (mut s, img, _) = busy_image_at_two_points_a_pixel(); + // Pixels (10.5, 5.5) to (40.5, 25.5): off the pixel grid, so the cut is drawn and lands on the rect, not on whole pixels. + s.execute("object.cropImage", &json!({"rect": [21, 11, 60, 40]})).unwrap(); + let b = bounds(&s, img); + assert!(close(b.x0, 21.0) && close(b.y0, 11.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}"); +} + /// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a /// null `rect` crops to the artboard as a missing one does. #[test] @@ -3612,7 +3686,7 @@ fn make_release_clear_guides() { s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); let n = undo_len(&s); assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.doc().unwrap().doc.node(a).is_none()); assert_eq!(undo_len(&s), n + 1); } @@ -3677,13 +3751,13 @@ fn ruler_guides_and_lock() { let mut s = session(); assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0); s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 70.0); assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true); assert!(s.guides_locked()); assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..)))); s.execute("view.guides.lock", &json!({})).unwrap(); s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); } @@ -3730,7 +3804,7 @@ fn invalid_guide_lock_parameters_keep_guides_selected() { for params in [json!({"locked": "false"}), json!({"locked": 1})] { assert!(s.execute("view.guides.lock", ¶ms).is_err(), "accepted {params}"); assert!(!s.guides_locked(), "locked guides for {params}"); - assert_eq!(s.doc().unwrap().selection.guides, vec![0], "cleared selection for {params}"); + assert_eq!(selected_guides(&s), vec![0], "cleared selection for {params}"); assert_eq!(s.doc().unwrap().history.undo.len(), before_undo, "recorded undo for {params}"); } // Left out, or null as for every command, it toggles. @@ -3913,11 +3987,11 @@ fn a_failed_preview_leaves_locked_guides_deselected() { } fn guides(s: &Session) -> Vec<(bool, f64)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect() } fn selected_guides(s: &Session) -> Vec { - s.doc().unwrap().selection.guides.clone() + super::selected_guides(s) } /// #414: ruler guides are selected, moved, copied, nudged and deleted like art, over commands. @@ -4145,10 +4219,12 @@ fn guides_and_art_later_cases(s: &mut Session, a: NodeId) { assert_eq!(guides(s).len(), 4); assert_eq!(guides(s)[2], (true, 210.0)); assert_eq!(guides(s)[3], (false, 320.0)); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // The layer's art (its ruler guides are listed after it). + let art = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count(); + assert_eq!(art(s), 2); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(guides(s).len(), 2); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + assert_eq!(art(s), 1); } /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, @@ -4170,7 +4246,7 @@ fn artboard_guides_go_with_their_artboard() { ); let spans = |s: &Session| { let d = &s.doc().unwrap().doc; - d.guides.iter().map(|g| d.guide_span(g)).collect::>() + d.ruler_guides().iter().map(|g| d.guide_span(g)).collect::>() }; assert_eq!(spans(&s), [Some((0.0, 300.0)), None]); for bad in [json!(5), json!("x"), json!(-1)] { @@ -4196,7 +4272,7 @@ fn artboard_guides_go_with_their_artboard() { // Rearranged, they follow their artboards. s.execute("artboard.rearrange", &json!({"columns": 4})).unwrap(); let d = &s.doc().unwrap().doc; - for (g, ab) in d.guides.iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { + for (g, ab) in d.ruler_guides().iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { assert_eq!(g.pos - ab.rect.x0, 160.5, "{g:?} on {ab:?}"); } // Deleted, the artboard takes its guides; the selected ones left stay selected. @@ -4256,17 +4332,91 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() { ); } +/// With Preferences › Guides & Grid › Artboard Guides with Cmd/Ctrl + Ruler Drag on, Cmd/Ctrl held +/// when a guide is dragged out of a ruler makes a guide of the view's active artboard (one undo +/// step), shown and made only where it lies within that artboard; with the Artboard tool it makes +/// a canvas guide instead. The key counts on the drag's first event only. With the preference off, +/// Cmd/Ctrl changes nothing. +#[test] +fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + let mut s = session(); + s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap(); + let v = ViewInfo { artboard: 1, ..ViewInfo::default() }; + let cmd = Mods { cmd: true, ..Mods::default() }; + let ev = |kind, x, y, mods| PointerEvent::new(kind, x, y).with_mods(mods); + let owners = |s: &mut Session| -> Vec { + let list = s.execute("guide.list", &json!({})).unwrap(); + list.as_array().unwrap().iter().map(|g| g.get("artboard").cloned().unwrap_or(Value::Null)).collect() + }; + let drag = |s: &mut Session, view: ViewInfo, vertical: bool, at: f64, first: Mods| { + let (x, y) = if vertical { (at, 30.0) } else { (1000.0, at) }; + s.ruler_guide(vertical, &ev(PointerKind::Drag, x, y, first), true, view).unwrap(); + // Letting go of the key during the drag keeps the kind of guide. + s.ruler_guide(vertical, &ev(PointerKind::Up, x, y, Mods::default()), true, view).unwrap(); + }; + + // Off by default: a Cmd/Ctrl drag makes a canvas guide. + assert!(!s.prefs.cmd_ruler_drag_artboard_guides); + drag(&mut s, v, true, 1010.0, cmd); + assert_eq!(owners(&mut s), [Value::Null]); + + s.execute("prefs.set", &json!({"key": "cmdRulerDragArtboardGuides", "value": true})).unwrap(); + let n = undo_len(&s); + drag(&mut s, v, true, 1020.0, cmd); + drag(&mut s, v, false, 120.0, cmd); + assert_eq!(guides(&s)[1..], [(true, 1020.0), (false, 120.0)]); + assert_eq!(owners(&mut s)[1..], [json!(1), json!(1)]); + assert_eq!(undo_len(&s), n + 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(owners(&mut s)[1..], [json!(1), json!(1)], "redo keeps the artboard"); + + // Outside the active artboard (left of it, and below it): no preview, no guide, no undo step. + let n = undo_len(&s); + s.ruler_guide(true, &ev(PointerKind::Drag, 500.0, 30.0, cmd), true, v).unwrap(); + assert_eq!(guides(&s).len(), 3, "no preview"); + s.ruler_guide(true, &ev(PointerKind::Up, 500.0, 30.0, cmd), true, v).unwrap(); + drag(&mut s, v, false, 450.0, cmd); + assert_eq!((guides(&s).len(), undo_len(&s)), (3, n)); + // Dragged in and out again: the preview goes and nothing is made. + s.ruler_guide(true, &ev(PointerKind::Drag, 1100.0, 30.0, cmd), true, v).unwrap(); + assert_eq!(guides(&s).len(), 4, "the preview within the artboard"); + s.ruler_guide(true, &ev(PointerKind::Drag, 700.0, 30.0, Mods::default()), true, v).unwrap(); + s.ruler_guide(true, &ev(PointerKind::Up, 700.0, 30.0, Mods::default()), true, v).unwrap(); + assert_eq!((guides(&s).len(), undo_len(&s)), (3, n)); + + // An active artboard the document doesn't have: nothing is made. + drag(&mut s, ViewInfo { artboard: 7, ..v }, true, 1030.0, cmd); + assert_eq!((guides(&s).len(), undo_len(&s)), (3, n)); + + // Without the key: a canvas guide, as before. + drag(&mut s, v, true, 1040.0, Mods::default()); + assert_eq!(owners(&mut s)[3], Value::Null); + + // The Artboard tool: Cmd/Ctrl makes a canvas guide, and a plain drag an artboard guide. + s.select_tool("artboard", v).unwrap(); + s.set_tool_option("active", &json!(1)); + drag(&mut s, v, true, 400.0, cmd); + drag(&mut s, v, true, 1050.0, Mods::default()); + assert_eq!(guides(&s)[4..], [(true, 400.0), (true, 1050.0)]); + assert_eq!(owners(&mut s)[4..], [Value::Null, json!(1)]); +} + /// Guides saved before guides had layers are put on a layer when the document opens. #[test] fn guides_from_older_files_are_put_on_a_layer() { let mut s = Session::new(); - let mut d = vectorcraft_doc::Document::new(400.0, 300.0); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + // A document read with its guides on no layer, as such a file holds them. + let mut v = serde_json::to_value(vectorcraft_doc::Document::new(400.0, 300.0)).unwrap(); + v["guides"] = json!([{"vertical": true, "pos": 50.0}, {"vertical": false, "pos": 80.0}]); + let d: vectorcraft_doc::Document = serde_json::from_value(v).unwrap(); s.add_document(d, None); let st = s.doc().unwrap(); let top = st.doc.default_layer(); - assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides); + let guides = st.doc.ruler_guides(); + assert_eq!(guides.len(), 2); + assert!(top.is_some() && guides.iter().all(|g| g.layer == top), "{guides:?}"); } /// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, @@ -4276,10 +4426,10 @@ fn ruler_guides_live_on_layers() { let mut s = session(); let first = s.doc().unwrap().current_layer().unwrap(); s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(first), "on the current layer"); let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(second), "the new layer is current"); assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err()); // Hidden or locked with its layer. s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); @@ -4290,12 +4440,12 @@ fn ruler_guides_live_on_layers() { s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap(); // Moved to the first layer, then back. s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(first)); s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err()); // Several at once, as dragging their rows onto a layer's row does. s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first))); + assert!(s.doc().unwrap().doc.ruler_guides().iter().all(|g| g.layer == Some(first))); s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); // Deleting a layer deletes its guides; undo brings them back. @@ -4331,7 +4481,7 @@ fn guides_on_hidden_or_locked_layers_are_left_alone() { s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap(); // The current layer locked: the new guide goes on the top shown, unlocked layer. s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[2].layer, Some(first)); assert_eq!(guides(&s).len(), 3); } @@ -4344,7 +4494,7 @@ fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() { s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::>(); + let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).iter().map(|g| (g.vertical, g.pos)).collect::>(); // Duplicate Layer copies its guides onto the copy. let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap(); assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)])); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index b9ccac12d..2930bf172 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -1086,6 +1086,43 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() { assert!(matches!(selected(&s).kind, NodeKind::Path { live: Some(_), .. })); } +/// Expand refuses an option of the wrong type with an error naming it, and leaves the art, the +/// selection and the undo history as they were. Null counts as an option left out, which keeps its +/// default. +#[test] +fn expand_refuses_an_option_of_the_wrong_type() { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.revision, st.history.undo.len(), st.history.redo.len(), st.selection.objects.clone()) + }; + let before = (state(&s), node(&s, id)); + let switch = |k: &str| format!("`{k}` must be true or false"); + for (p, want) in [ + (json!({"object": "yes"}), switch("object")), + (json!({"object": 0}), switch("object")), + (json!({"fill": "false"}), switch("fill")), + (json!({"stroke": 1}), switch("stroke")), + (json!({"gradient": 3}), "`gradient` must be a string".to_string()), + (json!({"gradient": true}), "`gradient` must be a string".to_string()), + (json!({"steps": "16"}), "`steps` must be a number, not \"16\"".to_string()), + (json!({"steps": [16]}), "`steps` must be a number, not [16]".to_string()), + ] { + let r = s.execute("object.expand", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{p}: {r:?}"); + assert_eq!((state(&s), node(&s, id)), before, "{p}"); + } + // Null options take their defaults: Object, Fill and Stroke on, 255 solid strips. + run(&mut s, "object.expand", json!({"object": null, "fill": null, "stroke": null, "gradient": null, "steps": null})); + let g = selected(&s); + let NodeKind::Group { children, .. } = &g.kind else { panic!("{:?}", g.kind) }; + let (clip, strips) = clip_parts(&children[0]); + assert!(matches!(clip.kind, NodeKind::Path { live: None, .. })); + assert_eq!(strips.len(), 255); + assert!(children[1].appearance.stroke_paint().is_none(), "outlined stroke"); +} + /// A selected open line with a 2 pt black stroke, no fill and a Zig Zag effect (#3089). fn zig_zag_line(s: &mut Session) -> NodeId { let id = NodeId(run(s, "path.create", json!({"d": "M 20 100 L 180 100"}))["id"].as_u64().unwrap()); diff --git a/crates/engine/src/tests/opacitymask.rs b/crates/engine/src/tests/opacitymask.rs index 206ef7e66..9327bdc02 100644 --- a/crates/engine/src/tests/opacitymask.rs +++ b/crates/engine/src/tests/opacitymask.rs @@ -151,6 +151,59 @@ fn mask_options_already_set_record_no_undo_step() { assert!(s.execute("transparency.setOpacityMask", &json!({"id": a.0})).is_err(), "no flag given"); } +/// Make Opacity Mask, Opacity Mask Options and the New Opacity Masks and page toggles refuse a +/// param of the wrong type with an error naming it, and leave the document, the selection, the +/// undo history and the defaults for new masks as they were. Null counts as a param left out, +/// which keeps its default. +#[test] +fn mask_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let (a, m) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 0.0, 50.0)); + s.execute("select.set", &json!({"ids": [a.0, m.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let (b, c) = (rect(&mut s, 150.0, 100.0), rect(&mut s, 150.0, 50.0)); + s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.revision, history(s), st.selection.objects.clone(), s.new_mask_defaults()) + }; + let before = state(&s); + let switch = |k: &str| format!("`{k}` must be true or false"); + for (cmd, p, want) in [ + ("transparency.makeOpacityMask", json!({"clip": "false"}), switch("clip")), + ("transparency.makeOpacityMask", json!({"clip": 0}), switch("clip")), + ("transparency.makeOpacityMask", json!({"invert": "true"}), switch("invert")), + ("transparency.makeOpacityMask", json!({"invert": [true]}), switch("invert")), + ("transparency.setOpacityMask", json!({"id": a.0, "clip": "no"}), switch("clip")), + ("transparency.setOpacityMask", json!({"id": a.0, "clip": false, "invert": "yes"}), switch("invert")), + ("transparency.setOpacityMask", json!({"id": a.0, "disabled": 1}), switch("disabled")), + ("transparency.setOpacityMask", json!({"id": a.0, "linked": "false"}), switch("linked")), + ("transparency.toggleNewMasksClipping", json!({"value": "true"}), switch("value")), + ("transparency.toggleNewMasksInverted", json!({"value": 1}), switch("value")), + ("transparency.togglePageKnockoutGroup", json!({"value": "on"}), switch("value")), + ("transparency.togglePageIsolatedBlending", json!({"value": 0}), switch("value")), + ] { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); + assert!(state(&s) == before, "{cmd} {p}: nothing changes"); + } + // Null params take their defaults: a new mask clips and is not inverted, a null flag stays as + // it is, and a null value toggles. + let id = s.execute("transparency.makeOpacityMask", &json!({"clip": null, "invert": null})).unwrap()["id"].as_u64().unwrap(); + let made = mask(&s, NodeId(id)).unwrap(); + assert!(made.clip && !made.invert); + s.execute("transparency.setOpacityMask", &json!({"id": a.0, "clip": null, "invert": true, "disabled": null, "linked": null})).unwrap(); + let set = mask(&s, a).unwrap(); + assert!(set.clip && set.invert && !set.disabled && set.linked); + assert!(s.execute("transparency.setOpacityMask", &json!({"id": a.0, "clip": null})).is_err(), "no flag given"); + assert_eq!(s.execute("transparency.toggleNewMasksClipping", &json!({"value": null})).unwrap(), json!({"value": false})); + assert_eq!(s.execute("transparency.toggleNewMasksInverted", &json!({"value": null})).unwrap(), json!({"value": true})); + assert_eq!(s.new_mask_defaults(), (false, true)); + assert_eq!(s.execute("transparency.togglePageKnockoutGroup", &json!({"value": null})).unwrap(), json!({"value": true})); + assert_eq!(s.execute("transparency.togglePageIsolatedBlending", &json!({"value": null})).unwrap(), json!({"value": true})); + assert!(doc(&s).page_knockout && doc(&s).page_isolate); +} + /// #4538: while a mask is edited the Layers panel lists only the mask, so New Layer and New /// Sublayer are refused instead of adding layers no row shows; they work again afterwards. #[test] diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 239d6bee7..7e7204f6e 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -2,7 +2,7 @@ use serde_json::json; use vectorcraft_color::{Color, GradientKind, Paint}; -use vectorcraft_doc::{AppearanceItem, NodeKind}; +use vectorcraft_doc::{AppearanceItem, LayerColor, NodeKind}; use crate::*; @@ -798,7 +798,7 @@ fn copied_artboards_take_a_free_id_and_keep_their_own_guides() { let mut kept = vec![]; for i in [1, 0] { s.execute("artboard.delete", &json!({"index": i})).unwrap(); - kept.push(s.doc().unwrap().doc.guides.iter().map(|g| g.artboard).collect::>()); + kept.push(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.artboard).collect::>()); s.execute("edit.undo", &json!({})).unwrap(); } got.push((cmd, s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::>(), kept)); @@ -1387,6 +1387,128 @@ fn artboard_video_display_options_are_set_in_one_undo_step() { assert_eq!(video(&s, 1), (false, 10.0), "10 is in range"); } +/// Artboard Options' guide label: `artboard.setProps {guideLabel}` takes a layer color's name or +/// `#rrggbb` and null clears it, each change one undo step, also on a locked artboard (whose +/// rectangle stays locked) and for several artboards in one list; a value equal to the artboard's +/// adds no undo step. `artboard.new` takes it, a duplicate and an undone delete keep it, and +/// `inspect_document` reports it. A bad value anywhere in a list changes nothing. +#[test] +fn artboard_guide_labels_are_set_and_cleared_in_one_undo_step() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let label = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].guide_label; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + assert_eq!((label(&s, 0), label(&s, 1)), (None, None), "no label by default"); + let u0 = undo(&s); + s.execute("artboard.setProps", &json!({"index": 1, "guideLabel": "Red"})).unwrap(); + assert_eq!((label(&s, 0), label(&s, 1)), (None, Some(LayerColor::Preset(1))), "only that artboard"); + s.execute("artboard.setProps", &json!({"index": 1, "guideLabel": "#12aBcd"})).unwrap(); + assert_eq!(label(&s, 1), Some(LayerColor::Custom([0x12, 0xab, 0xcd]))); + s.execute("artboard.setProps", &json!({"index": 1, "guideLabel": null})).unwrap(); + assert_eq!(label(&s, 1), None, "null clears it"); + assert_eq!(undo(&s), u0 + 3, "one undo step each"); + for was in [Some(LayerColor::Custom([0x12, 0xab, 0xcd])), Some(LayerColor::Preset(1)), None] { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(label(&s, 1), was); + } + s.execute("edit.redo", &json!({})).unwrap(); + // The same label again, or a list that changes nothing: no undo step. + let u = undo(&s); + s.execute("artboard.setProps", &json!({"index": 1, "guideLabel": "Red"})).unwrap(); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "guideLabel": null}, {"index": 1, "guideLabel": "Red"}]})).unwrap(); + assert_eq!(undo(&s), u, "nothing changed, no undo step"); + // A locked artboard takes a label; its rectangle stays locked. + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + let rect0 = s.doc().unwrap().doc.artboards[0].rect; + s.execute("artboard.setProps", &json!({"index": 0, "guideLabel": "Gold"})).unwrap(); + assert_eq!(label(&s, 0), Some(LayerColor::Preset(15))); + assert!(s.execute("artboard.setProps", &json!({"index": 0, "x": 999, "guideLabel": "Teal"})).is_err()); + assert_eq!((s.doc().unwrap().doc.artboards[0].rect, label(&s, 0)), (rect0, Some(LayerColor::Preset(15)))); + let u = undo(&s); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "guideLabel": "Teal"}, {"index": 1, "guideLabel": "#000000"}]})).unwrap(); + assert_eq!((label(&s, 0), label(&s, 1)), (Some(LayerColor::Preset(11)), Some(LayerColor::Custom([0, 0, 0]))), "each its own"); + assert_eq!(undo(&s), u + 1, "in one undo step"); + let doc = crate::inspect::document(&s); + assert_eq!((doc["artboards"][0]["guideLabel"].clone(), doc["artboards"][1]["guideLabel"].clone()), (json!("Teal"), json!("#000000"))); + s.execute("artboard.setProps", &json!({"index": 1, "guideLabel": null})).unwrap(); + assert!(crate::inspect::document(&s)["artboards"][1].get("guideLabel").is_none(), "no label isn't reported"); + let copy = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(label(&s, copy), Some(LayerColor::Preset(11)), "a duplicate keeps it"); + let made = s.execute("artboard.new", &json!({"guideLabel": "Violet"})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(label(&s, made), Some(LayerColor::Preset(14)), "a new artboard takes it"); + let plain = s.execute("artboard.new", &json!({})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(label(&s, plain), None); + s.execute("artboard.delete", &json!({"index": made})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(label(&s, made), Some(LayerColor::Preset(14)), "undoing a delete brings it back"); + let before = s.doc().unwrap().doc.clone(); + let bads = [ + json!("red"), + json!(" Red"), + json!("#12345"), + json!("#1234567"), + json!("#12345678"), + json!("#fff"), + json!("#gg0000"), + json!(1), + json!([1, 2, 3]), + json!({"Preset": 1}), + json!(true), + ]; + for bad in bads { + assert!(s.execute("artboard.setProps", &json!({"index": 1, "guideLabel": bad})).is_err(), "{bad}"); + assert!(s.execute("artboard.new", &json!({"guideLabel": bad})).is_err(), "{bad}"); + assert!( + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "guideLabel": "Red"}, {"index": 1, "guideLabel": bad}]})).is_err(), + "{bad}" + ); + } + assert!(s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "guideLabel": "Red"}, {"index": 1, "guideLabel": "Red"}]})).is_err()); + assert!(s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "guideLabel": "Red"}, {"index": 99, "guideLabel": "Red"}]})).is_err()); + assert_eq!(*s.doc().unwrap().doc, *before, "a bad value changes nothing"); +} + +/// The native format keeps the guide label, as `{"Preset": n}` or `{"Custom": [r, g, b]}`. An +/// artboard without one is written as before labels could be set, and a preset outside the layer +/// colors in a file reads as no label. Exports leave the label out. +#[test] +fn artboard_guide_labels_round_trip_through_the_native_format() { + let mut s = session(); + rect(&mut s); + s.execute("artboard.new", &json!({})).unwrap(); + let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false); + let before = save(&s); + let plain = (*s.doc().unwrap().doc).clone(); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "guideLabel": "Green"}, {"index": 1, "guideLabel": "#102030"}]})).unwrap(); + let bytes = save(&s); + let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let ab = |i: usize| saved["document"]["artboards"][i]["guide_label"].clone(); + assert_eq!((ab(0), ab(1)), (json!({"Preset": 2}), json!({"Custom": [16, 32, 48]}))); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.artboards, s.doc().unwrap().doc.artboards); + // Screen-only: SVG and PDF exports are the same as without the labels. + let on = (*s.doc().unwrap().doc).clone(); + assert_eq!(vectorcraft_svg::export(&on, &Default::default()), vectorcraft_svg::export(&plain, &Default::default())); + let options = vectorcraft_pdf::PdfOptions { created: Some(0), ..Default::default() }; + assert_eq!(crate::export_pdf(&on, &options).unwrap(), crate::export_pdf(&plain, &options).unwrap()); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "guideLabel": null}, {"index": 1, "guideLabel": null}]})).unwrap(); + assert_eq!(save(&s), before, "without labels, the file is as it was"); + // A file's labels as another writer might put them: a preset past the layer colors reads as + // none, and a label that isn't a layer color at all is refused. + let mut edited = saved.clone(); + edited["document"]["artboards"][0]["guide_label"] = json!({"Preset": 27}); + edited["document"]["artboards"][1]["guide_label"] = json!({"Preset": 26}); + let back = vectorcraft_format::load(&serde_json::to_vec(&edited).unwrap()).unwrap(); + assert_eq!((back.artboards[0].guide_label, back.artboards[1].guide_label), (None, Some(LayerColor::Preset(26)))); + for bad in [json!({"Preset": 256}), json!({"Custom": [1, 2]}), json!("Red"), json!({"Custom": [1, 2, 300]})] { + edited["document"]["artboards"][0]["guide_label"] = bad.clone(); + assert!(vectorcraft_format::load(&serde_json::to_vec(&edited).unwrap()).is_err(), "{bad}"); + } + edited["document"]["artboards"][0]["guide_label"] = json!(null); + let back = vectorcraft_format::load(&serde_json::to_vec(&edited).unwrap()).unwrap(); + assert_eq!(back.artboards[0].guide_label, None); +} + /// The native format keeps Show Video Safe Areas and the Video Ruler Pixel Aspect Ratio. A /// document whose artboards hide the safe areas and have square pixels is written byte for byte as /// before they could be set, a file written without the two keys opens with the safe areas off @@ -1553,12 +1675,12 @@ fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() { assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]})); let st = s.doc().unwrap(); assert_eq!(artboard_names(&s), ["Artboard 2"]); - assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them"); + assert_eq!(st.doc.ruler_guides().len(), 1, "the deleted artboards' guides go with them"); assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays"); assert_eq!(st.history.undo.len(), steps + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]); - assert_eq!(s.doc().unwrap().doc.guides.len(), 3); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); } /// What counts as art: hidden and locked objects, an object straddling two artboards and one diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index a5bd60490..139735706 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -656,13 +656,13 @@ fn simplify_refreshes_changed_path_selection_and_undo() { let kept = NodeId(s.execute("path.create", &json!({"anchors": [{"x": 350, "y": 180}]})).unwrap()["id"].as_u64().unwrap()); let unrelated = rect(&mut s, 400.0, 200.0, 40.0, 30.0); s.edit("selection fixture", |d, sel| { - d.guides.push(vectorcraft_doc::Guide::new(true, 30.0)); + let guide = d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 30.0)); sel.set([kept, id]); sel.anchors.insert(id, [(0, 19)].into_iter().collect()); sel.anchors.insert(kept, [(0, 0)].into_iter().collect()); sel.key = Some(kept); sel.target = Some(id); - sel.guides.push(0); + sel.guides.push(guide); Ok(()) }) .unwrap(); @@ -781,7 +781,7 @@ fn split_into_grid_options() { rect(&mut s, 10.0, 20.0, 110.0, 50.0); let r = s.execute("object.path.splitIntoGrid", &p).unwrap(); let cells: Vec = ids(&r).iter().map(|id| bounds(&s, *id)).collect(); - let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!(r["guides"], guides.len()); (cells, guides) }; @@ -812,13 +812,16 @@ fn split_into_grid_options() { assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 40.0, 60.0), 1e-9) && close(bounds(&s, r[1]), Rect::new(60.0, 0.0, 100.0, 60.0), 1e-9)); let st = s.doc().unwrap(); assert!(r.iter().all(|id| node(&s, *id).appearance == node(&s, r[0]).appearance) && st.doc.node(back).is_none() && st.doc.node(front).is_none()); - assert_eq!(st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(), [kept, r[0], r[1]], "above the path left alone"); + let art: Vec = st.doc.layers[0].children().unwrap().iter().filter(|n| !n.is_ruler_guide()).map(|n| n.id).collect(); + assert_eq!(art, [kept, r[0], r[1]], "above the path left alone"); + let children = st.doc.layers[0].children().unwrap(); + assert!(children.len() == 9 && children[3..].iter().all(|n| n.is_ruler_guide()), "the six guides are above the art"); assert_eq!(st.selection.objects, r); - assert!(st.doc.guides.iter().all(|g| g.layer == Some(st.doc.layers[0].id))); + assert!(st.doc.ruler_guides().iter().all(|g| g.layer == Some(st.doc.layers[0].id))); assert_eq!(st.history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); let st = s.doc().unwrap(); - assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.guides.is_empty()); + assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.ruler_guides().is_empty()); // A grid with no room for its gutters is refused and changes nothing. assert!(s.execute("object.path.splitIntoGrid", &json!({"columns": 4, "gutter": 50})).is_err()); assert!(s.execute("object.path.splitIntoGrid", &json!({"width": -5})).is_err()); @@ -844,6 +847,71 @@ fn clean_up_removes_junk() { assert!(d.node(unpainted).is_none() && d.node(stray).is_none() && d.node(empty).is_none()); } +/// Offset Path, Simplify, Split Into Grid and Clean Up refuse a param of the wrong type with an +/// error naming the param, and leave the document, the selection and the undo history as they were. +/// Null keeps a param's default, and a distance still takes a string with a unit. +#[test] +fn path_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + s.execute("path.create", &json!({"anchors": [{"x": 5, "y": 5}]})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.selection.objects.clone(), st.history.undo.len()) + }; + let before = state(&s); + let number = |k: &str, v: &str| format!("`{k}` must be a number, not {v}"); + let switch = |k: &str| format!("`{k}` must be true or false"); + for (cmd, p, want) in [ + ("object.path.offsetPath", json!({"offset": true}), number("offset", "true")), + ("object.path.offsetPath", json!({"offset": "wide"}), number("offset", "\"wide\"")), + ("object.path.offsetPath", json!({"offset": "10abc"}), number("offset", "\"10abc\"")), + ("object.path.offsetPath", json!({"offset": 5, "miterLimit": "4x"}), number("miterLimit", "\"4x\"")), + ("object.path.offsetPath", json!({"offset": 5, "joins": 3}), "`joins` must be a string".to_string()), + ("object.path.offsetPath", json!({"offset": 5, "joins": "pointy"}), "`joins` must be one of miter, round, bevel".to_string()), + ("object.path.offsetPath", json!({"offset": 5, "miterLimit": [4]}), number("miterLimit", "[4]")), + ("object.path.simplify", json!({"auto": "true"}), switch("auto")), + ("object.path.simplify", json!({"level": false}), number("level", "false")), + ("object.path.simplify", json!({"level": 50, "tolerance": {}}), number("tolerance", "{}")), + ("object.path.simplify", json!({"cornerAngle": "sharp"}), number("cornerAngle", "\"sharp\"")), + ("object.path.simplify", json!({"straightLines": 1}), switch("straightLines")), + ("object.path.simplify", json!({"wholePaths": "no"}), switch("wholePaths")), + ("object.path.splitIntoGrid", json!({"rows": "many"}), number("rows", "\"many\"")), + ("object.path.splitIntoGrid", json!({"columns": true}), number("columns", "true")), + ("object.path.splitIntoGrid", json!({"gutter": false}), number("gutter", "false")), + ("object.path.splitIntoGrid", json!({"rowGutter": 2, "gutter": "x"}), number("gutter", "\"x\"")), + ("object.path.splitIntoGrid", json!({"height": 5, "totalHeight": [1]}), number("totalHeight", "[1]")), + ("object.path.splitIntoGrid", json!({"width": "wide"}), number("width", "\"wide\"")), + ("object.path.splitIntoGrid", json!({"columnGutter": true}), number("columnGutter", "true")), + ("object.path.splitIntoGrid", json!({"totalWidth": {}}), number("totalWidth", "{}")), + ("object.path.splitIntoGrid", json!({"rowGutter": "a"}), number("rowGutter", "\"a\"")), + ("object.path.splitIntoGrid", json!({"addGuides": "yes"}), switch("addGuides")), + ("object.path.cleanUp", json!({"strayPoints": "false"}), switch("strayPoints")), + ("object.path.cleanUp", json!({"unpaintedObjects": 0}), switch("unpaintedObjects")), + ("object.path.cleanUp", json!({"emptyTextPaths": "no"}), switch("emptyTextPaths")), + ] { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); + assert!(state(&s) == before, "{cmd} {p} changed the document, the selection or the undo history"); + } + // Null takes the default: a 10 pt offset with mitered joins. + let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": null, "joins": null, "miterLimit": null})).unwrap()); + assert!(close(bounds(&s, r[0]), Rect::new(90.0, 90.0, 160.0, 150.0), 1e-2), "{:?}", bounds(&s, r[0])); + // A distance with a unit, and a join named in capitals. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": "0.1 in", "joins": "Bevel"})).unwrap()); + assert!(close(bounds(&s, r[0]), Rect::new(92.8, 92.8, 157.2, 147.2), 1e-2), "{:?}", bounds(&s, r[0])); + // Null switches keep Clean Up's defaults, which remove the stray point. + let r = s.execute("object.path.cleanUp", &json!({"strayPoints": null, "unpaintedObjects": null, "emptyTextPaths": null})).unwrap(); + assert_eq!(r["removed"], 1); + // Split Into Grid with null counts and a gutter with a unit: two by two, 0.5 in apart. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = ids(&s.execute("object.path.splitIntoGrid", &json!({"rows": null, "columns": null, "gutter": "0.5 in", "addGuides": null})).unwrap()); + assert_eq!(r.len(), 4); + assert!(close(bounds(&s, r[0]), Rect::new(100.0, 100.0, 107.0, 102.0), 1e-9), "{:?}", bounds(&s, r[0])); +} + #[test] fn divide_objects_below_cuts() { let mut s = session(); diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index c36d84fd9..0b948242d 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -1112,3 +1112,184 @@ fn mirror_axis_hidden_controls_and_other_repeat_kinds_do_not_capture_drags() { assert_eq!(node(&s, id), before); } } + +/// The document, its revision, the undo and redo stacks and the selection: what a refused call +/// must leave as it was. +fn untouched(s: &Session) -> (std::sync::Arc, u64, (usize, usize, bool), Vec) { + let st = s.doc().unwrap(); + (st.doc.clone(), st.revision, crate::tests::history(s), st.selection.objects.to_vec()) +} + +/// Each call in `calls` is refused with the parameter error `want` and changes nothing. +fn refused(s: &mut Session, calls: Vec<(&str, Value, String)>) { + let before = untouched(s); + for (cmd, p, want) in calls { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}, want {want:?}"); + assert!(untouched(s) == before, "{cmd} {p}: nothing changes"); + } +} + +fn number(k: &str, v: Value) -> String { + format!("`{k}` must be a number, not {v}") +} + +fn switch(k: &str) -> String { + format!("`{k}` must be true or false") +} + +fn string(k: &str) -> String { + format!("`{k}` must be a string") +} + +/// Radial, Grid, Mirror and Repeat Options refuse a param of the wrong type with an error naming +/// it, and change nothing; a distance given as text with a unit is refused like any other string. +/// Null counts as a param left out, which keeps its default. +#[test] +fn repeat_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let r = rect(&mut s, 100.0, 100.0, 60.0, 80.0); + sel(&mut s, &[r]); + refused( + &mut s, + vec![ + ("object.repeat.radial", json!({"instances": "8"}), number("instances", json!("8"))), + ("object.repeat.radial", json!({"radius": true}), number("radius", json!(true))), + ("object.repeat.radial", json!({"radius": "5 mm"}), number("radius", json!("5 mm"))), + ("object.repeat.radial", json!({"radius": -5}), "`radius` must be a number not below 0".into()), + ("object.repeat.grid", json!({"hSpacing": "5 mm"}), number("hSpacing", json!("5 mm"))), + ("object.repeat.grid", json!({"vSpacing": [1]}), number("vSpacing", json!([1]))), + ("object.repeat.grid", json!({"rows": "3"}), number("rows", json!("3"))), + ("object.repeat.grid", json!({"cols": false}), number("cols", json!(false))), + ("object.repeat.mirror", json!({"angle": "45"}), number("angle", json!("45"))), + ("object.repeat.mirror", json!({"offset": {}}), number("offset", json!({}))), + ], + ); + // Null params keep the defaults: each repeat matches the one made with `{}`. + for (cmd, nulls) in [ + ("object.repeat.radial", json!({"instances": null, "radius": null})), + ("object.repeat.grid", json!({"hSpacing": null, "vSpacing": null, "rows": null, "cols": null})), + ("object.repeat.mirror", json!({"angle": null, "offset": null})), + ] { + let made = |p: &Value| { + let mut s = session(); + let r = rect(&mut s, 100.0, 100.0, 60.0, 80.0); + sel(&mut s, &[r]); + let id = id_of(&s.execute(cmd, p).unwrap()); + node(&s, id) + }; + assert_eq!(made(&nulls), made(&json!({})), "{cmd}"); + } + // A grid's rows and cols take any number, as Repeat Options does: 2.5 rows are 2. + let id = id_of(&s.execute("object.repeat.grid", &json!({"rows": 2.5, "cols": 4})).unwrap()); + assert!(matches!(node(&s, id).kind, NodeKind::Repeat(ref r) if matches!(r.kind, RepeatKind::Grid { rows: 2, cols: 4, .. }))); + // `brickOffset` alone sets the offset of a brick grid, and leaves a plain grid as it is. + let grid_type = |s: &Session| match node(s, id).kind { + NodeKind::Repeat(r) => match r.kind { + RepeatKind::Grid { grid_type, .. } => grid_type, + other => panic!("not a grid: {other:?}"), + }, + other => panic!("not a repeat: {other:?}"), + }; + s.execute("object.repeat.options", &json!({"brickOffset": 0.25})).unwrap(); + assert_eq!(grid_type(&s), TileType::Grid); + s.execute("object.repeat.options", &json!({"gridType": "brickByRow"})).unwrap(); + s.execute("object.repeat.options", &json!({"brickOffset": 0.25})).unwrap(); + assert_eq!(grid_type(&s), TileType::BrickByRow { offset: 0.25 }); + + // Repeat Options checks every param, whichever kind the selected repeat is. + s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); + let kind = |s: &Session| match node(s, id).kind { + NodeKind::Repeat(r) => r.kind, + other => panic!("not a repeat: {other:?}"), + }; + let radial = kind(&s); + refused( + &mut s, + vec![ + ("object.repeat.options", json!({"instances": "4"}), number("instances", json!("4"))), + ("object.repeat.options", json!({"radius": "50 pt"}), number("radius", json!("50 pt"))), + ("object.repeat.options", json!({"startAngle": true}), number("startAngle", json!(true))), + ("object.repeat.options", json!({"endAngle": "x"}), number("endAngle", json!("x"))), + ("object.repeat.options", json!({"hSpacing": "1"}), number("hSpacing", json!("1"))), + ("object.repeat.options", json!({"vSpacing": [1]}), number("vSpacing", json!([1]))), + ("object.repeat.options", json!({"rows": "2"}), number("rows", json!("2"))), + ("object.repeat.options", json!({"cols": [2]}), number("cols", json!([2]))), + ("object.repeat.options", json!({"brickOffset": "0.5"}), number("brickOffset", json!("0.5"))), + ("object.repeat.options", json!({"angle": "10"}), number("angle", json!("10"))), + ("object.repeat.options", json!({"reverseOverlap": "true"}), switch("reverseOverlap")), + ("object.repeat.options", json!({"flipRows": 1}), switch("flipRows")), + ("object.repeat.options", json!({"flipCols": "no"}), switch("flipCols")), + ("object.repeat.options", json!({"gridType": 3}), string("gridType")), + ("object.repeat.options", json!({"center": [1]}), "center must be [x, y] with finite coordinates".into()), + ("object.repeat.options", json!({"center": "0, 0"}), "center must be [x, y] with finite coordinates".into()), + ], + ); + let all_null = json!({"instances": null, "radius": null, "startAngle": null, "endAngle": null, "reverseOverlap": null, "center": null}); + s.execute("object.repeat.options", &all_null).unwrap(); + assert_eq!(kind(&s), radial, "null params keep the options"); + s.execute("object.repeat.options", &json!({"instances": 6, "reverseOverlap": true, "center": [10, 20]})).unwrap(); + assert!(matches!(kind(&s), RepeatKind::Radial { instances: 6, reverse_overlap: true, .. })); +} + +/// Make, Edit Pattern, Save a Copy, Pattern Options and Delete Pattern refuse a param of the wrong +/// type (and Pattern Options an `overlap` side it doesn't know) with an error naming it, and change +/// nothing. Null counts as a param left out, which keeps its default. +#[test] +fn pattern_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let r = make_dots(&mut s); + sel(&mut s, &[r]); + let options = |p: Value| { + let mut p = p; + p["name"] = json!("Dots"); + p + }; + refused( + &mut s, + vec![ + ("object.pattern.make", json!({"name": 5}), string("name")), + ("object.pattern.make", json!({"tileType": 2}), string("tileType")), + ("object.pattern.make", json!({"brickOffset": "0.5"}), number("brickOffset", json!("0.5"))), + ("object.pattern.make", json!({"width": "20"}), number("width", json!("20"))), + ("object.pattern.make", json!({"height": true}), number("height", json!(true))), + ("object.pattern.make", json!({"edit": "false"}), switch("edit")), + ("object.pattern.edit", json!({"name": 5}), string("name")), + ("pattern.options", json!({"name": ["Dots"]}), string("name")), + ("pattern.options", options(json!({"newName": 5})), string("newName")), + ("pattern.options", options(json!({"tileType": []})), string("tileType")), + ("pattern.options", options(json!({"width": "20"})), number("width", json!("20"))), + ("pattern.options", options(json!({"hSpacing": "1"})), number("hSpacing", json!("1"))), + ("pattern.options", options(json!({"vSpacing": true})), number("vSpacing", json!(true))), + ("pattern.options", options(json!({"copies": "5"})), number("copies", json!("5"))), + ("pattern.options", options(json!({"dimCopies": "50"})), number("dimCopies", json!("50"))), + ("pattern.options", options(json!({"sizeTileToArt": "true"})), switch("sizeTileToArt")), + ("pattern.options", options(json!({"showTileEdge": 1})), switch("showTileEdge")), + ("pattern.options", options(json!({"showSwatchBounds": "no"})), switch("showSwatchBounds")), + ("pattern.options", options(json!({"overlap": "right"})), "`overlap` must be an object, not \"right\"".into()), + ("pattern.options", options(json!({"overlap": {"h": "center"}})), "`overlap.h` must be one of left, right".into()), + ("pattern.options", options(json!({"overlap": {"v": 1}})), "`overlap.v` must be one of top, bottom".into()), + ("pattern.delete", json!({"name": 5}), string("name")), + ], + ); + // Null params keep the defaults: a new pattern named "New Pattern 1", a grid tile the size of + // the art, edited. + let made = s + .execute("object.pattern.make", &json!({"name": null, "tileType": null, "brickOffset": null, "width": null, "height": null, "edit": null})) + .unwrap(); + assert_eq!(made["name"], "New Pattern 1"); + let def = doc(&s).pattern("New Pattern 1").unwrap().clone(); + assert_eq!(def.tile_type, TileType::Grid); + assert!((def.tile.width() - 10.0).abs() < 1e-6, "{:?}", def.tile); + assert_eq!(doc(&s).pattern_edit.as_ref().map(|e| e.pattern.as_str()), Some("New Pattern 1")); + // In editing mode: Save a Copy refuses a name that isn't a string, and Pattern Options with + // null params keeps the edited pattern's options. + refused(&mut s, vec![("object.pattern.saveCopy", json!({"name": 5}), string("name"))]); + s.execute("pattern.options", &json!({"name": null, "overlap": {"h": null, "v": null}, "copies": null, "sizeTileToArt": null})).unwrap(); + assert!(doc(&s).pattern("New Pattern 1").unwrap() == &def); + s.execute("pattern.options", &json!({"overlap": {"h": "right"}})).unwrap(); + assert!(doc(&s).pattern("New Pattern 1").unwrap().overlap.right_in_front); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": null})).unwrap()["name"], "New Pattern 1 copy"); + s.execute("object.pattern.done", &json!({})).unwrap(); + assert!(s.execute("pattern.delete", &json!({"name": null})).is_err(), "no name given"); +} diff --git a/crates/engine/src/tests/pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs index eee88c5d1..7de08463f 100644 --- a/crates/engine/src/tests/pdfpresets.rs +++ b/crates/engine/src/tests/pdfpresets.rs @@ -123,6 +123,46 @@ fn presets_export_and_import_round_trip() { assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export"); } +/// `longPagesInPoints` is saved with a preset, comes back through a presets file and the +/// preferences, shows in `document.pdfSettings`, and a 10 m page exported with it reopens at 10 m. +/// A value that isn't a boolean is refused. +#[test] +fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 28_346.456_692_913_386, "height": 2_834.645_669_291_338_5})).unwrap(); + s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": true, "preserveEditing": false})).unwrap(); + assert!(s.prefs.pdf_presets[0].settings.long_pages_in_points); + let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.pdf_presets, s.prefs.pdf_presets); + let text = s.execute("pdf.preset.export", &json!({"names": ["Banner"]})).unwrap()["data"].as_str().unwrap().to_string(); + let mut t = session(); + t.execute("pdf.preset.import", &json!({"data": text})).unwrap(); + assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets); + let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": true})).unwrap(); + assert_eq!((&v["settings"]["longPagesInPoints"], &v["changed"]), (&json!(true), &json!([{"option": "longPagesInPoints", "value": true}])), "{v}"); + assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(false)); + // The preset writes the page in points; the file reopens at 10 m. + let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); + let bytes = pdf(&v); + assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); + assert!( + v["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w.as_str().unwrap().ends_with("written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in")), + "{v}" + ); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + let rect = o.doc().unwrap().doc.artboards[0].rect; + assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + for bad in [json!("yes"), json!(1)] { + assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}"); + assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Bad", "longPagesInPoints": bad}))), "{bad}"); + } +} + #[test] fn saved_presets_persist_with_the_preferences() { let mut s = session(); diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index 6a6022fdb..0ce9b0bf0 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -262,6 +262,45 @@ fn snap_moves_objects_at_different_depths_by_one_plane_offset() { assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}"); } +/// The anchors of `flat` and `far` after a snapped move of both on the left plane, where `flat` +/// starts flat (attached by the move, or first with `attach_first`) and `far` sits 50 pt deeper. +fn move_with_a_flat_first_object(attach_first: bool, constrain: bool) -> Vec { + let (mut s, _) = persp_session(); + let rect = |s: &mut Session, x: f64| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) + }; + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap(); + let far = rect(&mut s, 150.0); + s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap(); + let flat = rect(&mut s, 240.0); + if attach_first { + s.execute("perspective.attach", &json!({"ids": [flat.0], "plane": "left"})).unwrap(); + } + s.execute("perspective.grid.snap", &json!({"on": true})).unwrap(); + let h = grid(&s).homography_at(Plane::Left, 0.0).unwrap(); + let (from, to) = (h.apply(Point::new(100.0, 100.0)).unwrap(), h.apply(Point::new(137.3, 121.7)).unwrap()); + s.execute( + "perspective.move", + &json!({"ids": [flat.0, far.0], "plane": "left", "from": [from.x, from.y], "to": [to.x, to.y], "constrain": constrain}), + ) + .unwrap(); + assert_eq!(attached(&s, flat).map(|a| a.0), Some(Plane::Left)); + let doc = &s.doc().unwrap().doc; + [flat, far].iter().flat_map(|id| doc.node(*id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect::>()).collect() +} + +#[test] +fn snap_reads_a_flat_first_object_as_attached_by_the_move() { + for constrain in [false, true] { + let (moved, attached_first) = (move_with_a_flat_first_object(false, constrain), move_with_a_flat_first_object(true, constrain)); + assert_eq!(moved.len(), attached_first.len()); + for (a, b) in moved.iter().zip(&attached_first) { + assert!(a.distance(*b) < 1e-6, "constrain {constrain}: {moved:?} vs {attached_first:?}"); + } + } +} + #[test] fn constrained_move_uses_each_objects_plane() { let (mut s, right) = persp_session(); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a705f05b9..a4be6e554 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -13,6 +13,11 @@ use crate::*; const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; +/// Held by the tests that set the Additional Plug-ins Folder preference or run `plugin.reload`: +/// the folder last loaded and its report are kept for the whole process, so another test loading +/// its own folder between two steps would change what the next step loads or lists. +static PREFS_FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } @@ -980,6 +985,174 @@ fn painted_pieces_keep_to_the_effects_budget() { assert!((350..=100_000).contains(&paths), "{paths} paths"); } +/// A clock for a document's deferrals that reads `step` milliseconds later each time it is read; +/// the test changes the step as it goes. +fn stepping_clock(step: &std::sync::Arc) -> vectorcraft_plugins::deferred::Clock { + let (now, step) = (std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), step.clone()); + std::sync::Arc::new(move || Some(now.fetch_add(step.load(std::sync::atomic::Ordering::Relaxed), std::sync::atomic::Ordering::Relaxed))) +} + +/// While a document is drawn on screen, the plug-in effects of its first view share a time budget. +/// A stage left out draws its input art, is listed by `effect.deferredStatus`, and stays left out +/// on later drawings; Effect › Refresh Deferred Effects runs it again with a fresh budget, and an +/// export draws every stage. Another document has a budget of its own. +#[test] +fn the_first_view_defers_plug_in_effects_past_its_budget() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 30.0, "#ff0000"); + for (id, x) in [("test.engine.deferred.a", 100.0), ("test.engine.deferred.b", 140.0)] { + let out = json!({"objects": [{"path": square_at(x)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest(id, "effect", "{}"), &out)); + } + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.a"})); + run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "plugin.test.engine.deferred.b"})); + // Each reading is 3 s later: the first run's result comes back after the budget, and the + // second run doesn't start. + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + let img = fx.view(|| drawn(&s)); + fx.first_view_done(); + assert_eq!((img.pixel(15, 15), img.pixel(35, 15), img.pixel(110, 50), img.pixel(150, 50)), (RED, RED, WHITE, WHITE), "the input art"); + assert_eq!(fx.take_report(), Some(2)); + let status = run(&mut s, "effect.deferredStatus", json!({})); + let listed = |id: NodeId, plugin: &str| json!({"objectId": id.0, "effectIndex": 0, "pluginId": plugin, "reason": "budget"}); + assert_eq!(status, json!({"budgetMs": 2000, "effects": [listed(a, "test.engine.deferred.a"), listed(b, "test.engine.deferred.b")]})); + assert_eq!(fx.view(|| drawn(&s)).pixel(110, 50), WHITE, "a later drawing leaves it deferred"); + + // Refreshed with a fresh budget, in which the clock stands still. + assert!(s.execute("effect.refreshDeferred", &json!({"ids": ["a"]})).is_err()); + assert!(s.execute("effect.refreshDeferred", &json!({"ids": [999_999]})).is_err(), "not an object of the document"); + step.store(0, std::sync::atomic::Ordering::Relaxed); + let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0]})); + assert_eq!(refreshed, json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + let img = fx.view(|| drawn(&s)); + assert_eq!((img.pixel(110, 50), img.pixel(150, 50)), (RED, WHITE)); + assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([listed(b, "test.engine.deferred.b")])); + + // An export draws every stage; the view then uses the result. + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(svg.contains(r#">(); + assert_eq!(places, [(a.0, None, 1), (a.0, None, 2), (b.0, Some(0), 1), (b.0, Some(0), 2)]); +} + +/// A refresh that does not draw a stage (its object, or a group holding it, is hidden) leaves it +/// deferred, and a stage whose effect has been removed or hidden, or whose object has been removed, +/// is no longer listed and does not enable Effect › Refresh Deferred Effects. +#[test] +fn refresh_keeps_stages_it_did_not_draw_and_drops_removed_ones() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 30.0, "#ff0000"); + let c = rect(&mut s, 50.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.kept", "effect", "{}"), &out)); + run(&mut s, "effect.apply", json!({"ids": [a.0, b.0, c.0], "effect": "plugin.test.engine.deferred.kept"})); + run(&mut s, "select.set", json!({"ids": [c.0]})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + fx.view(|| drawn(&s)); + fx.first_view_done(); + let listed = |s: &mut Session| { + run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::>() + }; + assert_eq!(listed(&mut s), [a.0, b.0, c.0]); + + s.execute("object.hide", &json!({"ids": [a.0, g.0]})).unwrap(); + step.store(0, std::sync::atomic::Ordering::Relaxed); + let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0, c.0]})); + assert_eq!(refreshed, json!({"refreshed": 0, "stillDeferred": 2, "failed": 0}), "a hidden object is not drawn"); + assert_eq!(listed(&mut s), [a.0, b.0, c.0]); + + run(&mut s, "effect.remove", json!({"ids": [a.0], "index": 0})); + assert_eq!(listed(&mut s), [b.0, c.0], "its effect was removed"); + run(&mut s, "effect.setParams", json!({"ids": [b.0], "index": 0, "visible": false})); + assert_eq!(listed(&mut s), [c.0], "its effect was hidden"); + s.execute("edit.clear", &json!({"ids": [b.0, g.0]})).unwrap(); + assert_eq!(listed(&mut s), Vec::::new(), "its object was deleted"); + assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred"); +} + +/// A deferred stage in a symbol's art is listed by the object in that art, and a refresh runs it +/// where the symbol is placed. +#[test] +fn deferred_stages_in_symbol_art_are_listed_and_refreshed() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.symbol", "effect", "{}"), &out)); + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.symbol"})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let instance = run(&mut s, "symbol.new", json!({"name": "Deferred"}))["id"].as_u64().unwrap(); + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + fx.view(|| drawn(&s)); + fx.first_view_done(); + assert_eq!(fx.take_report(), Some(1)); + let effects = run(&mut s, "effect.deferredStatus", json!({}))["effects"].clone(); + let object = effects[0]["objectId"].as_u64().unwrap(); + assert_eq!(effects.as_array().map(Vec::len), Some(1), "{effects}"); + assert!(object != instance && s.doc().unwrap().doc.node(NodeId(object)).is_none(), "an object of the symbol's art: {effects}"); + + step.store(0, std::sync::atomic::Ordering::Relaxed); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([])); +} + /// `CRAFT_PLUGIN_PAINTS_WASM=` saves a test live effect to try in the app /// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its /// object's stroke is 5 pt wide, and hands the geometry back otherwise. @@ -1110,6 +1283,7 @@ fn filters_keep_their_data_with_the_document() { #[test] fn plugin_management_commands() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); install(&mut s, DESATURATE); let list = run(&mut s, "plugin.list", json!({})); @@ -1147,6 +1321,26 @@ fn plugin_management_commands() { std::fs::remove_dir_all(&dir).ok(); } +/// Clearing the Additional Plug-ins Folder forgets the folder: setting the same folder again +/// installs what is in it, as setting any other folder does. +#[test] +fn plugins_folder_set_again_after_clearing_installs_its_plugins() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let dir = crate::tests_links::Folder::new("plugin-folder-cleared"); + let id = "test.engine.foldercleared"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(dir.0.join("cleared.wasm"), wat::parse_str(src).unwrap()).unwrap(); + let mut s = session(); + let folder = json!({"key": "pluginsFolder", "value": dir.0.display().to_string()}); + run(&mut s, "prefs.set", folder.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed from the folder"); + run(&mut s, "plugin.remove", json!({"id": id})); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": ""})); + run(&mut s, "prefs.set", folder); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again when the folder is set again"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] @@ -1811,6 +2005,7 @@ fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() { /// the plug-in store stays, with the stored module it covers. #[test] fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderstale"; let empty = json!({"objects": []}); @@ -1988,6 +2183,7 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { /// grants held by the process. #[test] fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderbytes"; let plain = "test.engine.permissions.folderplain"; @@ -2216,6 +2412,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { /// until it is granted on its own. #[test] fn granted_permissions_are_kept_and_folders_skip_the_rest() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grants"); let id = "test.engine.permissions.stored"; let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); @@ -2304,6 +2501,7 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { /// covers no module. #[test] fn a_grant_covers_only_the_module_it_was_given_to() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); let id = "test.engine.granthash"; let mut s = session(); @@ -3407,3 +3605,29 @@ fn another_module_under_an_id_with_settings_needs_replace() { install(&mut s, json!({})).unwrap(); run(&mut s, "plugin.remove", json!({"id": id})); } + +/// A filter that calls commands runs on the document with the edit in progress kept first: a move +/// still being previewed is its own undo step, and the filter is given the moved rectangle, which +/// its empty output then removes. +#[test] +fn a_filter_that_calls_commands_is_given_the_edit_in_progress() { + let id = "test.engine.callsettle"; + let mut s = session(); + let grant = json!(["doc.read", "doc.write", "commands"]); + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{grant},"commandAllowlist":["select.all"]}}"#); + let wasm = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true})); + let paths = |s: &Session| s.doc().unwrap().doc.layers.first().and_then(|l| l.children()).map_or(0, |c| c.len()); + run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap(); + run(&mut s, "plugin.run", json!({"id": id})); + assert!(!s.in_interaction()); + assert_eq!(paths(&s), 0, "the filter was given the moved rectangle and removed it"); + let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); + assert_eq!(labels, [format!("Test {id}").as_str(), "Move"]); + run(&mut s, "edit.undo", json!({})); + let x = run(&mut s, "document.inspect", json!({}))["layers"][0]["children"][0]["bounds"]["x"].clone(); + assert_eq!(x, json!(17.0), "undoing the filter brings back the moved rectangle"); + run(&mut s, "plugin.remove", json!({"id": id})); +} diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 8e3c043e4..ed37d0969 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -1990,9 +1990,12 @@ fn real_time_drawing_off_still_shows_a_dragged_guide() { set_pref(&mut s, "realTimeDrawing", json!(false)); s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 200.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 200.0), v).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 150.0, "the guide moves while dragged"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 150.0, "the guide moves while dragged"); s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 200.0), v).unwrap(); - assert_eq!((s.doc().unwrap().doc.guides[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), (150.0, Some("Move Guide"))); + assert_eq!( + (s.doc().unwrap().doc.ruler_guides()[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), + (150.0, Some("Move Guide")) + ); } /// With Real-time Drawing off, a 9-slice guide of the edited symbol moved with the Selection tool diff --git a/crates/engine/src/tests/prefs_file.rs b/crates/engine/src/tests/prefs_file.rs new file mode 100644 index 000000000..7693ad0d5 --- /dev/null +++ b/crates/engine/src/tests/prefs_file.rs @@ -0,0 +1,169 @@ +//! Preferences files: `prefs.export` writes the preferences as JSON and `prefs.import` checks and +//! applies such a file. + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; + +use crate::cmd::prefscmds::{PREF_GROUPS, PREF_SPECS, PREFS_FORMAT}; +use crate::*; + +fn is_bad_params(r: Result) -> bool { + matches!(r, Err(EngineError::BadParams { .. })) +} + +/// Folder preferences, which a preferences file leaves out. +const FOLDERS: [&str; 4] = ["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"]; + +/// A preferences file holding `prefs`, as `prefs.export` writes one. +fn file(prefs: Value) -> String { + json!({"format": PREFS_FORMAT, "version": 1, "prefs": prefs}).to_string() +} + +/// A session whose preferences differ from the defaults in every kind of value. +fn changed() -> Session { + let mut s = Session::new(); + let values = json!({ + "showToolTips": false, + "keyboardIncrement": 2.5, + "historyStates": 42, + "uiAccent": "green", + "guideColor": "#12ab34", + "unitsGeneral": "inches", + "eyedropper": {"sampleSize": 5}, + }); + s.execute("prefs.set", &json!({"values": values})).unwrap(); + s +} + +/// The preferences a preferences file holds, read from a session. +fn in_file(s: &Session) -> Value { + let all = s.prefs.to_json(); + let keys = PREF_SPECS.iter().map(|sp| sp.key).filter(|k| !FOLDERS.contains(k)).chain(PREF_GROUPS.iter().copied()); + Value::Object(keys.map(|k| (k.to_string(), all[k].clone())).collect()) +} + +#[test] +fn exported_preferences_import_unchanged() { + let mut s = changed(); + s.prefs.fonts_folder = "/fonts/here".into(); + let r = s.execute("prefs.export", &json!({})).unwrap(); + let text = r["data"].as_str().unwrap().to_string(); + let written: Value = serde_json::from_str(&text).unwrap(); + assert_eq!((written["format"].as_str(), written["version"].as_u64()), (Some(PREFS_FORMAT), Some(1))); + let prefs = written["prefs"].as_object().unwrap(); + assert_eq!(r["count"], json!(prefs.len())); + assert_eq!(prefs.len(), PREF_SPECS.len() - FOLDERS.len() + PREF_GROUPS.len()); + for k in FOLDERS { + assert!(!prefs.contains_key(k), "{k} is left out"); + } + assert!(!prefs.contains_key("printPresets"), "saved presets are left out"); + assert_eq!(prefs["uiAccent"], "green"); + + // Into a session with defaults and a document in millimeters. + let mut t = Session::new(); + t.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + t.execute("document.setUnits", &json!({"units": "Millimeters"})).unwrap(); + let undo = t.doc().unwrap().history.undo.len(); + let r = t.execute("prefs.import", &json!({"data": text})).unwrap(); + assert_eq!(r["imported"], json!(prefs.len())); + assert_eq!(in_file(&t), in_file(&s), "export then import gives the same preferences"); + assert_eq!(t.prefs.units_general, "inches"); + assert_eq!(t.doc().unwrap().doc.units, Unit::Millimeters, "the document's units stay as they are"); + assert_eq!(t.doc().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(t.prefs.fonts_folder, "", "folders are not carried over"); + + // Through a file, too. + let path = std::env::temp_dir().join(format!("vc-prefs-{}.json", std::process::id())).to_string_lossy().to_string(); + assert_eq!(s.execute("prefs.export", &json!({"path": path})).unwrap()["path"], json!(path)); + let mut u = Session::new(); + u.execute("prefs.import", &json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(in_file(&u), in_file(&s)); +} + +/// A file written by hand, not by `prefs.export`: so the two sides cannot agree on a wrong layout. +#[test] +fn a_hand_written_file_sets_what_it_names_and_leaves_the_rest() { + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "historyStates", "value": 77})).unwrap(); + let text = r##"{ + "format": "vectorcraft-preferences", + "version": 1, + "prefs": {"uiAccent": "Violet", "keyboardIncrement": "3 pt", "guideColor": "#ABCDEF", "pathfinder": {}} + }"##; + assert_eq!(s.execute("prefs.import", &json!({"data": text})).unwrap(), json!({"imported": 4})); + assert_eq!(s.prefs.ui_accent, "violet"); + assert_eq!(s.prefs.keyboard_increment, 3.0); + assert_eq!(s.prefs.guide_color, "#abcdef"); + assert_eq!(s.prefs.history_states, 77, "a preference the file leaves out keeps its value"); +} + +#[test] +fn a_bad_preferences_file_is_refused_and_nothing_changes() { + let mut s = changed(); + let before = s.prefs.to_json().to_string(); + let too_large = " ".repeat((1 << 20) + 1); + let cases: Vec<(Value, &str)> = vec![ + (json!({"data": ""}), "not a preferences file"), + (json!({"data": "{\"format\": "}), "not a preferences file"), + (json!({"data": "[]"}), "not a preferences file: no JSON object"), + (json!({"data": "{\"version\": 1, \"prefs\": {}}"}), "no `format`"), + (json!({"data": "{\"format\": \"vcprintpresets\", \"version\": 1, \"prefs\": {}}"}), "(format `vcprintpresets`)"), + (json!({"data": "{\"format\": \"vectorcraft-preferences\", \"prefs\": {\"uiAccent\": \"blue\"}}"}), "no `version`"), + (json!({"data": "{\"format\": \"vectorcraft-preferences\", \"version\": 0, \"prefs\": {\"uiAccent\": \"blue\"}}"}), "no `version`"), + (json!({"data": file(json!({"uiAccent": "blue"})).replace("\"version\":1", "\"version\":2")}), "newer version of VectorCraft (version 2)"), + (json!({"data": "{\"format\": \"vectorcraft-preferences\", \"version\": 1}"}), "no `prefs`"), + (json!({"data": file(json!({}))}), "holds no preferences"), + (json!({"data": file(json!({"uiAccent": "blue", "noSuchPreference": 1}))}), "unknown preference `noSuchPreference`"), + (json!({"data": file(json!({"uiAccent": "blue", "showToolTips": 3}))}), "`showToolTips` must be true or false"), + (json!({"data": file(json!({"uiAccent": "blue", "historyStates": 5000}))}), "`historyStates` must be a whole number between 5 and 1000"), + (json!({"data": file(json!({"uiAccent": "blue", "eyedropper": 5}))}), "`eyedropper` must be an object"), + (json!({"data": file(json!({"uiAccent": "blue", "fontsFolder": "/fonts"}))}), "`fontsFolder` names a folder"), + (json!({"data": too_large}), "larger than 1 MB"), + (json!({"data": 5}), "`data` must be a string"), + (json!({"path": "/a.json", "data": file(json!({"uiAccent": "blue"}))}), "give `path` or `data`"), + (json!({}), "give `path` or `data`"), + ]; + for (params, says) in cases { + let r = s.execute("prefs.import", ¶ms); + let e = r.as_ref().map_err(ToString::to_string).unwrap_err(); + assert!(e.contains(says), "{params}: {e}"); + assert!(is_bad_params(r), "{params}"); + assert_eq!(s.prefs.to_json().to_string(), before, "{params}: nothing changes"); + } +} + +/// `prefs.import` as a step of a batch is an error, and the preferences stay as they are. +#[test] +fn prefs_import_is_refused_as_a_batch_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let before = s.prefs.to_json(); + let steps = json!({"commands": [{"command": "prefs.import", "params": {"data": file(json!({"uiAccent": "green"}))}}]}); + let e = s.execute("command.batch", &steps).unwrap_err().to_string(); + assert!(e.contains("`prefs.import` runs on its own"), "{e}"); + assert_eq!(s.prefs.to_json(), before); +} + +#[test] +fn a_file_over_the_limit_is_refused_before_it_is_parsed() { + let mut s = Session::new(); + let before = s.prefs.to_json().to_string(); + let path = std::env::temp_dir().join(format!("vc-prefs-large-{}.json", std::process::id())); + // A valid file padded past 1 MB: refused for its size, not read for its preferences. + let mut text = file(json!({"uiAccent": "pink"})); + text.push_str(&" ".repeat(1 << 20)); + std::fs::write(&path, text).unwrap(); + let e = s.execute("prefs.import", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + let _ = std::fs::remove_file(&path); + assert!(e.contains("larger than 1 MB"), "{e}"); + assert_eq!(s.prefs.to_json().to_string(), before); + // Nor is such a file written: a preferences file over 1 MB could not be imported again. + s.prefs.hyphenation_exceptions = "x".repeat(1 << 20); + let e = s.execute("prefs.export", &json!({})).unwrap_err().to_string(); + assert!(e.contains("larger than 1 MB"), "{e}"); + s.prefs.hyphenation_exceptions.clear(); + let missing = std::env::temp_dir().join("vc-prefs-no-such-file.json"); + assert!(s.execute("prefs.import", &json!({"path": missing.to_string_lossy()})).is_err()); + assert_eq!(s.prefs.to_json().to_string(), before); +} diff --git a/crates/engine/src/tests/printadvanced.rs b/crates/engine/src/tests/printadvanced.rs index 99cfcd373..8faa2f7ad 100644 --- a/crates/engine/src/tests/printadvanced.rs +++ b/crates/engine/src/tests/printadvanced.rs @@ -130,6 +130,28 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() { assert!(crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none()); } +/// Print as Bitmap of an artboard longer than the renderer draws at the raster effects resolution +/// (32,768 pixels a side) prints an image of all of it, at a lower resolution. +#[test] +fn print_as_bitmap_prints_all_of_a_long_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 9000, "height": 100})).unwrap(); + s.execute("document.rasterEffectsSettings", &json!({"resolution": 300})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 100})).unwrap(); + let set = settings(json!({"advanced": {"printAsBitmap": true}})); + let saved = s.prefs.flattener_presets.clone(); + let bitmap = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); + let mut placed = vec![]; + bitmap.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + placed.push((im.width, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))))); + } + }); + let [(width, b)] = placed[..] else { panic!("one image: {placed:?}") }; + assert!(width <= vectorcraft_render::MAX_RASTER_SIDE, "{width} pixels"); + assert!(b.x0 <= 0.0 && b.x1 >= 9000.0, "the image covers the artboard: {b:?}"); +} + /// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB /// black when `rich_black` is on. #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 68b8b57ab..d92365a35 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -579,7 +579,7 @@ fn units_and_ruler_guides_survive_save_and_reopen() { s.execute("file.close", &json!({})).unwrap(); s.execute("document.open", &json!({"path": p})).unwrap(); let doc = &s.doc().unwrap().doc; - let guides: Vec<_> = doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<_> = doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!((doc.units, guides), (vectorcraft_doc::Unit::Millimeters, vec![(true, 50.0), (false, 30.0)]), "{ext}"); } let _ = std::fs::remove_dir_all(d); diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs new file mode 100644 index 000000000..27f53cef0 --- /dev/null +++ b/crates/engine/src/tests/script_console.rs @@ -0,0 +1,267 @@ +//! The Script Console: `$.write` and `$.writeln`, `vc.log` lines, the bounded store of a session +//! and of a run, errors with their place, the end and report lines, and the diagnostics a run's +//! report holds. + +use serde_json::{Value, json}; + +use crate::script::console::{CUT_MARK, MAX_CONSOLE_BYTES, MAX_CONSOLE_LINES, MAX_LINE_BYTES, MAX_NAME_BYTES}; +use crate::*; + +fn run(s: &mut Session, source: &str) -> Result { + s.execute("script.run", &json!({"source": source, "name": "t.js"})) +} + +/// The session console's `(run, kind, text)` lines. +fn lines(s: &mut Session) -> Vec<(u64, String, String)> { + let c = s.execute("script.console", &json!({})).unwrap(); + c["lines"] + .as_array() + .unwrap() + .iter() + .map(|l| (l["run"].as_u64().unwrap(), l["kind"].as_str().unwrap().to_string(), l["text"].as_str().unwrap().to_string())) + .collect() +} + +fn texts(s: &mut Session, kind: &str) -> Vec { + lines(s).into_iter().filter(|(_, k, _)| k == kind).map(|(_, _, t)| t).collect() +} + +#[test] +fn write_and_writeln_join_their_values_and_break_lines() { + let mut s = Session::new(); + run(&mut s, "$.write('a', 1, null, undefined, 1/0); $.write('b\\r\\nc\\rd\\n'); $.writeln(); $.write('x\\r'); $.write('\\ny'); $.writeln([1, 2], {}); $.write('p\\r'); $.write(''); $.write('\\nq'); $.writeln(); $.write('\\0\\t\\u2028\\x7f')").unwrap(); + let want = ["a1nullundefinedInfinityb", "c", "d", "", "x", "y1,2[object Object]", "p", "q", "\u{2400}\u{2409}\u{2424}\u{2421}"]; + assert_eq!(texts(&mut s, "output"), want); + let report = s.script_report().unwrap(); + assert_eq!(report["console"]["lines"], json!(want), "the run's own console holds the same output"); + assert_eq!(report["hostCallCounts"]["consoleWrites"], 11); +} + +#[test] +fn an_endless_writeln_stays_within_the_line_limit_and_counts_what_it_dropped() { + let mut s = Session::new(); + let e = s.execute("script.run", &json!({"source": "while (1) $.writeln('x')", "name": "t.js", "maxFuel": 2_000_000})).unwrap_err(); + assert!(e.to_string().ends_with("(limit)"), "{e}"); + let c = s.execute("script.console", &json!({})).unwrap(); + let kept = c["lines"].as_array().unwrap().len(); + assert_eq!(kept, MAX_CONSOLE_LINES); + let report = s.script_report().unwrap().clone(); + let writes = report["hostCallCounts"]["consoleWrites"].as_u64().unwrap(); + assert!(writes > MAX_CONSOLE_LINES as u64 * 2, "{writes} writes"); + // The session console holds a start line, the output, an error, an end and a report line. + assert_eq!(c["droppedLines"].as_u64().unwrap(), writes + 4 - MAX_CONSOLE_LINES as u64); + assert_eq!(report["console"]["lines"].as_array().unwrap().len(), MAX_CONSOLE_LINES); + assert_eq!(report["console"]["droppedLines"].as_u64().unwrap(), writes + 1 - MAX_CONSOLE_LINES as u64); + assert!(report["fuelUsed"].as_u64().unwrap() > 1_900_000); + assert_eq!(report["hostCalls"].as_u64().unwrap(), writes); + let tail = lines(&mut s); + let ends: Vec<&str> = tail.iter().rev().take(3).map(|(_, k, t)| if k == "error" { "error" } else { t.as_str() }).collect(); + assert_eq!(ends[1], "ended: fuel"); + assert_eq!(ends[2], "error", "{tail:?}"); +} + +#[test] +fn long_lines_stay_within_the_byte_limit() { + let mut s = Session::new(); + let source = "var l = new Array(4000).join('y'); for (var i = 0; i < 400; i++) $.writeln(l)"; + run(&mut s, source).unwrap(); + let c = s.execute("script.console", &json!({})).unwrap(); + let bytes: usize = c["lines"].as_array().unwrap().iter().map(|l| l["text"].as_str().unwrap().len()).sum(); + assert!(bytes <= MAX_CONSOLE_BYTES, "{bytes}"); + let kept = c["lines"].as_array().unwrap().len(); + assert!(kept < 400 && kept > 200, "{kept} lines"); + assert!(c["droppedLines"].as_u64().unwrap() > 100); +} + +#[test] +fn a_huge_line_is_cut_at_a_character_boundary_and_counted() { + let mut s = Session::new(); + run(&mut s, "$.writeln(new Array(5000).join('é')); $.write(new Array(3000).join('a')); $.write(new Array(3000).join('b')); $.writeln('c'); $.writeln('ok')") + .unwrap(); + let out = texts(&mut s, "output"); + assert_eq!(out.len(), 3); + for line in &out[..2] { + assert!(line.len() <= MAX_LINE_BYTES && line.len() > MAX_LINE_BYTES - 8, "{}", line.len()); + assert!(line.ends_with(CUT_MARK)); + } + assert!(!out[1].contains('c'), "the rest of a cut line is left out until it ends"); + assert_eq!(out[2], "ok"); + let c = s.execute("script.console", &json!({})).unwrap(); + assert_eq!(c["truncatedLines"], 2); + assert_eq!(s.script_report().unwrap()["console"]["truncatedLines"], 2); +} + +#[test] +fn a_long_script_name_is_cut_in_every_line_and_shows_its_control_characters() { + let mut s = Session::new(); + let name = format!("a\nb{}", "é".repeat(100_000)); + s.execute("script.run", &json!({"source": "$.writeln('x'); $.writeln('y')", "name": name})).unwrap(); + let c = s.execute("script.console", &json!({})).unwrap(); + let names: Vec<&str> = c["lines"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect(); + assert_eq!(names.len(), 5); + for n in names { + assert!(n.len() <= MAX_NAME_BYTES && n.len() > MAX_NAME_BYTES - 8, "{}", n.len()); + assert!(n.starts_with("a\u{240a}b") && n.ends_with(CUT_MARK), "{n}"); + } + assert_eq!(s.script_report().unwrap()["name"].as_str().map(str::len), Some(name.len()), "the report keeps the name given"); +} + +#[test] +fn an_uncaught_error_shows_the_script_its_line_and_column() { + let mut s = Session::new(); + run(&mut s, "$.write('half');\n\n throw new Error('boom\\nsecond');").unwrap_err(); + let all = lines(&mut s); + let kinds: Vec<&str> = all.iter().map(|(_, k, _)| k.as_str()).collect(); + assert_eq!(kinds, ["start", "output", "error", "error", "end", "report"]); + assert_eq!(all[1].2, "half", "a line left unended still shows"); + assert_eq!(all[2].2, "t.js:3:3: Error: boom"); + assert_eq!(all[3].2, "second"); + assert_eq!(all[4].2, "ended: error"); + let report = s.script_report().unwrap(); + assert_eq!(report["console"]["lines"], json!(["half", "t.js:3:3: Error: boom", "second"])); + let mut s = Session::new(); + run(&mut s, "var = 1").unwrap_err(); + assert!(texts(&mut s, "error")[0].starts_with("t.js:1:"), "a syntax error has its place too"); + let mut s = Session::new(); + s.script_frames = true; + run(&mut s, "while (true) {}").unwrap(); + s.script_abandon(); + assert_eq!(texts(&mut s, "error"), ["t.js: location unavailable: limit reached: Interrupted"]); +} + +#[test] +fn the_result_and_the_report_agree_and_count_each_kind_of_host_call() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = "vc.run('shape.rectangle', {x:0,y:0,width:5,height:5}); vc.run('shape.rectangle', {x:9,y:0,width:5,height:5}); \ + try { vc.run('no.such', {}); } catch (e) {} var d = vc.document; var c = vc.commands; vc.log({a: 1}); vc.log('two\\nlines'); $.writeln('w');"; + let r = run(&mut s, source).unwrap(); + assert_eq!(&r, s.script_report().unwrap()); + assert_eq!( + r["hostCallCounts"], + json!({"vcRun": 3, "documentReads": 1, "commandListReads": 1, "vcLog": 2, "consoleWrites": 1, "fileCalls": 0, "fileExecutes": 0}) + ); + assert_eq!(r["timingAvailable"], true); + assert!(r["runTimeMs"].is_u64()); + let slow = r["slowestCommands"].as_array().unwrap(); + assert_eq!(slow.len(), 1, "an unknown command is not timed: {slow:?}"); + assert_eq!((slow[0]["id"].as_str(), slow[0]["calls"].as_u64()), (Some("shape.rectangle"), Some(2))); + assert!(slow[0]["maxMs"].as_u64() <= slow[0]["totalMs"].as_u64()); + assert_eq!(r["console"]["lines"], json!(["{\"a\":1}", "two", "lines", "w"])); + assert_eq!(texts(&mut s, "log"), ["{\"a\":1}", "two", "lines"]); + assert_eq!(texts(&mut s, "end"), ["ended: finished"]); + assert!(texts(&mut s, "report")[0].contains("host calls; slowest: shape.rectangle ×2"), "{:?}", texts(&mut s, "report")); + assert_eq!(r["log"], json!([{"a": 1}, "two\nlines"]), "the log keeps its values"); +} + +#[test] +fn a_stopped_run_reports_its_diagnostics_and_its_unended_line() { + let mut s = Session::new(); + s.script_frames = true; + run(&mut s, "$.write('so far'); vc.log(1); while (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + assert_eq!(texts(&mut s, "log"), ["1"], "a line shows while the run goes on"); + s.execute("script.stop", &json!({})).unwrap(); + assert!(s.script_pump().unwrap().is_err()); + let report = s.script_report().unwrap(); + assert_eq!(report["kind"], "stopped"); + for key in ["runTimeMs", "hostCallCounts", "slowestCommands", "console", "fuelUsed", "hostCalls", "timingAvailable"] { + assert!(!report[key].is_null(), "{key}: {report}"); + } + assert_eq!(report["hostCalls"], 2); + assert_eq!(texts(&mut s, "output"), ["so far"]); + assert_eq!(texts(&mut s, "end"), ["ended: stopped"]); +} + +/// A report line counts one host call in the singular, and gives a run without a clock as "time +/// unavailable". +#[test] +fn a_report_line_counts_one_host_call_in_the_singular_and_says_when_time_is_unavailable() { + use crate::script::console::{Entry, Slow}; + let report = |ms: Option, host_calls: u64| Entry::Report { ms, host_calls, slowest: vec![] }.text(); + assert_eq!(report(Some(3), 0), "3 ms, 0 host calls"); + assert_eq!(report(Some(3), 1), "3 ms, 1 host call"); + assert_eq!(report(Some(0), 2), "0 ms, 2 host calls"); + assert_eq!(report(None, 0), "time unavailable, 0 host calls"); + assert_eq!(report(None, 1), "time unavailable, 1 host call"); + assert_eq!(report(None, 2), "time unavailable, 2 host calls"); + let slowest = vec![Slow { id: "a.b".into(), calls: 1, total_ms: 4, max_ms: 4 }]; + assert_eq!(Entry::Report { ms: None, host_calls: 1, slowest }.text(), "time unavailable, 1 host call; slowest: a.b ×1 4 ms"); +} + +/// A run whose job is gone still ends its console entry, once, with its own number and name and no +/// report line: as stopped when it is abandoned, as an internal fault when a slice finds it gone. +/// The session can start the next run. +#[test] +fn a_run_whose_job_is_gone_still_ends_its_console_entry() { + for abandon in [true, false] { + let mut s = Session::new(); + s.script_frames = true; + run(&mut s, "$.writeln('one'); while (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + assert!(script::forget_job(&s)); + if abandon { + assert!(s.script_abandon()); + } else { + let e = s.script_pump().unwrap().unwrap_err(); + assert_eq!(e.to_string(), "the script's run is no longer available"); + } + assert!(!s.script_running()); + let all = lines(&mut s); + let kinds: Vec<&str> = all.iter().map(|(_, k, _)| k.as_str()).collect(); + assert_eq!(kinds, ["start", "output", "end"], "{all:?}"); + assert_eq!(all[2].2, if abandon { "ended: stopped" } else { "ended: internal" }); + let c = s.execute("script.console", &json!({})).unwrap(); + assert_eq!((c["lines"][2]["run"].as_u64(), c["lines"][2]["name"].as_str()), (Some(1), Some("t.js"))); + assert!(!s.script_abandon() && s.script_pump().is_none(), "nothing is left to end"); + assert_eq!(lines(&mut s).len(), 3, "the entry ends once"); + s.script_frames = false; + run(&mut s, "$.writeln('two')").unwrap(); + assert_eq!(texts(&mut s, "end").last().map(String::as_str), Some("ended: finished"), "the next run starts"); + assert_eq!(lines(&mut s).last().map(|l| l.0), Some(2)); + } +} + +#[test] +fn runs_are_numbered_a_busy_run_adds_nothing_and_clear_keeps_the_report() { + let mut s = Session::new(); + for _ in 0..3 { + run(&mut s, "for (var i = 0; i < 1000; i++) $.writeln(i)").unwrap(); + } + let all = lines(&mut s); + assert_eq!(all.len(), MAX_CONSOLE_LINES); + assert_eq!(all.last().map(|l| l.0), Some(3)); + s.script_frames = true; + run(&mut s, "$.writeln('four'); while (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + let before = lines(&mut s); + assert!(matches!(run(&mut s, "$.writeln('five')"), Err(EngineError::Busy))); + assert_eq!(lines(&mut s), before, "a refused run adds no line"); + assert_eq!(s.execute("script.console.clear", &json!({})).unwrap()["cleared"], MAX_CONSOLE_LINES); + let c = s.execute("script.console", &json!({})).unwrap(); + assert_eq!((c["lines"].as_array().map(Vec::len), c["droppedLines"].as_u64()), (Some(0), Some(0))); + s.execute("script.stop", &json!({})).unwrap(); + assert!(s.script_pump().unwrap().is_err()); + let report = s.script_report().unwrap(); + assert_eq!(report["console"]["lines"][0], "four", "a clear leaves the running script's own console"); + assert_eq!(texts(&mut s, "end"), ["ended: stopped"], "the run goes on writing after a clear"); + assert_eq!(lines(&mut s)[0].0, 4); + let mut other = Session::new(); + assert!(lines(&mut other).is_empty(), "each session has its own console"); +} + +#[test] +fn the_slowest_commands_are_the_longest_in_all_then_by_id() { + let times = std::collections::HashMap::from([ + ("a.one", (1, 5_000_000, 5_000_000)), + ("b.two", (3, 9_000_000, 4_000_000)), + ("c.three", (1, 5_000_000, 5_000_000)), + ("d", (1, 1, 1)), + ("e", (1, 2, 2)), + ("f", (1, 3, 3)), + ]); + let slow = script::slowest(×); + let ids: Vec<&str> = slow.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]); + assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4)); +} diff --git a/crates/engine/src/tests/script_dialogs.rs b/crates/engine/src/tests/script_dialogs.rs new file mode 100644 index 000000000..7572ca52f --- /dev/null +++ b/crates/engine/src/tests/script_dialogs.rs @@ -0,0 +1,289 @@ +//! `alert`, `confirm` and `prompt` in a script: their arguments and results, the answers a run with +//! nobody to ask takes from `answers`, the wait in the app, Stop, and the report's `dialogs`. + +use serde_json::{Value, json}; + +use crate::script::{DialogAnswer, DialogKind, ScriptDialog, dialogs}; +use crate::*; + +/// One open document with one rectangle. +fn one_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s +} + +fn art(s: &Session) -> (String, usize) { + let d = &s.documents()[0]; + (format!("{:?}", d.doc.layers), d.history.undo.len()) +} + +fn headless(s: &mut Session, source: &str, answers: Value) -> Result { + s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": answers})) +} + +/// A run in the app of `source`, one slice in. +fn framed(s: &mut Session, source: &str) { + s.script_frames = true; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true); + assert!(s.script_pump().is_none()); +} + +/// Pumps the run in the app until it ends or waits at a dialog (`None`). +fn pump(s: &mut Session) -> Option> { + for _ in 0..10_000 { + if s.script_dialog().is_some() { + return None; + } + if let Some(r) = s.script_pump() { + return Some(r); + } + } + panic!("the run neither ended nor waited"); +} + +fn answers(list: &[Value]) -> Value { + Value::Array(list.to_vec()) +} + +#[test] +fn notification_signatures_and_returns() { + let mut s = one_doc(); + let source = "vc.log(typeof alert('a')); alert(42, 'Title', 1);\ + vc.log(confirm('c')); vc.log(confirm('n', true, undefined));\ + vc.log(prompt('p', 'pre')); vc.log(prompt({toString: function () { return 'own'; }}, '', 'T'));"; + let r = headless( + &mut s, + source, + answers(&[ + json!({"kind": "alert", "value": "ok"}), + json!({"kind": "alert", "value": "ok"}), + json!({"kind": "confirm", "value": true}), + json!({"kind": "confirm", "value": false}), + json!({"kind": "prompt", "value": "typed"}), + json!({"kind": "prompt", "value": null}), + ]), + ) + .unwrap(); + assert_eq!(r["log"], json!(["undefined", true, false, "typed", null]), "{r}"); + assert_eq!( + r["dialogs"], + json!([ + {"kind": "alert", "message": "a", "title": "Script Alert", "errorIcon": false, "answer": "ok"}, + {"kind": "alert", "message": "42", "title": "Title", "errorIcon": true, "answer": "ok"}, + {"kind": "confirm", "message": "c", "title": "Script Alert", "noAsDefault": false, "answer": true}, + {"kind": "confirm", "message": "n", "title": "Script Alert", "noAsDefault": true, "answer": false}, + {"kind": "prompt", "message": "p", "title": "Script Alert", "preset": "pre", "answer": "typed"}, + {"kind": "prompt", "message": "own", "title": "T", "preset": "", "answer": null}, + ]) + ); + assert_eq!((r["dialogCount"].clone(), r["dialogsTruncated"].clone()), (json!(6), json!(false))); + let has = headless(&mut s, "vc.log(vc.api.has('dialogs'));", json!([])).unwrap(); + assert_eq!(has["log"], json!([true])); +} + +#[test] +fn headless_answers_follow_call_order() { + let mut s = one_doc(); + let source = "var out = []; [1, 2].forEach(function (i) { out.push(confirm('q' + i)); }); out.push(prompt('r', 'x')); vc.log(out.join());"; + let r = headless( + &mut s, + source, + answers(&[json!({"kind": "confirm", "value": false}), json!({"kind": "confirm", "value": true}), json!({"kind": "prompt", "value": "z"})]), + ) + .unwrap(); + assert_eq!(r["log"], json!(["false,true,z"]), "a callback of a built-in takes its answer too: {r}"); + assert_eq!(headless(&mut s, "1", json!({})).unwrap()["dialogCount"], 0, "an empty object is an empty list"); +} + +#[test] +fn missing_or_mismatched_answer_rolls_back() { + let mut s = one_doc(); + let before = art(&s); + let edit = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});"; + for (source, given, says) in [ + (format!("{edit} try {{ alert('x'); }} catch (e) {{ vc.log('caught'); }}"), json!([]), "no answer left"), + (format!("{edit} alert('x');"), answers(&[json!({"kind": "confirm", "value": true})]), "is for confirm, not alert"), + (format!("{edit} alert('x');"), answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})]), "1 answer"), + ] { + let e = headless(&mut s, &source, given).unwrap_err().to_string(); + assert!(e.contains(says), "{e}"); + assert_eq!(art(&s), before, "{source}"); + let report = s.script_report().unwrap(); + assert_eq!(report["kind"], "limit", "{report}"); + assert_eq!(report["log"], json!([]), "a missing answer cannot be caught"); + assert_eq!(report["dialogCount"], 1); + } + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "ok"); + headless(&mut s, "alert('x')", json!([])).unwrap_err(); + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "missing"); +} + +#[test] +fn invalid_answers_are_rejected_before_run() { + let mut s = one_doc(); + let before = art(&s); + let many: Vec = (0..=dialogs::MAX_ANSWERS).map(|_| json!({"kind": "alert", "value": "ok"})).collect(); + let big: Vec = (0..80).map(|_| json!({"kind": "prompt", "value": "x".repeat(16_000)})).collect(); + for given in [ + json!({"a": 1}), + json!("ok"), + json!([1]), + json!([{"kind": "alert"}]), + json!([{"kind": "alert", "value": true}]), + json!([{"kind": "confirm", "value": "yes"}]), + json!([{"kind": "prompt", "value": 3}]), + json!([{"kind": "prompt", "value": "x".repeat(dialogs::MAX_PROMPT_BYTES + 1)}]), + json!([{"kind": "other", "value": "ok"}]), + json!([{"kind": "alert", "value": "ok", "extra": 1}]), + Value::Array(many), + Value::Array(big), + ] { + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});"; + let r = s.execute("script.run", &json!({"source": source, "answers": given})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); + assert_eq!(art(&s), before); + } + s.script_frames = true; + let r = s.execute("script.run", &json!({"source": "1", "answers": [{"kind": "alert", "value": "ok"}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "a run in the app takes no answers: {r:?}"); + assert!(!s.script_running()); +} + +#[test] +fn a_framed_run_waits_at_each_dialog_and_takes_the_persons_answer() { + let mut s = one_doc(); + framed( + &mut s, + "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); var a = confirm('Go?', true, 'Ask'); var b = prompt('Name', 'n'); vc.log([a, b]);", + ); + let want = ScriptDialog { + kind: DialogKind::Confirm, + message: "Go?".into(), + title: Some("Ask".into()), + error_icon: false, + no_as_default: true, + preset: String::new(), + }; + assert_eq!(s.script_dialog(), Some(want)); + assert!(!s.script_answer(DialogAnswer::Ok), "an answer for another kind of dialog is refused"); + assert!(s.script_answer(DialogAnswer::Confirm(true))); + assert!(!s.script_answer(DialogAnswer::Confirm(true)), "nothing waits until the run goes on"); + assert!(pump(&mut s).is_none()); + assert_eq!(s.script_dialog().unwrap().preset, "n"); + assert!(!s.script_answer(DialogAnswer::Prompt(Some("x".repeat(dialogs::MAX_PROMPT_BYTES + 1))))); + assert!(s.script_answer(DialogAnswer::Prompt(Some("Ann".into())))); + let r = pump(&mut s).unwrap().unwrap(); + assert_eq!(r["log"], json!([[true, "Ann"]])); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script", "a finished run is one undo step"); + assert_eq!(r["dialogs"][1]["answer"], "Ann"); +} + +#[test] +fn prompt_cancel_returns_null() { + let mut s = one_doc(); + framed(&mut s, "vc.log(prompt('Name', '') === null);"); + assert!(s.script_answer(DialogAnswer::Prompt(None))); + assert_eq!(pump(&mut s).unwrap().unwrap()["log"], json!([true])); +} + +#[test] +fn dialog_loop_stops_or_exhausts() { + let mut s = one_doc(); + let before = art(&s); + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); while (1) { alert('x'); }"; + let three: Vec = (0..3).map(|_| json!({"kind": "alert", "value": "ok"})).collect(); + let e = headless(&mut s, source, Value::Array(three)).unwrap_err().to_string(); + assert!(e.contains("no answer left"), "{e}"); + assert_eq!(s.script_report().unwrap()["dialogCount"], 4); + assert_eq!(art(&s), before); + framed(&mut s, source); + for _ in 0..3 { + assert!(s.script_answer(DialogAnswer::Ok)); + assert!(pump(&mut s).is_none()); + } + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert_eq!(s.script_dialog(), None, "a run asked to stop no longer waits"); + assert!(!s.script_answer(DialogAnswer::Ok)); + assert!(s.script_pump().unwrap().is_err()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), report["dialogCount"].as_u64()), (Some("stopped"), Some(4)), "{report}"); + assert_eq!(report["dialogs"][3]["answer"], "stop"); + assert_eq!(art(&s), before, "Stop at the first or any later dialog leaves no undo step"); + framed(&mut s, source); + assert!(s.script_abandon(), "quitting while a dialog waits abandons the run"); + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "stop"); + assert_eq!(art(&s), before); +} + +#[test] +fn dialog_text_limits_and_controls() { + let mut s = one_doc(); + let source = format!( + "var out = [];\ + function t(f) {{ try {{ f(); out.push('opened'); }} catch (e) {{ out.push(e.name); }} }}\ + t(function () {{ alert('x'); }});\ + t(function () {{ alert(new Array({m}).join('m')); }});\ + t(function () {{ alert('m', new Array({t}).join('t')); }});\ + t(function () {{ prompt('m', new Array({m}).join('p')); }});\ + t(function () {{ alert(); }});\ + t(function () {{ prompt('m'); }});\ + t(function () {{ alert({{toString: function () {{ throw new Error('no'); }}}}); }});\ + t(function () {{ alert(undefined); }});\ + vc.log(out.join());", + m = dialogs::MAX_MESSAGE_BYTES + 2, + t = dialogs::MAX_TITLE_BYTES + 2, + ); + let r = headless(&mut s, &source, answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})])).unwrap(); + assert_eq!(r["log"], json!(["opened,RangeError,RangeError,RangeError,TypeError,TypeError,Error,opened"]), "{r}"); + assert_eq!(r["dialogs"][1]["message"], "undefined", "an explicit undefined message is a message"); + assert_eq!(dialogs::shown_text("a\r\nb\rc\nd\te\u{7}\u{7f}", true), "a\nb\nc\nd\u{2409}e\u{2407}\u{2421}"); + assert_eq!(dialogs::shown_text("a\nb", false), "a\u{240a}b", "a title stays on one line"); + assert_eq!(dialogs::cut_to("aé", 2), "a", "a cut keeps whole characters"); +} + +#[test] +fn dialog_transcript_on_success_and_failure() { + let mut s = one_doc(); + let r = headless(&mut s, "alert('one'); throw new Error('later');", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap_err(); + assert!(r.to_string().contains("later")); + let report = s.script_report().unwrap(); + assert_eq!(report["dialogs"], json!([{"kind": "alert", "message": "one", "title": "Script Alert", "errorIcon": false, "answer": "ok"}])); + let ok = headless(&mut s, "1", json!([])).unwrap(); + assert_eq!((ok["dialogs"].clone(), ok["dialogCount"].clone()), (json!([]), json!(0))); +} + +#[test] +fn dialog_wait_does_not_spend_fuel() { + let mut s = one_doc(); + framed(&mut s, "alert('x'); 1"); + for _ in 0..20 { + assert!(s.script_pump().is_none(), "the run waits"); + } + assert!(s.script_answer(DialogAnswer::Ok)); + let r = pump(&mut s).unwrap().unwrap(); + let mut t = one_doc(); + let plain = headless(&mut t, "alert('x'); 1", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap(); + assert_eq!((r["fuelUsed"].clone(), r["hostCalls"].clone()), (plain["fuelUsed"].clone(), plain["hostCalls"].clone()), "{r}"); +} + +#[test] +fn second_run_is_busy_while_dialog_waits() { + let mut s = one_doc(); + framed(&mut s, "alert('x');"); + assert!(s.execute("script.run", &json!({"source": "1"})).is_err()); + assert!(s.script_dialog().is_some(), "the first run still waits"); + assert!(s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 1, "height": 1})).is_err(), "edits are refused while it waits"); +} + +#[test] +fn a_dialog_in_a_callback_throws_in_the_app() { + let mut s = one_doc(); + s.script_frames = true; + let source = "var m = ''; [1].forEach(function () { try { alert('x'); } catch (e) { m = e.message; } }); vc.log(m);"; + s.execute("script.run", &json!({"source": source})).unwrap(); + let r = pump(&mut s).unwrap().unwrap(); + assert!(r["log"][0].as_str().unwrap().contains("callback"), "{r}"); + assert_eq!(r["dialogCount"], 0); +} diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs new file mode 100644 index 000000000..d5b962450 --- /dev/null +++ b/crates/engine/src/tests/script_dollar.rs @@ -0,0 +1,247 @@ +//! The `$` object's members besides `$.write` and `$.writeln`, the global `localize` and +//! `toSource`, and what of them one run keeps to itself. + +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::script::{TEST_ELAPSED, TEST_NO_CLOCK}; +use crate::*; + +/// The values `source` gives `vc.log`, in a run of its text. +fn logged(s: &mut Session, source: &str) -> Value { + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap_or_else(|e| panic!("{source}: {e}"))["log"].clone() +} + +/// The one value `expr` gives, logged. +fn value(s: &mut Session, expr: &str) -> Value { + logged(s, &format!("vc.log({expr});"))[0].clone() +} + +/// The name of the error `statement` throws and its message, as `Name: message`. +fn thrown(s: &mut Session, statement: &str) -> String { + value(s, &format!("(function () {{ try {{ {statement}; return 'no error'; }} catch (e) {{ return e.name + ': ' + e.message; }} }})()")) + .as_str() + .unwrap_or_default() + .to_string() +} + +#[test] +fn each_member_has_its_value() { + let mut s = Session::new(); + let version = env!("CARGO_PKG_VERSION"); + let os = if cfg!(target_arch = "wasm32") { + "Web" + } else if cfg!(windows) { + "Windows" + } else if cfg!(target_os = "macos") { + "macOS" + } else if cfg!(target_os = "linux") { + "Linux" + } else if cfg!(target_os = "freebsd") { + "FreeBSD" + } else { + "Unix" + }; + for (expr, want) in [ + ("$.about()", json!(format!("VectorCraft script engine {version}"))), + ("$.appEncoding", json!("UTF-8")), + ("$.build", json!(version)), + ("$.buildDate", json!(null)), + ("$.decimalPoint", json!(".")), + ("$.engineName", json!("VectorCraft")), + ("$.error", json!(null)), + ("$.flags", json!(0)), + ("$.gc() === undefined", json!(true)), + ("$.global === this", json!(true)), + ("typeof $.hiresTimer === 'number' && $.hiresTimer >= 0", json!(true)), + ("$.level", json!(0)), + ("\n$.line", json!(2)), + ("$.locale", json!("en")), + ("$.localize", json!(false)), + ("$.memCache", json!(0)), + ("$.os", json!(os)), + ("$.screens.length", json!(0)), + ("$.stack", json!("[global]:1\n")), + ("$.strict", json!(false)), + ("$.version", json!(version)), + ("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")), + ("vc.api.has('dollar')", json!(true)), + ] { + assert_eq!(value(&mut s, expr), want, "{expr}"); + } + assert_eq!(thrown(&mut s, "$.bp()"), "Error: $.bp: scripts here have no debugger"); + assert_eq!(thrown(&mut s, "$.colorPicker(0)"), "Error: $.colorPicker: scripts cannot show a dialog yet"); +} + +#[test] +fn read_only_members_keep_their_values_and_settable_ones_check_what_they_are_given() { + let mut s = Session::new(); + assert_eq!(value(&mut s, "($.build = 'x', $.level = 3, $.os = 'y', [$.build === 'x', $.level, $.os === 'y'])"), json!([false, 0, false])); + assert!(thrown(&mut s, "(function () { 'use strict'; $.version = '9'; })()").starts_with("TypeError")); + assert!(thrown(&mut s, "(function () { 'use strict'; $.error = null; })()").starts_with("TypeError")); + assert_eq!(value(&mut s, "($.appEncoding = 'utf-8', $.appEncoding)"), json!("UTF-8")); + assert!(thrown(&mut s, "$.appEncoding = 'ISO-8859-1'").starts_with("RangeError")); + assert!(thrown(&mut s, "$.appEncoding = 8").starts_with("TypeError")); + assert!(thrown(&mut s, "$.memCache = 1024").starts_with("Error")); + assert_eq!(value(&mut s, "($.flags = 512, [$.flags, $.localize])"), json!([512, true])); + assert_eq!(value(&mut s, "($.flags = 512, $.flags = 0, [$.flags, $.localize])"), json!([0, false])); + for bad in ["$.flags = 2", "$.flags = 513", "$.flags = -512"] { + assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}"); + } + assert!(thrown(&mut s, "$.flags = '512'").starts_with("TypeError")); + assert!(thrown(&mut s, "$.localize = 1").starts_with("TypeError")); +} + +#[test] +fn the_locale_starts_as_the_interfaces_and_a_change_lasts_one_run() { + let mut s = Session::new(); + for (ui, want) in [("de", "de"), ("pt-br", "pt_BR"), ("zh-hant", "zh_TW"), ("zh-hans", "zh_CN"), ("auto", "en"), ("x", "en")] { + s.ui_language = Some(ui.into()); + assert_eq!(value(&mut s, "$.locale"), json!(want), "{ui}"); + } + s.ui_language = None; + s.prefs.interface_language = "fr".into(); + assert_eq!(value(&mut s, "$.locale"), json!("fr"), "without a language from the app, the preference"); + s.ui_language = Some("de".into()); + let lookup = "localize({en: 'e', de: 'd', fr: 'f', fr_CA: 'c'})"; + assert_eq!(value(&mut s, &format!("[{lookup}, $.decimalPoint]")), json!(["d", ","])); + let changed = format!( + "$.locale = 'FR-ca'; var a = [$.locale, {lookup}]; $.locale = 'fr'; a.push({lookup}); $.locale = null; a.push($.locale, {lookup}); a" + ); + assert_eq!(value(&mut s, &format!("(function () {{ {changed}; return a; }})()")), json!(["fr_CA", "c", "f", "de", "d"])); + assert_eq!(value(&mut s, "($.locale = '', $.locale)"), json!("de"), "an empty locale is the interface's"); + assert_eq!(value(&mut s, "($.locale = 'en_US', $.locale)"), json!("en_US")); + assert_eq!(value(&mut s, "$.locale"), json!("de"), "the next run starts with the interface's again"); + for bad in ["$.locale = 'german'", "$.locale = 'de_DE_x'", "$.locale = 'd'"] { + assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}"); + } + assert!(thrown(&mut s, "$.locale = 5").starts_with("TypeError")); +} + +#[test] +fn localized_to_string_is_on_only_while_the_run_turns_it_on() { + let mut s = Session::new(); + s.ui_language = Some("de".into()); + let text = "String({en: 'Open', de: 'Öffnen'})"; + assert_eq!(value(&mut s, text), json!("[object Object]")); + assert_eq!(value(&mut s, &format!("($.localize = true, {text})")), json!("Öffnen")); + assert_eq!(value(&mut s, text), json!("[object Object]"), "the next run starts with it off"); + assert_eq!(value(&mut s, "localize('%1 of %2', 3, 7)"), json!("3 of 7"), "localize works with it off"); +} + +#[test] +fn to_source_writes_plain_data() { + let mut s = Session::new(); + assert_eq!(value(&mut s, "toSource({a: [1, 'x'], b: null})"), json!(r#"({"a":[1, "x"], "b":null})"#)); + assert_eq!(value(&mut s, "[true, 2].toSource()"), json!("[true, 2]")); + assert!(thrown(&mut s, "var o = {}; o.o = o; toSource(o)").starts_with("TypeError")); + assert!(thrown(&mut s, "toSource(vc)").starts_with("TypeError"), "a host object is not plain data"); +} + +#[test] +fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() { + let mut s = Session::new(); + for name in ["HOME", "PATH", "USERPROFILE", "TEMP", "lang", ""] { + assert_eq!(value(&mut s, &format!("$.getenv('{name}')")), json!(null), "{name}"); + } + let process = std::env::var("OS").ok(); + let before = value(&mut s, "$.getenv('OS')"); + if let Some(os) = process.as_deref().filter(|v| v.len() <= 64 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-@".contains(&b))) { + assert_eq!(before, json!(os)); + } + assert_eq!(value(&mut s, "($.setenv('OS', 'Test-1'), $.getenv('OS'))"), json!("Test-1")); + assert_eq!(std::env::var("OS").ok(), process, "the process's environment stays as it was"); + assert_eq!(value(&mut s, "$.getenv('OS')"), before, "the next run reads the process's value again"); + assert!(thrown(&mut s, "$.setenv('HOME', 'x')").starts_with("Error")); + assert!(thrown(&mut s, "$.setenv('LANG', 'a b')").starts_with("RangeError")); + assert!(thrown(&mut s, &format!("$.setenv('LANG', '{}')", "a".repeat(65))).starts_with("RangeError")); + assert!(thrown(&mut s, "$.setenv('LANG')").starts_with("TypeError")); + assert!(thrown(&mut s, "$.getenv(1)").starts_with("TypeError")); +} + +#[test] +fn error_line_and_stack_follow_the_running_code() { + let mut s = Session::new(); + let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());"; + assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true])); + assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one"); +} + +#[test] +fn sleep_waits_between_slices_until_its_time_has_passed() { + let mut s = Session::new(); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + s.execute("script.run", &json!({"source": "vc.log(1); $.sleep(250); vc.log(2);", "name": "t.js"})).unwrap(); + let logs = + |s: &mut Session| s.execute("script.console", &json!({})).unwrap()["lines"].as_array().unwrap().iter().filter(|l| l["kind"] == "log").count(); + assert!(s.script_pump().is_none()); + assert_eq!(logs(&mut s), 1); + TEST_ELAPSED.set(Some(Duration::from_millis(249))); + assert!(s.script_pump().is_none()); + assert!(s.script_pump().is_none()); + assert_eq!(logs(&mut s), 1, "the run waits"); + assert_eq!(s.script_sleep_left(), Some(Duration::from_millis(1)), "the app can wait this long before the next frame"); + TEST_ELAPSED.set(Some(Duration::from_millis(250))); + let r = s.script_pump().unwrap().unwrap(); + assert_eq!(r["log"], json!([1, 2])); + TEST_ELAPSED.set(None); +} + +#[test] +fn a_sleeping_run_stops_when_asked() { + let mut s = Session::new(); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + s.execute("script.run", &json!({"source": "$.sleep(1000); vc.log(2);", "name": "t.js"})).unwrap(); + assert!(s.script_pump().is_none()); + assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1))); + s.execute("script.stop", &json!({})).unwrap(); + assert_eq!(s.script_sleep_left(), None, "a run asked to stop does not wait"); + assert!(s.script_pump().unwrap().is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "stopped"); + TEST_ELAPSED.set(None); +} + +#[test] +fn sleep_takes_milliseconds_from_0_to_1000() { + let mut s = Session::new(); + assert_eq!(value(&mut s, "($.sleep(0), $.sleep(1), 'woke')"), json!("woke")); + for bad in ["$.sleep(-1)", "$.sleep(1001)"] { + assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}"); + } + for bad in ["$.sleep()", "$.sleep('5')", "$.sleep(NaN)", "$.sleep(Infinity)"] { + assert!(thrown(&mut s, bad).starts_with("TypeError"), "{bad}"); + } +} + +#[test] +fn sleep_throws_where_the_run_cannot_wait_instead_of_running_on() { + let mut s = Session::new(); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); vc.log('after'); return 1; } }); + try { o.x; } catch (e) { vc.log(e.name + ': ' + e.message); } + try { [1].map(function () { $.sleep(250); vc.log('after'); }); } catch (e) { vc.log(e.name + ': ' + e.message); } + [1].map(function () { $.sleep(0); }); + vc.log('done');"; + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + let r = s.script_pump().expect("the run does not wait").unwrap(); + TEST_ELAPSED.set(None); + let message = "Error: $.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"; + assert_eq!(r["log"], json!([message, message, "done"])); +} + +#[test] +fn sleep_throws_where_the_run_cannot_wait_without_a_clock_too() { + let mut s = Session::new(); + TEST_NO_CLOCK.set(true); + let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); return 1; } }); + try { o.x; vc.log('no error'); } catch (e) { vc.log(e.name); } + $.sleep(250); + vc.log('done');"; + let log = logged(&mut s, source); + TEST_NO_CLOCK.set(false); + assert_eq!(log, json!(["Error", "done"]), "the script's own code does not wait without a clock"); +} diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs new file mode 100644 index 000000000..b176b4f14 --- /dev/null +++ b/crates/engine/src/tests/script_folder.rs @@ -0,0 +1,324 @@ +//! The Scripts folder: what a listing holds and leaves out, its order and labels, its limits, the +//! read of a listed script, `script.listInstalled` and the automation read root. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::json; + +use crate::script::folder::{self, Entry, Limits, Listing}; +use crate::*; + +/// A fresh folder for the files of test `name`, holding an empty `Scripts` folder. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-script-folder-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Scripts")).unwrap(); + dir +} + +/// Writes the empty file `name` (folders included) under `root`. +fn touch(root: &Path, name: &str) -> PathBuf { + let path = root.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "").unwrap(); + path +} + +/// Makes `link` lead to the folder `target`: a symbolic link, or on Windows a junction, which needs +/// no privilege. +fn link_folder(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap(); + assert!(made.status.success(), "mklink /J: {}", String::from_utf8_lossy(&made.stderr)); + } +} + +/// A listing's rows as text: `label`, or `label/` followed by the folder's rows in brackets. +fn rows(entries: &[Entry]) -> String { + let row = |e: &Entry| match e { + Entry::Script { label, .. } => label.clone(), + Entry::Folder { label, entries } => format!("{label}/[{}]", rows(entries)), + }; + entries.iter().map(row).collect::>().join(", ") +} + +/// A listing within `limits` that takes no time. +fn list(root: &Path, limits: Limits) -> Listing { + folder::list_with(root, &limits, &mut || Duration::ZERO) +} + +#[test] +fn scripts_and_subfolders_are_listed_by_name_without_the_extension() { + let dir = folder("names"); + let root = dir.join("Scripts"); + for name in + ["b.js", "A.JSX", "Dessin (été) #1.jsx", "v1.2 draft.Js", "Zeta/inner.jsx", "Zeta/Deep/x.js", "notes.txt", "x.jsxbin", "lib.jsxinc", "js"] + { + touch(&root, name); + } + std::fs::create_dir_all(root.join("Empty/Also Empty")).unwrap(); + let listing = folder::list(&root); + assert_eq!(rows(&listing.entries), "A, b, Dessin (été) #1, v1.2 draft, Zeta/[Deep/[x], inner]"); + assert!(!listing.truncated && !listing.unreadable); + // Each script keeps the path the folder gave, whatever its label. + let paths: Vec<_> = + listing.scripts().into_iter().map(|(folders, label, path)| (folders.join("/"), label.to_string(), path.to_path_buf())).collect(); + assert_eq!(paths[0], (String::new(), "A".to_string(), root.join("A.JSX"))); + assert_eq!(paths[4], ("Zeta/Deep".to_string(), "x".to_string(), root.join("Zeta").join("Deep").join("x.js"))); + assert_eq!(paths.len(), 6); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_script_whose_label_another_row_has_shows_its_whole_file_name() { + let dir = folder("same"); + let root = dir.join("Scripts"); + for name in ["tidy.js", "tidy.jsx", "tidy.js.js", "tidy/x.js", "alone.jsx"] { + touch(&root, name); + } + // `tidy.js.js` is labeled `tidy.js` until `tidy.js` takes that label, then shows its whole name. + assert_eq!(rows(&folder::list(&root).entries), "alone, tidy/[x], tidy.js, tidy.js.js, tidy.jsx"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn control_characters_in_a_name_are_written_as_escapes_and_a_long_label_is_cut() { + let dir = folder("escapes"); + let root = dir.join("Scripts"); + // A right-to-left override, which would show the name backwards. + touch(&root, "a\u{202E}sj.js"); + let long = "n".repeat(200); + touch(&root, &format!("{long}.jsx")); + let listing = folder::list(&root); + let labels: Vec<&str> = listing.scripts().into_iter().map(|(_, label, _)| label).collect(); + assert_eq!(labels[0], "a\\u{202E}sj"); + assert_eq!(labels[1], format!("{}…", "n".repeat(folder::MAX_LABEL_CHARS - 1))); + assert!(folder::holds(&root, &root.join(format!("{long}.jsx")))); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn hidden_names_and_links_are_left_out() { + let dir = folder("hidden"); + let root = dir.join("Scripts"); + touch(&root, "shown.js"); + touch(&root, ".hidden.js"); + touch(&root, ".git/hook.js"); + let outside = touch(&dir, "Outside/secret.js"); + // A link out of the folder, and one back to the folder itself. + link_folder(outside.parent().unwrap(), &root.join("Out")); + link_folder(&root, &root.join("Loop")); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, root.join("linked.js")).unwrap(); + #[cfg(windows)] + { + let marked = touch(&root, "marked.js"); + assert!(std::process::Command::new("attrib").arg("+h").arg(&marked).status().unwrap().success()); + } + let listing = folder::list(&root); + assert_eq!(rows(&listing.entries), "shown"); + assert!(!listing.truncated && !listing.unreadable); + // Nor is a file behind a link read as a listed script. + assert!(folder::read(&root, &root.join("shown.js"), 10).is_some()); + for path in [root.join("Out").join("secret.js"), root.join("Loop").join("shown.js"), root.join(".hidden.js"), root.join("linked.js")] { + assert!(folder::read(&root, &path, 10).is_none(), "{}", path.display()); + } + // A script, or the folder it is in, that Windows marks hidden after a listing is not read. + #[cfg(windows)] + { + assert!(folder::read(&root, &root.join("marked.js"), 10).is_none()); + let inner = touch(&root, "Sub/inner.js"); + assert!(folder::read(&root, &inner, 10).is_some()); + assert!(std::process::Command::new("attrib").arg("+h").arg(root.join("Sub")).status().unwrap().success()); + assert!(folder::read(&root, &inner, 10).is_none()); + } + let _ = std::fs::remove_dir_all(root.join("Out")); + let _ = std::fs::remove_dir_all(root.join("Loop")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_folder_that_is_missing_is_empty_and_one_that_is_a_file_or_a_link_is_unreadable() { + let dir = folder("root"); + let root = dir.join("Scripts"); + touch(&root, "a.js"); + assert_eq!(folder::list(&dir.join("None")), Listing::default()); + let file = touch(&dir, "file"); + assert_eq!(folder::list(&file), Listing { unreadable: true, ..Listing::default() }); + let link = dir.join("Link"); + link_folder(&root, &link); + assert_eq!(folder::list(&link), Listing { unreadable: true, ..Listing::default() }); + assert!(folder::read(&link, &link.join("a.js"), 10).is_none()); + let _ = std::fs::remove_dir_all(&link); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn each_limit_stops_a_listing_with_what_it_found() { + let dir = folder("limits"); + let root = dir.join("Scripts"); + for n in 0..10 { + touch(&root, &format!("s{n}.js")); + } + let all = Limits::default(); + let entries = list(&root, Limits { entries: 4, ..all }); + assert_eq!((entries.scripts().len(), entries.truncated), (4, true)); + let scripts = list(&root, Limits { scripts: 3, ..all }); + assert_eq!((scripts.scripts().len(), scripts.truncated), (3, true)); + // The clock passes the time limit at the third entry. + let mut calls = 0; + let timed = folder::list_with(&root, &all, &mut || { + calls += 1; + if calls > 2 { all.time + Duration::from_millis(1) } else { Duration::ZERO } + }); + assert_eq!((timed.scripts().len(), timed.truncated), (2, true)); + let whole = list(&root, all); + assert_eq!((whole.scripts().len(), whole.truncated), (10, false)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn subfolders_past_the_fourth_level_or_the_folder_limit_are_left_out() { + let dir = folder("depth"); + let root = dir.join("Scripts"); + touch(&root, "1/2/3/4/kept.js"); + touch(&root, "1/2/3/4/5/deeper.js"); + let listing = folder::list(&root); + assert_eq!(rows(&listing.entries), "1/[2/[3/[4/[kept]]]]"); + assert!(listing.truncated); + assert!(folder::holds(&root, &root.join("1/2/3/4/kept.js"))); + assert!(!folder::holds(&root, &root.join("1/2/3/4/5/deeper.js"))); + let two = folder("folders"); + let root = two.join("Scripts"); + touch(&root, "a/x.js"); + touch(&root, "b/y.js"); + let listing = list(&root, Limits { folders: 1, ..Limits::default() }); + assert_eq!(rows(&listing.entries), "a/[x]"); + assert!(listing.truncated); + assert!(!folder::list(&root).truncated); + let _ = std::fs::remove_dir_all(dir); + let _ = std::fs::remove_dir_all(two); +} + +#[test] +fn a_folder_with_more_scripts_than_the_limit_lists_that_many() { + let dir = folder("many"); + let root = dir.join("Scripts"); + for n in 0..folder::MAX_SCRIPTS + 40 { + touch(&root, &format!("s{n:04}.js")); + } + let listing = list(&root, Limits::default()); + assert_eq!((listing.scripts().len(), listing.truncated), (folder::MAX_SCRIPTS, true)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_read_takes_only_a_script_that_is_still_a_file_under_the_folder() { + let dir = folder("read"); + let root = dir.join("Scripts"); + let script = touch(&root, "Sub/draw.jsx"); + std::fs::write(&script, "vc.log(1);").unwrap(); + assert_eq!(folder::read(&root, &script, 100).as_deref(), Some(&b"vc.log(1);"[..])); + assert_eq!(folder::read(&root, &script, 4).as_deref(), Some(&b"vc.l"[..])); + let outside = touch(&dir, "outside.js"); + touch(&root, "notes.txt"); + for path in [outside, root.join("Sub").join("..").join("..").join("outside.js"), root.join("notes.txt"), root.join("Sub"), root.join("gone.js")] { + assert!(folder::read(&root, &path, 100).is_none(), "{}", path.display()); + } + // A folder that took the script's place is not a script. + std::fs::remove_file(&script).unwrap(); + std::fs::create_dir(&script).unwrap(); + assert!(folder::read(&root, &script, 100).is_none()); + // Nor is the script read once a link stands in for its folder. + let other = touch(&dir, "Other/draw.jsx"); + std::fs::remove_dir_all(root.join("Sub")).unwrap(); + link_folder(other.parent().unwrap(), &root.join("Sub")); + assert!(folder::read(&root, &root.join("Sub").join("draw.jsx"), 100).is_none()); + let _ = std::fs::remove_dir_all(root.join("Sub")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn list_installed_gives_the_folder_and_its_scripts_in_menu_order() { + let dir = folder("command"); + let root = dir.join("Scripts"); + touch(&root, "Tidy Layers.jsx"); + touch(&root, "Export/All Artboards.js"); + let mut s = Session::new(); + assert_eq!( + s.execute("script.listInstalled", &json!({})).unwrap(), + json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}) + ); + s.attach_scripts_dir(root.clone()); + let text = |p: &Path| p.to_string_lossy().into_owned(); + assert_eq!( + s.execute("script.listInstalled", &json!({})).unwrap(), + json!({ + "folder": text(&root), + "scripts": [ + {"path": text(&root.join("Export").join("All Artboards.js")), "label": "All Artboards", "folders": ["Export"]}, + {"path": text(&root.join("Tidy Layers.jsx")), "label": "Tidy Layers", "folders": []}, + ], + "truncated": false, + "unreadable": false, + }) + ); + assert!(!find_command("script.listInstalled").unwrap().journal); + assert!(s.execute("script.listInstalled", &json!({"folder": "x"})).is_err()); + // A folder that is not there yet lists nothing, and the listing does not make it. + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(s.execute("script.listInstalled", &json!({})).unwrap()["scripts"], json!([])); + assert!(!root.exists()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_installed_script_is_read_as_script_run_reads_a_file() { + let dir = folder("installed"); + let root = dir.join("Scripts"); + let text = |p: &Path| p.to_string_lossy().into_owned(); + let mut s = Session::new(); + let script = touch(&root, "draw.jsx"); + std::fs::write(&script, "\u{feff}vc.log(1);").unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), cmd::scripts::NOT_LISTED, "no folder is attached"); + s.attach_scripts_dir(root.clone()); + assert_eq!(s.installed_script(&text(&script)).unwrap(), "vc.log(1);"); + std::fs::write(&script, "@JSXBIN@ES@2.0@").unwrap(); + assert!(s.installed_script(&text(&script)).unwrap_err().to_string().contains("compiled script")); + std::fs::write(&script, [0xff, 0xfe]).unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), "the script is not UTF-8 text"); + std::fs::write(&script, vec![b' '; script::MAX_SOURCE_BYTES + 1]).unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), cmd::scripts::TOO_LARGE); + std::fs::remove_file(&script).unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), cmd::scripts::NOT_LISTED); + let outside = touch(&dir, "outside.js"); + assert_eq!(s.installed_script(&text(&outside)).unwrap_err().to_string(), cmd::scripts::NOT_LISTED); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { + let dir = folder("roots"); + let root = dir.join("Scripts"); + let script = touch(&root, "draw.js"); + let work = dir.join("work"); + std::fs::create_dir_all(&work).unwrap(); + let mut s = Session::new(); + s.attach_scripts_dir(root.clone()); + let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); + let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err(); + assert!(e.to_string().contains("outside the read root"), "{e}"); + let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err(); + assert!(e.to_string().contains("outside the read root"), "{e}"); + // A read root that holds the folder lets both through. + let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || { + assert_eq!(s.execute("script.listInstalled", &json!({})).unwrap()["scripts"][0]["label"], "draw"); + assert_eq!(s.installed_script(&script.to_string_lossy()).unwrap(), ""); + }); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs new file mode 100644 index 000000000..124407f6b --- /dev/null +++ b/crates/engine/src/tests/script_include.rs @@ -0,0 +1,581 @@ +//! A script's preprocessor lines: `#include` and `//@include` in place, the folders they are looked +//! for in, the run's limits and scope, the other lines, `$.evalFile`, `$.fileName`, and errors at the +//! line of the file they are in. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::script::TEST_JSINCLUDE; +use crate::*; + +/// A fresh folder for test `name`, with a `main` folder inside it for the script. +fn folder(name: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("vc-script-include-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("main")).unwrap(); + base +} + +fn write(path: &Path, text: &str) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, text).unwrap(); +} + +/// Runs `main/main.jsx` in `base`, with the text `text`. +fn run(base: &Path, text: &str) -> Result { + let main = base.join("main/main.jsx"); + write(&main, text); + Session::new().execute("script.run", &json!({"path": main.to_string_lossy()})) +} + +/// The report of the run that failed with `e`. +fn failed(base: &Path, text: &str) -> Value { + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, text); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})); + assert!(e.is_err(), "the run failed: {e:?}"); + s.script_report().unwrap().clone() +} + +#[test] +fn script_include_sibling_runs_in_place() { + let base = folder("sibling"); + write(&base.join("main/lib.jsxinc"), "var b = a + 1;"); + write(&base.join("main/two.jsxinc"), "b = b * 10;\r\n"); + let r = run(&base, "var a = 1;\n #include \"lib.jsxinc\"\n//@include 'two.jsxinc'\nvc.log(a + b);").unwrap(); + assert_eq!(r["log"], json!([21])); + // A file may be included again once its earlier inclusion has ended. + let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include \"three.jsxinc\"\nvc.log(n);"); + assert!(r.is_err(), "three.jsxinc is missing"); + write(&base.join("main/three.jsxinc"), "n++;"); + let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include three.jsxinc\nvc.log(n);").unwrap(); + assert_eq!(r["log"], json!([2])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_nested_include_uses_including_folder() { + let base = folder("nested"); + write(&base.join("main/sub/one.jsxinc"), "#include \"two.jsxinc\"\nvar one = two + 1;"); + write(&base.join("main/sub/two.jsxinc"), "var two = 2;"); + write(&base.join("main/two.jsxinc"), "var two = 200;"); + let r = run(&base, "#include \"sub/one.jsxinc\"\nvc.log(one);").unwrap(); + assert_eq!(r["log"], json!([3]), "two.jsxinc beside one.jsxinc, not beside the script"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_missing_include_reports_origin() { + let base = folder("missing"); + let report = failed(&base, "vc.log(1);\n\t#include \"nope.jsxinc\"\n"); + assert_eq!(report["kind"], "thrown"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(2))); + assert!(report["message"].as_str().unwrap().contains("include file not found: nope.jsxinc"), "{report}"); + assert_eq!(report["log"], json!([]), "nothing ran before the include was read"); + assert!(report.get("file").is_none(), "the line is the script's own"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_includepath_searches_in_order() { + let base = folder("includepath"); + write(&base.join("main/a/x.jsxinc"), "var x = 'a';"); + write(&base.join("main/b/x.jsxinc"), "var x = 'b';"); + write(&base.join("main/b/y.jsxinc"), "var y = 'b';"); + let r = run(&base, "#includepath \"a; b\"\n#include \"x.jsxinc\"\n#include \"y.jsxinc\"\nvc.log(x + y + $.includePath);").unwrap(); + assert_eq!(r["log"], json!(["aba;b"])); + // A later #includepath replaces the list. + let report = failed(&base, "#includepath \"b\"\n#includepath \"a\"\n#include \"y.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("include file not found: y.jsxinc"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_include_cycle_and_depth_stop() { + let base = folder("cycle"); + write(&base.join("main/a.jsxinc"), "#include \"b.jsxinc\""); + write(&base.join("main/b.jsxinc"), "\n#include \"a.jsxinc\""); + let report = failed(&base, "#include \"a.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("include cycle: a.jsxinc"), "{report}"); + assert_eq!(report["line"], 2); + assert!(report["file"].as_str().unwrap().ends_with("b.jsxinc"), "{report}"); + // A script that includes itself. + let report = failed(&base, "#include \"main.jsx\""); + assert!(report["message"].as_str().unwrap().contains("include cycle"), "{report}"); + for i in 0..20 { + write(&base.join(format!("main/d{i}.jsxinc")), &format!("#include \"d{}.jsxinc\"", i + 1)); + } + let report = failed(&base, "#include \"d0.jsxinc\""); + assert_eq!(report["kind"], "limit"); + assert!(report["message"].as_str().unwrap().contains("include depth limit (16) reached"), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("d15.jsxinc"), "the 17th level is refused: {report}"); + let _ = std::fs::remove_dir_all(base); +} + +/// Makes `link` lead to the folder `target`: a junction on Windows, a symbolic link elsewhere. +/// `false` when the system does not allow it here. +fn link_folder(target: &Path, link: &Path) -> bool { + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().is_ok_and(|o| o.status.success()); + made && std::fs::canonicalize(link).ok() == std::fs::canonicalize(target).ok() + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link).is_ok() + } + #[cfg(not(any(unix, windows)))] + { + let _ = (target, link); + false + } +} + +#[test] +fn script_include_link_out_of_the_folder_is_refused() { + let base = folder("junction"); + write(&base.join("outside/x.jsxinc"), "var leaked = 1;"); + // `mklink` reads a `/` in its paths as a switch, so each name is joined on its own. + if !link_folder(&base.join("outside"), &base.join("main").join("away")) { + eprintln!("skipped: a folder link can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(std::fs::canonicalize(base.join("main/away/x.jsxinc")).unwrap().starts_with(std::fs::canonicalize(base.join("outside")).unwrap())); + let report = failed(&base, "#include \"away/x.jsxinc\"\nvc.log(leaked);"); + assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}"); + let report = failed(&base, "$.evalFile('away/x.jsxinc');"); + assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_include_parent_component_refused() { + let base = folder("parent"); + write(&base.join("outside.jsxinc"), "var leaked = 1;"); + write(&base.join("main/sub/x.jsxinc"), "var x = 1;"); + let report = failed(&base, "#include \"../outside.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("script file read refused: ../outside.jsxinc"), "{report}"); + // Refused even where the path would come back inside the folder. + let report = failed(&base, "#include \"sub/../sub/x.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("script file read refused"), "{report}"); + let report = failed(&base, "#includepath \"..\"\n#include \"outside.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("script file read refused: outside.jsxinc"), "{report}"); + let absolute = base.join("outside.jsxinc").to_string_lossy().replace('\\', "/"); + let report = failed(&base, &format!("#include \"{absolute}\"")); + assert!(report["message"].as_str().unwrap().contains("script file read refused"), "an absolute path outside: {report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_jsinclude_outside_grants_ignored() { + let base = folder("jsinclude"); + write(&base.join("outside/x.jsxinc"), "var x = 'outside';"); + write(&base.join("main/lib/y.jsxinc"), "var y = 'lib';"); + TEST_JSINCLUDE.with(|t| *t.borrow_mut() = Some(vec![base.join("outside"), base.join("main/lib")])); + let report = failed(&base, "#include \"x.jsxinc\""); + let r = run(&base, "#include \"y.jsxinc\"\nvc.log(y);"); + TEST_JSINCLUDE.with(|t| *t.borrow_mut() = None); + assert!(report["message"].as_str().unwrap().contains("include file not found: x.jsxinc"), "passed over, not refused: {report}"); + assert_eq!(r.unwrap()["log"], json!(["lib"]), "a JSINCLUDE folder inside the script's folder is searched"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_target_other_app_does_not_route() { + let mut s = Session::new(); + let r = s.execute("script.run", &json!({"source": "#target someapp-12\n#target \"other\"\n//@target x\n#strict off\nvc.log(1);"})).unwrap(); + assert_eq!(r["log"], json!([1])); + for (source, message) in [ + ("#target\nvc.log(1);", "#target needs a value"), + ("#targetengine \"session\"", "unsupported script directive: #targetengine"), + ("#engine \"main\"", "unsupported script directive: #engine"), + ("#strict maybe", "#strict takes on or off"), + ("#frobnicate 1", "unknown script directive: #frobnicate"), + ("#include \"x.jsxinc", "unterminated quote in #include"), + ("#include \"x.jsxinc\"", "script file read refused: x.jsxinc"), + ] { + let e = s.execute("script.run", &json!({"source": format!("vc.log(0);\n{source}")})).unwrap_err().to_string(); + assert!(e.contains(message), "{source}: {e}"); + assert_eq!(s.script_report().unwrap()["line"], 2, "{source}"); + assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}"); + } + // An unknown `//@` name stays a comment. + assert_eq!(s.execute("script.run", &json!({"source": "//@frobnicate 1\nvc.log(2);"})).unwrap()["log"], json!([2])); +} + +#[test] +fn script_name_comes_from_the_script_line() { + let mut s = Session::new(); + let r = s.execute("script.run", &json!({"source": "#script \"First\"\n#script Second\nvc.log($.fileName);"})).unwrap(); + assert_eq!((r["name"].as_str(), r["log"].clone()), (Some("Second"), json!([""]))); + let r = s.execute("script.run", &json!({"source": "#script Second\n", "name": "given.js"})).unwrap(); + assert_eq!(r["name"], "given.js", "a name given to script.run wins"); + let base = folder("name"); + write(&base.join("main/lib.jsxinc"), "#script Inner\n"); + let r = run(&base, "#include \"lib.jsxinc\"").unwrap(); + assert_eq!(r["name"], "main.jsx", "an included file's #script line does not rename the run"); + let _ = std::fs::remove_dir_all(base); +} + +/// The script's `ro`, with a read-only `x`. +const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n"; + +#[test] +fn strict_on_runs_and_read_only_write_throws() { + let mut s = Session::new(); + for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] { + let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);"); + let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string(); + assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}"); + let report = s.script_report().unwrap(); + assert_eq!(report["kind"], "thrown", "{head}"); + assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write"); + let line = head.lines().count() as u64 + 4; + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}"); + } + // The error can be caught, and every kind of assignment is checked. + let source = format!( + "#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);" + ); + let r = s.execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1])); + // Without the line, or with `#strict off` last, the write is ignored as before. + for head in ["", "#strict off\n", "#strict on\n#strict off\n"] { + let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap(); + assert_eq!(r["log"], json!([1]), "{head}"); + } +} + +#[test] +fn strict_on_rejects_getter_only_and_nonextensible_additions() { + let mut s = Session::new(); + let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\ + var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\ + t(function () { g.v = 2; });\n\ + t(function () { Object.preventExtensions({}).n = 1; });\n\ + t(function () { Object.seal({a: 1}).n = 1; });\n\ + t(function () { Object.freeze({a: 1}).a = 2; });\n\ + t(function () { vc.extra = 1; });\n\ + var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\ + vc.log(out);\nvc.log(open.n);\nvc.log(set.got);"; + let r = s.execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!( + r["log"], + json!([ + [ + "TypeError: cannot assign to property 'v': it has only a getter", + "TypeError: cannot assign to property 'n': the object is not extensible", + "TypeError: cannot assign to property 'n': the object is not extensible", + "TypeError: cannot assign to property 'a': it is read-only", + "TypeError: cannot assign to property 'extra': the object is not extensible", + "wrote", + "wrote", + "wrote" + ], + 1, + 3 + ]) + ); + let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap(); + assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused"); +} + +#[test] +fn strict_on_does_not_enable_es5_early_errors() { + let mut s = Session::new(); + let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\ + vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());"; + let r = s.execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6])); + // 'use strict' still makes the code strict mode code. + let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string(); + assert!(e.contains("ReferenceError"), "{e}"); + let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string(); + assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}"); +} + +#[test] +fn strict_on_in_include_controls_expanded_run() { + let base = folder("strict"); + write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;"); + write(&base.join("main/off.jsxinc"), "//@strict off\n"); + write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n")); + // An included file's line sets the checks for the script's own code too. + let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;")); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + assert_eq!(report["line"], 4, "{report}"); + assert!(report.get("file").is_none(), "{report}"); + // A write in an included file is reported at its own line. + let report = failed(&base, "#strict on\n#include \"writes.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}"); + // The last line of the expanded run wins, wherever it is. + let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap(); + assert_eq!(r["log"], json!([1])); + let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;")); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn strict_mode_in_eval_file_is_scoped() { + let base = folder("strict-eval"); + write( + &base.join("main/on.jsx"), + &format!( + "#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n" + ), + ); + write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';")); + write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';")); + // A file's own `#strict on` holds for its code, its functions included, and not the caller's. + let r = run( + &base, + "$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);", + ) + .unwrap(); + assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]])); + // A file without the line takes the caller's checks, and its own `#strict off` turns them off. + let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap(); + assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks"); + let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');"); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}"); + assert_eq!(report["line"], 3, "{report}"); + assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_eval_file_returns_last_value_and_shares_globals() { + let base = folder("evalfile"); + write(&base.join("main/sub/e.jsx"), "var inner = $.fileName;\nfunction h(x) { return x * g; }\n#include \"f.jsxinc\"\ng + f;"); + write(&base.join("main/sub/f.jsxinc"), "var f = 40;"); + write(&base.join("main/empty.jsx"), ""); + let r = run( + &base, + "var g = 2;\nvar r = $.evalFile('sub/e.jsx', 1000);\nvc.log(r);\nvc.log(h(3));\nvc.log(/e\\.jsx$/.test(inner));\nvc.log(/main\\.jsx$/.test($.fileName));\nvc.log($.evalFile('empty.jsx') === undefined);", + ) + .unwrap(); + assert_eq!(r["log"], json!([42, 6, true, true, true])); + // Its errors can be caught; a file is evaluated again after it has returned. + let r = run( + &base, + "var g = 1; var out = [];\ntry { $.evalFile(''); } catch (e) { out.push(e.name); }\ntry { $.evalFile('nope.jsx'); } catch (e) { out.push(String(e)); }\ntry { $.evalFile('sub/e.jsx', -1); } catch (e) { out.push(e.name); }\ntry { $.evalFile('sub/e.jsx', Infinity); } catch (e) { out.push(e.name); }\nout.push($.evalFile('sub/e.jsx') + $.evalFile('sub/e.jsx'));\nvc.log(out);", + ) + .unwrap(); + assert_eq!(r["log"], json!([["TypeError", "Error: include file not found: nope.jsx", "RangeError", "TypeError", 82]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_eval_file_outside_scope_refused() { + let base = folder("evaloutside"); + write(&base.join("outside.jsx"), "1;"); + let absolute = base.join("outside.jsx").to_string_lossy().replace('\\', "/"); + let script = format!( + "var out = [];\ntry {{ $.evalFile('../outside.jsx'); }} catch (e) {{ out.push(String(e)); }}\ntry {{ $.evalFile('{absolute}'); }} catch (e) {{ out.push(String(e)); }}\nvc.log(out);" + ); + let r = run(&base, &script).unwrap(); + let out = r["log"][0].as_array().unwrap(); + assert_eq!(out.len(), 2); + for e in out { + assert!(e.as_str().unwrap().starts_with("Error: script file read refused"), "{e}"); + } + // A script given as text has no folder to read from. + let e = Session::new().execute("script.run", &json!({"source": "$.evalFile('x.jsx');"})).unwrap_err().to_string(); + assert!(e.contains("script file read refused: x.jsx"), "{e}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_directives_in_strings_and_block_comments_are_inert() { + let mut s = Session::new(); + let source = "#target x\nvar a = '#include \"nope.jsxinc\"';\n/*\n#include \"nope.jsxinc\"\n//@include \"nope.jsxinc\"\n*/\nvar b = 1; // #include \"nope.jsxinc\"\nvar c = 2; #include\nvc.log(a.length + b);"; + let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string(); + // `#` after code on a line is not a preprocessor line: the script has a syntax error there. + assert!(e.contains("line 8"), "{e}"); + let source = source.replace("var c = 2; #include\n", ""); + assert_eq!(s.execute("script.run", &json!({"source": source})).unwrap()["log"], json!([23])); +} + +#[test] +fn script_included_error_has_local_file_and_line() { + let base = folder("errors"); + write(&base.join("main/lib.jsxinc"), "var x = 1;\n\n throw new Error('boom');\n"); + write(&base.join("main/bad.jsxinc"), "var y = 1;\nvar = 2;\n"); + write(&base.join("main/five.jsxinc"), "1;\n2;\n3;\n4;\n5;"); + write(&base.join("main/sub/thrower.jsx"), "\n\nthrow new Error('deep');"); + let mut s = Session::new(); + let report = failed(&base, "var z = 0;\n#include \"lib.jsxinc\""); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(5)), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{report}"); + let report = failed(&base, "#include \"bad.jsxinc\""); + assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("bad.jsxinc"), "{report}"); + // A line of the script after an include keeps its own number. + let report = failed(&base, "#include \"five.jsxinc\"\n throw new Error('after');"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(3)), "{report}"); + assert!(report.get("file").is_none(), "{report}"); + let report = failed(&base, "#include \"five.jsxinc\"\nvar = 1;"); + assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}"); + // An included file that ends in a carriage return does not join the script's next line ending. + write(&base.join("main/cr.jsxinc"), "1;\r2;\r"); + let report = failed(&base, "#include \"cr.jsxinc\"\n\n throw new Error('after');"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(3)), "{report}"); + assert!(report.get("file").is_none(), "{report}"); + let report = failed(&base, "#include \"cr.jsxinc\"\r\nvar = 1;"); + assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}"); + // An uncaught error in a file $.evalFile ran is at that file's line. + let report = failed(&base, "var a = 1;\n$.evalFile('sub/thrower.jsx');"); + assert_eq!(report["line"], 3, "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("thrower.jsx"), "{report}"); + // So is a file $.evalFile could not run: a syntax error, or a bad preprocessor line. + write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;"); + write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1"); + for (file, line) in [("broken.jsx", 3), ("directive.jsx", 2)] { + let report = failed(&base, &format!("var a = 1;\n$.evalFile('sub/{file}');")); + assert_eq!(report["line"], line, "{report}"); + assert!(report["file"].as_str().unwrap().ends_with(file), "{report}"); + } + // The console's error line names the file. + let main = base.join("main/main.jsx"); + write(&main, "#include \"lib.jsxinc\""); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let console = s.execute("script.console", &json!({})).unwrap(); + let error = console["lines"].as_array().unwrap().iter().find(|l| l["kind"] == "error").unwrap()["text"].as_str().unwrap().to_string(); + assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}"); + let _ = std::fs::remove_dir_all(base); +} + +/// Pumps the run in the app until it ends or waits at a dialog (`None`). +fn pump(s: &mut Session) -> Option> { + for _ in 0..10_000 { + if s.script_dialog().is_some() { + return None; + } + if let Some(r) = s.script_pump() { + return Some(r); + } + } + panic!("the run neither ended nor waited"); +} + +#[test] +fn script_dialogs_in_included_and_evaluated_files() { + let base = folder("dialogs"); + write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\n"); + write(&base.join("main/ev.jsx"), "alert('ev'); 7"); + let text = "#include \"ask.jsxinc\"\nvar m = '';\ntry { m = $.evalFile('ev.jsx'); } catch (e) { m = e.message; }\nvc.log([a, m]);"; + let main = base.join("main/main.jsx"); + write(&main, text); + let path = main.to_string_lossy().to_string(); + // In the app, an included file's dialog waits as the script's own does; one in a file + // $.evalFile runs cannot wait there and throws an error the script can catch. + let mut s = Session::new(); + s.script_frames = true; + assert_eq!(s.execute("script.run", &json!({"path": path})).unwrap()["running"], true); + assert!(pump(&mut s).is_none()); + assert_eq!(s.script_dialog().unwrap().message, "inc"); + assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true))); + let r = pump(&mut s).unwrap().unwrap(); + let m = r["log"][0][1].as_str().unwrap_or_default(); + assert_eq!(r["log"][0][0], true, "{r}"); + assert!(m.contains("alert") && m.contains("$.evalFile"), "the error names the call and why: {r}"); + assert_eq!(r["dialogCount"], 1, "{r}"); + // A run with nobody to ask takes both answers in order. + let mut s = Session::new(); + let answers = json!([{"kind": "confirm", "value": false}, {"kind": "alert", "value": "ok"}]); + let r = s.execute("script.run", &json!({"path": path, "answers": answers})).unwrap(); + assert_eq!((r["log"].clone(), r["dialogCount"].clone()), (json!([[false, 7]]), json!(2)), "{r}"); + // An error in an included file after its dialog is at that file's line, and the report keeps + // both the file and the dialog. + write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\nif (a) throw new Error('after');\n"); + let mut s = Session::new(); + s.script_frames = true; + s.execute("script.run", &json!({"path": path})).unwrap(); + assert!(pump(&mut s).is_none()); + assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true))); + assert!(pump(&mut s).unwrap().is_err()); + let report = s.script_report().unwrap(); + assert_eq!(report["line"], 2, "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("ask.jsxinc"), "{report}"); + assert_eq!(report["dialogs"][0]["answer"], true, "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +/// The line, column and file name of a failed run's report, and its console error line (the only +/// line of a run without output that starts with the script's name). +fn place(report: &Value) -> (u64, u64, Option<&str>, &str) { + let file = report.get("file").map(|f| f.as_str().unwrap().rsplit(['/', '\\']).next().unwrap()); + let lines = report["console"]["lines"].as_array().unwrap(); + let error = lines.iter().filter_map(Value::as_str).find(|l| l.starts_with("main.jsx")).unwrap(); + (report["line"].as_u64().unwrap(), report["column"].as_u64().unwrap(), file, error) +} + +#[test] +fn script_eval_file_error_caught_and_thrown_again_is_at_the_new_throw() { + let base = folder("rethrow"); + write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;"); + write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1"); + write(&base.join("main/sub/rethrow.jsx"), "var r = 1;\n throw saved;"); + write(&base.join("main/sub/outer.jsx"), "var q = 1;\n$.evalFile('broken.jsx');"); + write(&base.join("main/sub/outer2.jsx"), "try { $.evalFile('broken.jsx'); } catch (e) {\n\n throw e;\n}"); + let caught = "var saved;\ntry { $.evalFile('sub/broken.jsx'); } catch (e) { saved = e; }\n"; + let cases = [ + // Thrown again in the script, or in another file $.evalFile runs. + (format!("{caught}var b = 2;\n throw saved;"), (4, 3, None)), + (format!("{caught}$.evalFile('sub/rethrow.jsx');"), (2, 3, Some("rethrow.jsx"))), + // A new error whose message holds the failure's text, or is the same text. + (format!("{caught}throw new Error(saved.message);"), (3, 1, None)), + (format!("{caught}throw new SyntaxError(saved.message);"), (3, 1, None)), + // A second failure, caught, does not move the first one. + (format!("{caught}try {{ $.evalFile('sub/directive.jsx'); }} catch (e) {{}}\n throw saved;"), (4, 2, None)), + // A second failure, uncaught, is at its own file's place. + (format!("{caught}$.evalFile('sub/directive.jsx');"), (2, 1, Some("directive.jsx"))), + // An uncaught failure is at the file's place, through a finally block and from a nested file. + ("var a = 1;\ntry { $.evalFile('sub/broken.jsx'); } finally { a = 2; }".to_string(), (3, 5, Some("broken.jsx"))), + ("$.evalFile('sub/outer.jsx');".to_string(), (3, 5, Some("broken.jsx"))), + // Caught in a nested file and thrown again there. + ("$.evalFile('sub/outer2.jsx');".to_string(), (3, 5, Some("outer2.jsx"))), + // A failure caught in a run after one that failed with it, and a different error. + ("try { $.evalFile('sub/broken.jsx'); } catch (e) {}\nthrow new Error('other');".to_string(), (2, 1, None)), + ]; + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + for (text, (line, column, file)) in cases { + write(&main, &text); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err(), "{text}"); + let report = s.script_report().unwrap().clone(); + let (l, c, f, error) = place(&report); + assert_eq!((report["kind"].as_str(), l, c, f), (Some("thrown"), line, column, file), "{text}\n{report}"); + let at = match file { + Some(f) => format!("{f}:{line}:{column}: "), + None => format!("main.jsx:{line}:{column}: "), + }; + assert!(error.starts_with("main.jsx") && error.contains(&at), "{error}"); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_error_that_passes_through_a_finally_block_is_at_its_throw() { + let base = folder("finally"); + let main = base.join("main/main.jsx"); + let mut s = Session::new(); + for text in ["try {\n throw new Error('x');\n} finally {\n var z=1;\n}", "try {\n null();\n} finally {\n var z=1;\n}"] { + write(&main, text); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err(), "{text}"); + let report = s.script_report().unwrap().clone(); + let (l, c, f, error) = place(&report); + assert_eq!((report["kind"].as_str(), l, c, f), (Some("thrown"), 2, 3, None), "{text}\n{report}"); + assert!(error.contains("main.jsx:2:3: "), "{error}"); + } + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs new file mode 100644 index 000000000..aae4810da --- /dev/null +++ b/crates/engine/src/tests/script_io.rs @@ -0,0 +1,407 @@ +//! `File` and `Folder` in a script: their classes and names, reading inside the script's own folder, +//! the paths refused before anything is looked up, the read limits, and the parts that write, which +//! are not available. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::file_access::{AutomationRoots, confine, script_probes}; +use crate::*; + +const DENIED: &str = "Script file access denied"; + +/// A fresh folder for test `name`, with a `main` folder inside it for the script. +fn folder(name: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("vc-script-io-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("main")).unwrap(); + base +} + +fn write(path: &Path, bytes: &[u8]) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, bytes).unwrap(); +} + +/// Runs `main/main.jsx` in `base`, with the text `text`. +fn run(base: &Path, text: &str) -> Value { + let main = base.join("main/main.jsx"); + write(&main, text.as_bytes()); + Session::new().execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap() +} + +/// The values the run of `text` logged. +fn logged(base: &Path, text: &str) -> Value { + run(base, text)["log"].clone() +} + +#[test] +fn file_constructor_identity_and_folder_path() { + let base = folder("identity"); + write(&base.join("main/data.txt"), b"x"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var a = new File('data.txt'), b = new File('data.txt');\n\ + vc.log([a === b, a instanceof File, a instanceof Folder, File('sub') instanceof Folder, new File('sub') instanceof File,\n\ + Folder('data.txt') instanceof File, new Folder('data.txt') instanceof Folder, File('nope') instanceof File,\n\ + a.parent instanceof Folder, a.name, a.parent.fsName === Folder.current.fsName, String(a) === a.absoluteURI,\n\ + a.exists, new File('nope').exists, a.length, a.created instanceof Date, Folder('sub').exists, new File().exists]);", + ); + assert_eq!(log, json!([[false, true, false, true, true, true, true, true, true, "data.txt", true, true, true, false, 1, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn file_names_are_uri_encoded_and_current_relative() { + let base = folder("names"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var f = new File('caf%C3%A9 x.txt');\n\ + var sep = File.fs === 'Windows' ? '\\\\' : '/';\n\ + vc.log([f.name, f.displayName, f.relativeURI, f.fsName === Folder.current.fsName + sep + 'caf\\u00e9 x.txt',\n\ + File.encode('\\u00e9 /'), File.decode('%C3%A9'), new File('sub/../sub/./y.txt').relativeURI,\n\ + new File('~/z').fsName === new Folder('~').fsName + sep + 'z', new File('a/b').path === new Folder('a').absoluteURI]);\n\ + Folder.current = new Folder('sub');\n\ + vc.log([new File('w').parent.name, new File('w').relativeURI, new File('../w').getRelativeURI(Folder.current.parent)]);\n\ + var l = new File('literal%2520name.txt'); Folder.current = new Folder('p%2541');\n\ + vc.log([l.displayName, l.name, l.absoluteURI.slice(-20), l.fsName === new File(l).fsName, Folder.current.displayName, new File('x').parent.displayName]);", + ); + assert_eq!( + log, + json!([ + ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true], + ["sub", "w", "w"], + ["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"] + ]) + ); + if cfg!(windows) { + let log = logged(&base, "vc.log([new File('/c/a b/x.txt').fsName, new File('C:/a/x.txt').absoluteURI, new File('d:\\\\q').fsName]);"); + assert_eq!(log, json!([["C:\\a b\\x.txt", "/c/a/x.txt", "D:\\q"]])); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_without_scope_cannot_probe_home() { + let base = folder("home"); + let source = "var f = File('~/.ssh/id_test'); var g = new Folder('~');\n\ + vc.log([f instanceof File, f.exists, f.open('r'), f.error, g.getFiles(), g.error, f.modified, f.length]);"; + let denied = json!([[true, false, false, "Script file access denied", null, "Script file access denied", null, 0]]); + let before = script_probes(); + let r = Session::new().execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], denied, "a script given as text reads nothing"); + assert_eq!(script_probes(), before, "nothing was looked up"); + // A script run from a file is held to its folder. + let before = script_probes(); + assert_eq!(logged(&base, source), denied); + assert_eq!(script_probes(), before, "the home folder lies outside the script's folder by its names alone"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_unc_rejected_before_lookup() { + let base = folder("unc"); + let before = script_probes(); + let log = logged( + &base, + "var out = [];\n\ + var paths = ['\\\\\\\\host\\\\s\\\\x.txt', '//host/s/x.txt', '\\\\\\\\?\\\\C:\\\\x.txt', '\\\\\\\\.\\\\pipe\\\\x'];\n\ + for (var i = 0; i < paths.length; i++) { var f = new File(paths[i]); out.push(f.exists, f.open('r'), new Folder(paths[i]).getFiles()); }\n\ + vc.log(out);", + ); + assert_eq!(log, json!([[false, false, null, false, false, null, false, false, null, false, false, null]])); + if cfg!(windows) { + assert_eq!(script_probes(), before, "no network or device path was looked up"); + } else { + // Outside Windows the backslash forms are relative file names in the script's folder, and + // `//host/s/x.txt` is an absolute path outside it. + let before = script_probes(); + let log = logged(&base, "var f = new File('//host/s/x.txt'); vc.log([f.exists, f.open('r'), new Folder('//host/s/x.txt').getFiles()]);"); + assert_eq!(log, json!([[false, false, null]])); + assert_eq!(script_probes(), before, "a path outside the script's folder was not looked up"); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_link_escape_refused() { + let base = folder("links"); + write(&base.join("outside/secret.txt"), b"secret"); + write(&base.join("main/own.txt"), b"own"); + std::fs::create_dir_all(base.join("main/inner")).unwrap(); + // A folder link: a symbolic link, or on Windows a junction, which needs no privilege. + let link_folder = |target: &Path, link: &Path| { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap(); + assert!(made.status.success(), "mklink /J: {}", String::from_utf8_lossy(&made.stderr)); + } + }; + link_folder(&base.join("outside"), &base.join("main").join("away")); + link_folder(&base.join("main").join("inner"), &base.join("main").join("near")); + let log = logged( + &base, + "var away = new Folder('away'); var s = new File('away/secret.txt');\n\ + vc.log([away.exists, away.getFiles(), away.error, away.resolve(), s.exists, s.open('r'), s.error,\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new Folder('near').alias, new Folder('near').resolve().name]);", + ); + assert_eq!(log, json!([[false, null, DENIED, null, false, false, DENIED, ["inner", "main.jsx", "near", "own.txt"], true, "inner"]])); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| std::os::windows::fs::symlink_file(target, link).is_ok(); + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make a link to a file. + if !made(&base.join("outside/secret.txt"), &base.join("main/leak.txt")) { + eprintln!("skipped: links to files can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(made(&base.join("main/own.txt"), &base.join("main/alias.txt"))); + let log = logged( + &base, + "var leak = new File('leak.txt');\n\ + vc.log([leak.exists, leak.open('r'), leak.error, leak.resolve(), new File('away/secret.txt').exists, new Folder('away').getFiles(),\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new File('alias.txt').alias, new File('alias.txt').resolve().name]);", + ); + assert_eq!(log, json!([[false, false, DENIED, null, false, null, ["alias.txt", "inner", "main.jsx", "near", "own.txt"], true, "own.txt"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_oversize_refused_before_read() { + let base = folder("oversize"); + let big = std::fs::File::create(base.join("main/big.bin")).unwrap(); + big.set_len(9 << 20).unwrap(); + drop(big); + write(&base.join("main/small.txt"), b"ok"); + let r = + run(&base, "var f = new File('big.bin'); var s = new File('small.txt'); vc.log([f.length, f.open('r'), f.error, s.open('r'), s.read()]);"); + assert_eq!(r["log"], json!([[9437184, false, "Script file exceeds 8 MiB", true, "ok"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_read_at_most_32_mib_in_a_run() { + let base = folder("total"); + write(&base.join("main/seven.bin"), &vec![b'a'; 7 << 20]); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"var f = new File('seven.bin'); for (var i = 0; i < 5; i++) { f.open('r'); vc.log(i); }"); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})); + assert!(e.is_err(), "the fifth open passes 32 MiB"); + let report = s.script_report().unwrap(); + assert_eq!(report["log"], json!([0, 1, 2, 3])); + assert!(report["message"].as_str().unwrap().contains("32 MiB"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_have_at_most_256_open() { + let base = folder("open"); + write(&base.join("main/empty.txt"), b""); + let log = logged( + &base, + "var all = [], fails = 0, error = '';\n\ + for (var i = 0; i < 300; i++) { var f = new File('empty.txt'); if (f.open('r')) all.push(f); else { fails++; error = f.error; } }\n\ + var closed = all[0].close(); var again = new File('empty.txt');\n\ + vc.log([all.length, fails, error, closed, again.open('r'), all[1].open('r'), new File('empty.txt').open('r')]);", + ); + assert_eq!(log, json!([[256, 44, "Script has 256 files open", true, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_folder_getfiles_stays_inside_scope() { + let base = folder("list"); + for name in ["a.jsx", "b.txt", "C.JSX"] { + write(&base.join("main").join(name), b""); + } + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + write(&base.join("outside.jsx"), b""); + let log = logged( + &base, + "var d = Folder.current; var names = function (l) { var o = []; for (var i = 0; i < l.length; i++) o.push(l[i].name); return o; };\n\ + vc.log(names(d.getFiles('*.jsx')));\n\ + vc.log(names(d.getFiles(function (f) { return f instanceof Folder; })));\n\ + var up = d.parent; vc.log([names(d.getFiles('?.txt')), d.getFiles().length, up.getFiles(), up.error]);\n\ + try { d.getFiles(function () { throw new Error('stop'); }); } catch (e) { vc.log(e.message); }\n\ + vc.log([new Folder('nope').getFiles(), d.getFiles('*.jsx')[0] instanceof File]);", + ); + assert_eq!(log, json!([["C.JSX", "a.jsx", "main.jsx"], ["sub"], [["b.txt"], 5, null, DENIED], "stop", [null, true]])); + let many = base.join("main/many"); + for i in 0..=crate::script::files::MAX_ENTRIES { + write(&many.join(format!("{i:04}.txt")), b""); + } + let log = logged(&base, "var m = new Folder('many'); vc.log([m.getFiles(), m.error, m.getFiles('0001.*').length]);"); + assert_eq!(log, json!([[null, "Script folder has more than 2,048 entries", 1]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_bom_and_line_read() { + let base = folder("lines"); + write(&base.join("main/lines.txt"), b"\xEF\xBB\xBFone\r\ntwo\n\rthree\rfour\nfin"); + write(&base.join("main/e.txt"), "\u{e9}t\u{e9}".as_bytes()); + let log = logged( + &base, + "var f = new File('lines.txt'); var out = [f.eof, f.open('r'), f.encoding, f.tell()];\n\ + var lines = []; while (!f.eof) lines.push(f.readln()); out.push(lines, f.tell());\n\ + out.push(f.seek(3), f.read(3), f.seek(-3, 2), f.read(), f.seek(1, 1), f.seek(100), f.seek(-1), f.tell());\n\ + f.seek(6); out.push(f.readch(), f.readch(), f.tell(), f.close(), f.read(), f.error, f.eof);\n\ + var e = new File('e.txt'); e.open(); out.push(e.read(1), e.tell(), e.read(5), e.tell());\n\ + vc.log(out);", + ); + assert_eq!( + log, + json!([[ + true, + true, + "UTF-8", + 3, + ["one", "two", "three", "four", "fin"], + 27, + true, + "one", + true, + "fin", + false, + false, + false, + 27, + "\n", + "t", + 9, + true, + "", + "Script file is not open", + true, + "\u{e9}", + 2, + "t\u{e9}", + 5 + ]]) + ); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_encoding_binary_ascii() { + let base = folder("encodings"); + write(&base.join("main/bytes.bin"), &[0xE8, 0x41]); + write(&base.join("main/euro.txt"), &[0x80]); + let log = logged( + &base, + "var b = new File('bytes.bin'); b.encoding = 'BINARY'; b.open('r'); var t = b.read();\n\ + var a = new File('bytes.bin'); a.encoding = 'ASCII'; a.open('r');\n\ + var out = [t.charCodeAt(0), t.charAt(1), b.encoding, a.read(), a.error, a.tell()];\n\ + a.encoding = 'binary'; out.push(a.encoding, a.read().length);\n\ + var c = new File('euro.txt'); c.open('r'); out.push(c.encoding, c.read().charCodeAt(0));\n\ + var k = new File('bytes.bin'); k.encoding = 'KLINGON'; out.push(k.open('r'), k.error);\n\ + out.push(File.isEncodingAvailable('utf-8'), Folder.isEncodingAvailable('BINARY'), File.isEncodingAvailable('KLINGON'));\n\ + vc.log(out);\n\ + var m = new File('bytes.bin'); m.encoding = 'BINARY'; m.open('r'); m.encoding = 'KLINGON'; vc.log([m.error, m.encoding, m.read().length]);\n\ + var r = new File('bytes.bin'); r.open('r'); r.encoding = 'ASCII'; r.close(); vc.log([r.encoding, r.open('r'), r.read(), r.error]);", + ); + let undecodable = "Script file cannot be decoded"; + assert_eq!(log[0], json!([232, "A", "BINARY", "", undecodable, 0, "BINARY", 2, "CP1252", 0x20AC, false, undecodable, true, true, false])); + assert_eq!(log[1], json!([undecodable, "BINARY", 2]), "a name that is not an encoding leaves the open file's encoding"); + assert_eq!(log[2], json!(["ASCII", true, "", undecodable]), "an encoding set while open is kept after close"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_writes_dialogs_and_execute_are_not_available() { + let base = folder("writes"); + write(&base.join("main/keep.txt"), b"keep"); + let r = run( + &base, + "var f = new File('keep.txt'); var n = new File('new.txt');\n\ + var out = [n.open('w'), n.error, f.open('a'), f.open('e'), f.open('x'), f.error, f.write('x'), f.remove(), f.copy('c.txt'), f.rename('r.txt')];\n\ + out.push(new Folder('made').create(), f.openDlg(), f.error, File.openDialog(), Folder.selectDialog(), f.execute(), f.error);\n\ + f.hidden = true; out.push(f.error, f.hidden); f.error = ''; out.push(f.error);\n\ + vc.log(out);", + ); + assert_eq!( + r["log"], + json!([[ + false, + "Script file writing is not available", + false, + false, + false, + "Script file open mode must be r, w, e or a", + false, + false, + false, + false, + false, + null, + "Script file dialogs are not available", + null, + null, + false, + "Script files cannot be opened in another application", + "Script file writing is not available", + false, + "" + ]]) + ); + assert_eq!(r["hostCallCounts"]["fileExecutes"], 1); + assert!(r["hostCallCounts"]["fileCalls"].as_u64().unwrap() > 0); + assert_eq!(std::fs::read(base.join("main/keep.txt")).unwrap(), b"keep"); + assert!(!base.join("main/new.txt").exists() && !base.join("main/made").exists() && !base.join("main/c.txt").exists()); + let _ = std::fs::remove_dir_all(base); +} + +/// A run with automation roots in force reads inside its folder and the read roots, and a run given +/// as text reads nothing even where the roots allow it. +#[test] +fn script_files_read_inside_the_automation_read_roots() { + let base = folder("roots"); + write(&base.join("main/data.txt"), b"data"); + let source = "var f = new File('data.txt'); vc.log([f.exists, f.open('r'), f.error, f.read()]);"; + let roots = AutomationRoots::new(&[&base.join("main")], &[]).unwrap(); + assert_eq!(confine(roots.as_ref(), || logged(&base, source)), json!([[true, true, "", "data"]])); + let text = format!("var f = new File({:?}); vc.log([f.exists, f.error]);", base.join("main/data.txt").to_string_lossy()); + let r = confine(roots.as_ref(), || Session::new().execute("script.run", &json!({"source": text})).unwrap()); + assert_eq!(r["log"], json!([[false, "Script file access denied"]])); + let _ = std::fs::remove_dir_all(base); +} + +/// A path longer than 32,767 characters is refused before it is split into names. +#[test] +fn script_file_path_length_is_bounded() { + let base = folder("longpath"); + let log = logged( + &base, + "var s = 'a/'; while (s.length < 40000) s += s;\n\ + var out = []; try { new File(s); } catch (e) { out.push(e.name, e.message); }\n\ + try { new Folder('x').getRelativeURI(s); } catch (e) { out.push(e.name); }\n\ + out.push(new File(s.slice(0, 30000)).exists); vc.log(out);", + ); + assert_eq!(log, json!([["RangeError", "Script file path exceeds 32,767 characters", "RangeError", false]])); + let _ = std::fs::remove_dir_all(base); +} + +/// An error `File` or `Folder` throws is reported at the script's line that called it. +#[test] +fn script_file_errors_are_at_the_calling_line() { + let base = folder("errors"); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"\n\n\nnew File('x').read(-1);"); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let report = s.script_report().unwrap(); + assert!(report["message"].as_str().unwrap().contains("RangeError: File.read: the count must be a finite number"), "{report}"); + assert_eq!(report["line"].as_u64(), Some(4), "{report}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 5480ba499..8294aa638 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -166,7 +166,7 @@ fn an_error_names_the_script_its_place_and_kind() { let e = run(&mut s, "var = 1;").unwrap_err().to_string(); assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}"); assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`")); - assert!(run(&mut s, "#target x\n1").unwrap_err().to_string().contains("#target")); + assert!(run(&mut s, "#targetengine x\n1").unwrap_err().to_string().contains("unsupported script directive: #targetengine")); assert_eq!(s.script_report().unwrap()["line"], 1); } @@ -862,6 +862,44 @@ fn a_file_saved_before_a_failure_stays_written_and_is_named() { let _ = std::fs::remove_file(&path); } +/// A run in the app's frames keeps the file-access scope its `script.run` came in, whatever scope +/// the frames that advance it run in. +#[test] +fn a_framed_run_keeps_the_file_access_scope_it_started_in() { + let dir = std::env::temp_dir().join(format!("vc-script-scope-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (allowed, outside) = (dir.join("allowed"), dir.join("outside")); + std::fs::create_dir_all(&allowed).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&allowed], &[&allowed]).unwrap().unwrap(); + let export = |target: &std::path::Path| { + let p = target.to_string_lossy().into_owned(); + format!("try {{ vc.run('document.export', {{path: {p:?}, format: 'svg'}}); vc.log('ok'); }} catch (e) {{ vc.log(e.name); }}") + }; + let finish = |s: &mut Session| { + loop { + if let Some(r) = s.script_pump() { + return r; + } + } + }; + let mut s = two_docs(); + s.script_frames = true; + let confined_target = outside.join("confined.svg"); + let started = crate::file_access::confine(Some(&roots), || run(&mut s, &export(&confined_target))).unwrap(); + assert_eq!(started["running"], true); + let r = crate::file_access::unconfined(|| finish(&mut s)).unwrap(); + assert_eq!(r["log"], json!(["Error"]), "a confined caller's run stays confined: {r}"); + assert!(!confined_target.exists()); + let user_target = outside.join("user.svg"); + let started = crate::file_access::unconfined(|| run(&mut s, &export(&user_target))).unwrap(); + assert_eq!(started["running"], true); + let r = crate::file_access::confine(Some(&roots), || finish(&mut s)).unwrap(); + assert_eq!(r["log"], json!(["ok"]), "a run the person started is not confined by a later caller: {r}"); + assert!(user_target.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_committed_run_exports_what_it_drew() { let path = std::env::temp_dir().join(format!("vc-script-export-{}.svg", std::process::id())); @@ -951,6 +989,15 @@ fn source_text() -> impl proptest::strategy::Strategy { Just("{".to_string()), Just("try {".to_string()), Just("} catch (e) {}".to_string()), + Just("\n#include \"x.jsxinc\"\n".to_string()), + Just("\n//@include 'x\n".to_string()), + Just("\n#includepath \";;..;\"\n".to_string()), + Just("\n#script \"\"\n".to_string()), + Just("\n#target\n".to_string()), + Just("\n#strict on\n".to_string()), + Just("\n#\u{0}\n".to_string()), + Just("try { $.evalFile('x.jsx', NaN); } catch (e) {}".to_string()), + "\n#[a-z]{0,12}[ -~]{0,20}\n", "[ -~]{0,30}", "\\PC{0,10}", ]; diff --git a/crates/engine/src/tests/slices.rs b/crates/engine/src/tests/slices.rs index 87d0088d9..79b2cbe66 100644 --- a/crates/engine/src/tests/slices.rs +++ b/crates/engine/src/tests/slices.rs @@ -93,7 +93,7 @@ fn too_many_guide_pieces_are_refused() { let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); for i in 1..3000 { for vertical in [true, false] { - doc.guides.push(vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); + doc.add_ruler_guide(&vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); } } assert!(s.execute("object.slice.fromGuides", &json!({})).unwrap_err().to_string().contains("more than")); @@ -440,9 +440,9 @@ fn a_canceled_undo_scope_leaves_locked_or_hidden_slices_deselected() { /// Select slice `id` and the first ruler guide together, through [`Session::select`]. fn pick_slice_and_guide(s: &mut Session, id: u64) { - s.select(|_, sel| { + s.select(|d, sel| { sel.slices = vec![NodeId(id)]; - sel.guides = vec![0]; + sel.guides = d.ruler_guides().first().and_then(|g| g.id).into_iter().collect(); }) .unwrap(); } diff --git a/crates/engine/src/tests/smartguides.rs b/crates/engine/src/tests/smartguides.rs index 8bb6a0dfb..7e2741c64 100644 --- a/crates/engine/src/tests/smartguides.rs +++ b/crates/engine/src/tests/smartguides.rs @@ -89,6 +89,37 @@ fn a_pen_anchor_lines_up_with_another_objects_centre() { assert!((pts[2] - Point::new(100.0, 381.0)).hypot() < 1e-9, "{pts:?}"); } +/// View › Pixel Preview turns Smart Guides off in the tools: a moved square no longer lines up with +/// the other square's top and shows no guide, and a Rectangle tool corner no longer lands on its +/// anchor. +#[test] +fn pixel_preview_turns_smart_guides_off() { + for pixel in [false, true] { + let view = ViewInfo { pixel_preview: pixel, ..ViewInfo::default() }; + let mut s = session(); + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + s.select_tool("selection", view).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 425.0, 325.0), view).unwrap(); + for y in [300.0, 250.0, 200.0, 150.0, 128.0] { + s.pointer(&PointerEvent::new(PointerKind::Drag, 425.0, y), view).unwrap(); + } + let guides = s.overlays(view).into_iter().filter(|o| matches!(o, Overlay::Line { .. } | Overlay::Label { .. })).count(); + s.pointer(&PointerEvent::new(PointerKind::Up, 425.0, 128.0), view).unwrap(); + let top = s.doc().unwrap().doc.node(NodeId(b)).unwrap().geometric_bounds().unwrap().y0; + assert_eq!((top, guides > 0), if pixel { (103.0, false) } else { (100.0, true) }, "Pixel Preview {pixel}"); + + s.select_tool("rectangle", view).unwrap(); + for (kind, x, y) in + [(PointerKind::Down, 302.0, 297.0), (PointerKind::Drag, 250.0, 250.0), (PointerKind::Drag, 202.0, 203.0), (PointerKind::Up, 202.0, 203.0)] + { + s.pointer(&PointerEvent::new(kind, x, y), view).unwrap(); + } + let r = made(&s); + assert_eq!((r.x0, r.y0), if pixel { (202.0, 203.0) } else { (200.0, 200.0) }, "Pixel Preview {pixel}"); + } +} + /// Preferences › Smart Guides › Construction Guides: on (the default), a Pen anchor near the /// 45° guide through the last one lands on it; off, it stays where it was clicked. #[test] diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index eca38d8e6..16d7f0199 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -260,6 +260,43 @@ fn a_change_that_cant_be_saved_is_taken_back() { run(&mut s, "text.spelling.removeWord", json!({"word": "wkqzvx"})); } +/// Edit Custom Dictionary's Done: its deletes and adds are saved together, or, when the save +/// fails, none of them is kept (a delete saved before an add that failed would stay otherwise). +#[test] +fn a_dictionary_edit_is_saved_whole_or_not_at_all() { + let _one_at_a_time = dictionary_lock(); + const EDIT: &str = "text.spelling.editDictionary"; + let dir = vectorcraft_testkit::temp_dir("spelling-edit"); + let file = dir.join("Custom Dictionary.txt"); + std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap(); + let mut s = session(); + s.attach_custom_dictionary(file.clone()); + // A word already gone and a word already there are skipped; added words are kept in lower case. + let r = run(&mut s, EDIT, json!({"remove": ["pqzvka", "pqzvkz"], "add": ["PqzvkC", "pqzvkb"]})); + assert_eq!(r, json!({"removed": ["pqzvka"], "added": ["pqzvkc"]})); + let saved = std::fs::read_to_string(&file).unwrap(); + let lines: Vec<&str> = saved.lines().collect(); + assert!(lines.contains(&"pqzvkb") && lines.contains(&"pqzvkc") && !lines.contains(&"pqzvka"), "{saved}"); + // A save that fails takes back every delete and add. + let folder = dir.join("folder"); + std::fs::create_dir_all(&folder).unwrap(); + let mut failing = session(); + failing.attach_custom_dictionary(folder); + assert!(failing.execute(EDIT, &json!({"remove": ["pqzvkb", "pqzvkc"], "add": ["pqzvkd"]})).is_err()); + assert!(in_dictionary(&mut s, "pqzvkb") && in_dictionary(&mut s, "pqzvkc"), "the deletes are taken back"); + assert!(!in_dictionary(&mut s, "pqzvkd"), "the add is taken back"); + // A list that isn't one of words is refused before anything changes. + let many: Vec = (0..10_001).map(|i| format!("pqzv{i}")).collect(); + for p in [json!({"remove": "pqzvkb"}), json!({"add": ["a b"]}), json!({"add": [5]}), json!({"remove": ["pqzvkb"], "add": many})] { + assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list"); + assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing"); + } + let e = s.execute(EDIT, &json!({"add": ["a b"]})).unwrap_err().to_string(); + assert!(e.contains("must hold single words of at most 64 characters"), "{e}"); + run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]})); + assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc")); +} + #[cfg(unix)] #[test] fn a_word_another_window_saved_stays_when_this_save_fails() { diff --git a/crates/engine/src/tests/store.rs b/crates/engine/src/tests/store.rs index 37eafe6e4..b6ac7b020 100644 --- a/crates/engine/src/tests/store.rs +++ b/crates/engine/src/tests/store.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::cms; use crate::Session; -use crate::store::{MAX_PRESET_FILE, file_stem}; +use crate::store::{MAX_PRESET_FILE, file_stem, holds}; fn fresh_dir(tag: &str) -> std::path::PathBuf { let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}")); @@ -128,6 +128,47 @@ fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() { assert_eq!(saved_names(&mut b), ["From Preferences"]); } +/// Serves `0` bytes without end and counts how many were read. +struct Endless(u64); + +impl std::io::Read for Endless { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.0 > 1 << 24 { + return Err(std::io::Error::other("read too far")); + } + buf.fill(0); + self.0 += buf.len() as u64; + Ok(buf.len()) + } +} + +#[test] +fn a_write_reads_the_file_it_replaces_only_one_byte_past_the_new_length() { + let mut old = Endless(0); + assert!(!holds(&mut old, &[0; 10])); + assert_eq!(old.0, 11); + assert!(holds(&[1, 2, 3][..], &[1, 2, 3])); + assert!(!holds(&[1, 2, 3, 4][..], &[1, 2, 3])); + assert!(!holds(&[1, 2][..], &[1, 2, 3])); + assert!(holds(&[][..], &[])); +} + +#[test] +fn a_saved_preset_replaces_an_oversized_file_in_its_place() { + let dir = fresh_dir("oversized-put").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); + let file = dir.join(format!("{}.vcpdfpresets", file_stem("Web"))); + let saved = std::fs::read(&file).unwrap(); + let mut oversized = saved.clone(); + oversized.resize(usize::try_from(MAX_PRESET_FILE).unwrap() * 2, b' '); + std::fs::write(&file, &oversized).unwrap(); + + a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), saved); +} + #[test] fn damaged_unsafe_and_oversized_preset_files_are_skipped() { let dir = fresh_dir("damaged").join("PDF Presets"); diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 31ceb2434..5124fb450 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -38,10 +38,12 @@ fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { #[test] fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() { - let mut s = Session::new(); + let mut s = session(); let list = run(&mut s, "graphicStyle.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert!(libs.len() >= 6 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); + assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -56,6 +58,25 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() } } } + let game = run(&mut s, "graphicStyle.library", json!({"library": "game-hud"})); + let game_names: Vec<&str> = game["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(game_names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]); + let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"})); + let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); + let node = doc(&s).node(id).unwrap(); + assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#3498db").unwrap())); + assert_eq!(node.appearance.stroke_paint(), Paint::None); + assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.outerGlow"]); + let id2 = rect(&mut s, 60.0, 20.0, 40.0, 30.0); + let r2 = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "status-effects", "name": "Poison Tint", "apply": true, "ids": [id2.0]})); + assert_eq!((names(&r2["added"]), r2["applied"].as_str()), (vec!["Poison Tint"], Some("Poison Tint"))); + let node2 = doc(&s).node(id2).unwrap(); + assert_eq!(node2.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#2ecc71").unwrap())); + assert_eq!(node2.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.outerGlow"]); // By name in any case. assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows"); assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index fcb4581c2..10f0ad75c 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -207,6 +207,228 @@ fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() { assert_eq!(drawn(&s, a), json!(SHIFT_MORE)); } +/// The `(name, xml)` of every `svg.filter` effect on the document's objects. +fn filter_effects(s: &Session) -> Vec<(String, String)> { + let mut out = vec![]; + s.doc().unwrap().doc.walk(|n| { + for e in n.appearance.effects.iter().filter(|e| e.id == vectorcraft_render::effects::SVG_FILTER) { + out.push((e.params["name"].as_str().unwrap().to_string(), e.params["xml"].as_str().unwrap().to_string())); + } + }); + out +} + +/// Pasted art whose filter has a name the document gives another filter keeps its own filter +/// under a free name, so editing or deleting the document's filter leaves it alone. Pasted back +/// into the document it came from, it takes the document's filter as it is now. +#[test] +fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "Shift 2", "renamed: {pasted:?}"); + assert!(pasted[0].1.contains("id=\"Shift 2\"") && pasted[0].1.contains("dx=\"9\""), "its own filter: {pasted:?}"); + assert_eq!(names(&mut s), ["Shift"]); + run(&mut s, "svgFilter.delete", json!({"name": "Shift"})); + assert_eq!(filter_effects(&s), pasted, "the document's filter leaves it alone"); + // The same filter as the document's keeps its name. + let mut t = session(); + run(&mut t, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT}"# + ); + run(&mut t, "clipboard.importSvg", json!({"svg": svg})); + run(&mut t, "edit.pasteInPlace", json!({})); + assert_eq!(filter_effects(&t), [("Shift".to_string(), SHIFT.to_string())]); + // Copied, then the filter edited: pasted back, the copy draws the edited filter. + let mut u = session(); + let a = rect(&mut u, 10.0); + run(&mut u, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut u, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut u, "select.set", json!({"ids": [a.0]})); + run(&mut u, "edit.copy", json!({})); + run(&mut u, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + run(&mut u, "edit.pasteInPlace", json!({})); + let both = filter_effects(&u); + assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]); +} + +/// A pasted filter at the size limit, which "Shift 2" would make too long to draw, keeps its own +/// definition under a free name no longer than its own (a free letter for a one-letter name), and +/// its id is that name. +#[test] +fn pasted_art_with_a_filter_at_the_size_limit_still_draws_when_renamed() { + for (name, held, renamed) in [("Shift", SHIFT, "Shif"), ("S", r#""#, "a")] { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": held})); + let (open, close) = (format!(r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + assert!(vectorcraft_render::effects::svg_filter::check(&big).is_ok()); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, renamed, "renamed: {}", pasted[0].0); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(renamed.to_string()), "the pasted filter draws under its name"); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); + assert_eq!(names(&mut s), [name]); + } +} + +/// A pasted filter is not renamed to a name an effect in the document already gives, even one whose +/// filter was deleted, so defining a filter of that name later changes only that effect. +#[test] +fn pasted_art_takes_no_name_the_documents_effects_use() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"})); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let names: Vec = filter_effects(&s).into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["Shift 2", "Shift 3"]); +} + +/// A pasted filter is not renamed to a name an effect in a brush's art gives either. +#[test] +fn pasted_art_takes_no_name_the_documents_brushes_use() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Fx"})); + run(&mut s, "edit.clear", json!({"ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"})); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let names: Vec = filter_effects(&s).into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["Shift 3"]); +} + +/// A pasted filter at the size limit with a one-letter name takes a free capital letter when every +/// lowercase letter and digit is in use. +#[test] +fn a_renamed_paste_takes_a_free_capital_letter() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9') { + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); + } + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "A"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("A".to_string())); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); +} + +/// A filter named `n`, with the characters a quoted attribute value cannot hold escaped. +fn one_char_filter(n: char) -> String { + let id = match n { + '"' => """.to_string(), + '&' => "&".to_string(), + '<' => "<".to_string(), + c => c.to_string(), + }; + format!(r#""#) +} + +/// A pasted filter at the size limit with a one-letter name takes a free punctuation character when +/// every letter and digit is in use. +#[test] +fn a_renamed_paste_takes_a_free_punctuation_character() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); + } + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "!"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("!".to_string())); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); +} + +/// A pasted filter at the size limit with a one-letter name, when every free name it could take is +/// in use, is refused rather than pasted under the name of the document's filter. +#[test] +fn a_paste_with_no_free_name_for_its_filter_is_refused() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); + } + // A document holds at most 64 filters: the other one-character names are taken by effects + // whose filters were deleted. + for n in ('!'..='~').filter(|c| !c.is_ascii_alphanumeric()) { + let a = rect(&mut s, 10.0); + let name = run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}))["name"].as_str().unwrap().to_string(); + assert_eq!(name, n.to_string()); + run(&mut s, "svgFilter.apply", json!({"name": name, "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": name})); + } + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + let (before, effects) = (steps(&s), filter_effects(&s).len()); + assert_eq!(effects, 32); + assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name")); + assert_eq!(filter_effects(&s).len(), effects, "nothing pasted"); + assert_eq!(steps(&s), before); +} + +/// A pasted filter whose name holds a tab, a line feed or a carriage return keeps those characters +/// in its id under its new name. +#[test] +fn a_renamed_paste_keeps_whitespace_characters_in_its_id() { + for c in [" ", " ", " "] { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + let svg = format!( + r#""# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_ne!(pasted[0].0, names(&mut s)[0], "renamed"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(pasted[0].0.clone()), "{c}: its id is its name"); + } +} + /// Art in a symbol's definition that uses a filter follows its edits too. #[test] fn a_symbols_art_follows_a_filter_edit() { diff --git a/crates/engine/src/tests/symbollib.rs b/crates/engine/src/tests/symbollib.rs index 42909da1c..54591c465 100644 --- a/crates/engine/src/tests/symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -84,12 +84,24 @@ fn the_builtin_library_is_original_art_generated_in_code() { let mut s = Session::new(); let list = run(&mut s, "symbol.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert_eq!(libs.len(), 2, "{libs:?}"); + assert_eq!(libs.len(), 5, "{libs:?}"); assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn"))); assert_eq!( (libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()), (Some("ui-frames"), Some("UI Frames"), Some("builtIn")) ); + assert_eq!( + (libs[2]["id"].as_str(), libs[2]["name"].as_str(), libs[2]["category"].as_str()), + (Some("map-markers"), Some("Map Markers"), Some("builtIn")) + ); + assert_eq!( + (libs[3]["id"].as_str(), libs[3]["name"].as_str(), libs[3]["category"].as_str()), + (Some("inventory-icons"), Some("Inventory Icons"), Some("builtIn")) + ); + assert_eq!( + (libs[4]["id"].as_str(), libs[4]["name"].as_str(), libs[4]["category"].as_str()), + (Some("skill-icons"), Some("Skill Icons"), Some("builtIn")) + ); let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"})); let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]); @@ -125,6 +137,84 @@ fn ui_frames_symbols_carry_default_nine_slice_when_added() { )); } +#[test] +fn inventory_icons_symbols_add_without_nine_slice() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "inventory-icons"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Sword", "Shield", "Potion", "Key", "Gem"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "inventory-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}), + ); + assert_eq!(r["added"], json!([name])); + placed.push(r["placed"].as_u64().unwrap()); + } + let d = doc(&s); + for name in &names { + assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides"); + } + assert!(matches!( + d.node(NodeId(placed[0])).map(|n| &n.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == "Sword" + )); +} + +#[test] +fn skill_icons_symbols_add_without_nine_slice() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "skill-icons"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Attack", "Defend", "Heal", "Buff", "Ult"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "skill-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}), + ); + assert_eq!(r["added"], json!([name])); + placed.push(r["placed"].as_u64().unwrap()); + } + let d = doc(&s); + for name in &names { + assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides"); + } + assert!(matches!( + d.node(NodeId(placed[0])).map(|n| &n.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == "Attack" + )); +} + +#[test] +fn map_markers_symbols_add_without_nine_slice() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "map-markers"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Pin", "Flag", "Crosshair", "Checkpoint"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "map-markers", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}), + ); + assert_eq!(r["added"], json!([name])); + placed.push(r["placed"].as_u64().unwrap()); + } + let d = doc(&s); + for name in &names { + assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides"); + } + assert!(matches!( + d.node(NodeId(placed[0])).map(|n| &n.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == "Pin" + )); +} + #[test] fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 885ce09e1..9eea9cf45 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -280,6 +280,37 @@ fn metrics_pair_step_preserves_the_drawn_auto_position() { assert!((moved - s.prefs.tracking_increment * 0.1).abs() < 0.01, "V moves by one kerning increment: {moved}"); } +/// At 50% Horizontal Scale the font's pair reads as the same thousandths of an em as at 100%: +/// kerning is measured in the em as the scale draws it (`vectorcraft_text::spacing_scale`), so the +/// value read keeps V where the font's kerning draws it, and a step moves V half as far as at 100%. +#[test] +fn horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it() { + use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font}; + + let db = vectorcraft_text::FontDb::global(); + db.add_font(pair_kerning_font().unwrap()); + let mut s = session(); + let id = NodeId( + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "AV", "size": 100, "font": PAIR_KERNING_FAMILY})).unwrap()["id"].as_u64().unwrap(), + ); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 2, "hScale": 50})).unwrap(); + let before = obj(&s, id); + assert_eq!(before.runs[0].style.h_scale, 50.0); + let auto = vectorcraft_text::layout(db, &before); + let drawn_v = |layout: &vectorcraft_text::TextLayout| layout.glyphs.iter().find(|g| g.byte == 1).unwrap().xf.as_coeffs()[4]; + let read = crate::cmd::effective_kerning(db, &before, &auto, 0, 1); + assert!((read + 160.0).abs() < 0.01, "the font's -160/1000 em: {read}"); + let mut same = before.clone(); + vectorcraft_text::edit::style_range(&mut same.runs, 0, 1, |st| st.kerning = Some(read)); + assert!((drawn_v(&vectorcraft_text::layout(db, &same)) - drawn_v(&auto)).abs() < 0.01); + + s.execute("type.step", &json!({"id": id.0, "start": 1, "end": 1, "attribute": "kerning", "by": 1})).unwrap(); + let after = obj(&s, id); + assert!((after.runs[0].style.kerning.unwrap() + 160.0 - s.prefs.tracking_increment).abs() < 0.01); + let moved = drawn_v(&vectorcraft_text::layout(db, &after)) - drawn_v(&auto); + assert!((moved - s.prefs.tracking_increment * 0.05).abs() < 0.01, "V moves by half a kerning increment at 100%: {moved}"); +} + #[test] fn effective_auto_kerning_at_area_line_end_survives_unkerned_wrap() { use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font}; @@ -467,6 +498,85 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby); } +/// The Character commands, Ruby and Move Type Path Handle refuse a param of the wrong type with an +/// error naming the param, also when another param would change something, and leave the +/// document, its revision, the selection and the undo history as they were. Null counts as a param +/// left out, which keeps its default, and `size` and `leading` take a length with a unit. +#[test] +fn character_ruby_and_path_handle_refuse_a_param_of_the_wrong_type() { + use crate::tests::{history, not_a_number, select_with_a_step_to_redo}; + let mut s = session(); + let id = text(&mut s, "abc"); + let path = s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})); + let on_path = s.execute("text.createInPath", &json!({"path": path.unwrap()["id"], "mode": "onPath", "text": "Along"})).unwrap()["id"].clone(); + select_with_a_step_to_redo(&mut s, &[id]); + let string = |k: &str| format!("`{k}` must be a string"); + let tags = "features must be a list of tags".to_string(); + let which = "`which` must be one of in, out".to_string(); + let handle = |which: Value| json!({"id": on_path, "anchor": 0, "which": which, "x": 100, "y": 250}); + let mut calls = vec![ + ("text.setStyle", json!({"font": 5}), string("font")), + ("text.setStyle", json!({"style": true}), string("style")), + ("text.setStyle", json!({"size": 30, "style": ["Bold"]}), string("style")), + ("text.setStyle", json!({"justify": 1}), string("justify")), + ("text.setStyle", json!({"size": 30, "justify": {"value": "center"}}), string("justify")), + ("text.setStyle", json!({"features": ["dlig", 5]}), tags.clone()), + ("text.setStyle", json!({"leading": true}), "leading must be a number or \"auto\"".to_string()), + ("text.setStyle", json!({"leading": "12abc"}), "leading must be a number or \"auto\"".to_string()), + ("text.setRangeStyle", json!({"id": id.0, "font": 5}), string("font")), + ("text.setRangeStyle", json!({"id": id.0, "size": 30, "style": false}), string("style")), + ("text.setRangeStyle", json!({"id": id.0, "features": [null]}), tags), + ("type.ruby", json!({"text": 5}), string("text")), + ("type.ruby", json!({"remove": true, "text": ["a"]}), string("text")), + ("type.ruby", json!({"text": "a", "position": 1}), string("position")), + ("type.ruby", json!({"text": "a", "align": true}), string("align")), + ("text.setPathHandle", handle(json!(1)), which.clone()), + ("text.setPathHandle", handle(json!("up")), which), + ]; + calls.extend( + [ + ("text.setStyle", json!({"size": true}), "size"), + ("text.setStyle", json!({"size": "big"}), "size"), + ("text.setStyle", json!({"size": "20abc"}), "size"), + ("text.setStyle", json!({"tracking": "20abc"}), "tracking"), + ("text.setStyle", json!({"font": "Inter", "size": [12]}), "size"), + ("text.setStyle", json!({"tracking": "wide"}), "tracking"), + ("text.setStyle", json!({"size": 30, "tracking": false}), "tracking"), + ] + .map(|(cmd, p, key)| { + let (_, p, msg) = not_a_number(cmd, p, key); + (cmd, p, msg) + }), + ); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.revision, st.selection.objects.clone(), history(s)) + }; + let before = state(&s); + let wrong: Vec = calls + .iter() + .filter_map(|(cmd, p, want)| { + let r = s.execute(cmd, p); + let refused = matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want); + let unchanged = state(&s) == before; + (!refused || !unchanged).then(|| format!("{cmd} {p}: {r:?}, unchanged: {unchanged}")) + }) + .collect(); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + // Null keeps each default; a size and a leading with a unit are lengths. + let r = json!({"font": null, "style": null, "justify": null, "tracking": null, "features": null, "leading": null, "size": "5 mm"}); + s.execute("text.setStyle", &r).unwrap(); + let st = obj(&s, id).runs[0].style.clone(); + assert!((st.size - 5.0 * 72.0 / 25.4).abs() < 1e-6 && st.tracking == 0.0 && st.leading.is_none(), "{st:?}"); + s.execute("text.setStyle", &json!({"size": "12 pt", "leading": "18 pt", "tracking": 50})).unwrap(); + let st = obj(&s, id).runs[0].style.clone(); + assert_eq!((st.size, st.leading, st.tracking), (12.0, Some(18.0), 50.0)); + s.execute("type.ruby", &json!({"text": "a", "position": null, "align": null})).unwrap(); + let ruby = obj(&s, id).ruby; + assert!(ruby.iter().all(|r| r.position == vectorcraft_doc::RubyPosition::Above && r.align == vectorcraft_doc::RubyAlign::Center), "{ruby:?}"); + s.execute("text.setPathHandle", &handle(Value::Null)).unwrap(); +} + /// Where each glyph of type object `id` is drawn from (its origin in font units), document space. fn glyph_pens(s: &Session, id: NodeId) -> Vec<(usize, Point)> { let t = obj(s, id); @@ -906,7 +1016,7 @@ fn dropped_type_press_restores_the_original_text_and_selection() { let a = at(&s, first); click(&mut s, a.x + 0.1, a.y); assert!(s.tool_wants_text()); - s.select(|_, selection| selection.guides.push(0)).unwrap(); + s.select(|d, selection| selection.guides.extend(d.ruler_guides().first().and_then(|g| g.id))).unwrap(); let before = s.doc().unwrap().selection.clone(); let b = at(&s, second); s.pointer(&PointerEvent::new(PointerKind::Down, b.x + 0.1, b.y), v).unwrap(); @@ -2357,6 +2467,62 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() { } } +/// Each line's first glyph in the document, and the line count. +fn line_starts_doc(t: &TextObject) -> Vec { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.lines.iter().map(|l| t.xf * lay.glyphs[l.glyph_start].origin).collect() +} + +/// Tracked type converted between point and area type stays where it is, however it is aligned, +/// as often as it goes round: an aligned line leaves the tracking after its last character out +/// of its width, and the frame Convert To Area Type makes ends where the lines are aligned, with +/// room for that tracking (line breaking counts it). Lines area type wraps at a space or after a +/// hyphen keep their place as the paragraphs Convert To Point Type makes of them (words without a +/// kerning pair across the wrap, which a new paragraph doesn't keep). +#[test] +fn converting_tracked_type_between_point_and_area_keeps_it_in_place() { + for j in ["left", "center", "right"] { + for tracking in [200.0, 1000.0, -100.0] { + let mut s = session(); + let id = + NodeId(s.execute("text.create", &json!({"x": 50, "y": 100, "text": "Wave\nWaves", "size": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": tracking})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), 2); + for round in 0..2 { + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let area = line_starts_doc(&obj(&s, id)); + assert_eq!(area.len(), 2, "{j} {tracking}: no rewrap"); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {tracking}, round {round}, area: {before:?} → {area:?}"); + } + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let point = line_starts_doc(&obj(&s, id)); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {tracking}, round {round}, point: {before:?} → {point:?}"); + } + } + } + // Area type wrapped at a space and after a hyphen, to point type. + for text in ["Wave Wave Wave", "Mode-Mode-Mode"] { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 20, "area": {"width": 90, "height": 200}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 200})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert!(before.len() > 1, "{text}: it wraps"); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let after = line_starts_doc(&obj(&s, id)); + assert_eq!(after.len(), before.len(), "{text}"); + for (a, b) in after.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {text}: {before:?} → {after:?}"); + } + } + } +} + #[test] fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { let mut s = session(); @@ -3358,6 +3524,75 @@ fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { assert_eq!(query["chosen"], json!(glyph.gid), "an overflowing character reports the fallback glyph it is drawn with"); } +/// `text.setGlyphAlternate` with `gid: null` reports the glyph the character is drawn with, as +/// `text.glyphAlternates` does: the fallback face's glyph for a character the font lacks, laid out +/// or overflowing, and the capital that All Caps draws for an a. +#[test] +fn a_glyph_choice_taken_away_reports_the_glyph_drawn() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let mut s = session(); + let reset = |s: &mut Session, id: NodeId, start: usize| { + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": null})).unwrap()["gid"].clone() + }; + let chosen = |s: &mut Session, id: NodeId, start: usize| { + s.execute("text.glyphAlternates", &json!({"id": id.0, "start": start})).unwrap()["chosen"].clone() + }; + + let ch = 'Ƃ'; + assert_eq!(face.glyph_for(ch), 0); + let point = json!({"x": 0, "y": 0, "text": ch.to_string(), "font": alternates_family(true)}); + let id = NodeId(s.execute("text.create", &point).unwrap()["id"].as_u64().unwrap()); + let drawn = vectorcraft_text::layout(db, &obj(&s, id)).glyphs[0].gid; + assert_ne!(drawn, 0); + assert_eq!(reset(&mut s, id, 0), json!(drawn), "the fallback glyph"); + assert_eq!(chosen(&mut s, id, 0), json!(drawn)); + + let area = json!({"x": 0, "y": 300, "text": ch.to_string(), "font": alternates_family(true), "area": {"width": 200, "height": 2}}); + let boxed = NodeId(s.execute("text.create", &area).unwrap()["id"].as_u64().unwrap()); + assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows"); + assert_eq!(reset(&mut s, boxed, 0), json!(drawn), "the overflowing character's fallback glyph"); + + let caps = NodeId( + s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("text.setRangeStyle", &json!({"id": caps.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + let drawn = vectorcraft_text::layout(db, &obj(&s, caps)).glyphs.iter().find(|g| g.byte == 1).expect("the a is laid out").gid; + assert_ne!(drawn, face.glyph_for('a'), "All Caps draws the a as A"); + assert_eq!(reset(&mut s, caps, 1), json!(drawn), "the capital drawn"); + assert_eq!(chosen(&mut s, caps, 1), json!(drawn)); +} + +/// A run that holds a choice for its first character and goes on past it, as a file may have, +/// draws Source Serif 4's fi ligature for the f and the i after that character. `gid: null` for the +/// i leaves the text as it is and reports the ligature, as `text.glyphAlternates` does. +#[test] +fn a_glyph_choice_taken_away_inside_a_ligature_reports_the_ligature() { + let db = vectorcraft_text::FontDb::global(); + let face = db.face("Source Serif 4", "Regular").expect("the bundled face"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xfin", "size": 40, "font": "Source Serif 4"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let mut runs = obj(&s, id).runs; + runs[0].style.glyph_alternate = Some(face.choice('x', face.glyph_for('x'))); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(text)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("text") }; + text.runs = runs.clone(); + let ligature = vectorcraft_text::layout(db, &obj(&s, id)).glyphs.iter().find(|g| g.byte == 1 && g.len == 2).expect("f + i is one glyph").gid; + let chosen = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap()["chosen"].clone(); + assert_eq!(chosen, json!(ligature)); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": null})).unwrap(); + assert_eq!(r["gid"], chosen, "the ligature drawn for the i"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no edit"); + assert_eq!(obj(&s, id).runs, runs, "the runs are kept"); +} + /// A fallback face draws Ka with the vowel sign I in one cluster, the vowel sign first. The glyph /// reported for Ka is the fallback face's glyph for Ka, laid out or overflowing, not the vowel /// sign's. diff --git a/crates/engine/src/tests/trap.rs b/crates/engine/src/tests/trap.rs index b2ae09908..aa0a1a75d 100644 --- a/crates/engine/src/tests/trap.rs +++ b/crates/engine/src/tests/trap.rs @@ -138,15 +138,35 @@ fn no_adjoining_edges_is_a_clear_error_that_changes_nothing() { } #[test] -fn out_of_range_and_unreadable_numbers_are_refused_or_defaulted() { +fn out_of_range_and_unreadable_numbers_are_refused() { let (mut s, _, _) = abutting(); + let before = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); for p in [json!({"thickness": 1e300}), json!({"thickness": 0}), json!({"heightWidth": 0}), json!({"tintReduction": 101})] { assert!(s.execute("object.pathfinder.trap", &p).is_err(), "{p}"); } - // A thickness that isn't a number falls back to the default. - let r = s.execute("object.pathfinder.trap", &json!({"thickness": "NaN", "tintReduction": 100})).unwrap(); + // A value of the wrong type is refused with an error naming the param, and changes nothing. + for (p, want) in [ + (json!({"thickness": "NaN"}), "`thickness` must be a number, not \"NaN\""), + (json!({"thickness": true}), "`thickness` must be a number, not true"), + (json!({"heightWidth": [100]}), "`heightWidth` must be a number, not [100]"), + (json!({"tintReduction": false}), "`tintReduction` must be a number, not false"), + (json!({"trapsWithProcessColor": "yes"}), "`trapsWithProcessColor` must be true or false"), + (json!({"reverseTraps": 1}), "`reverseTraps` must be true or false"), + ] { + let r = s.execute("object.pathfinder.trap", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{p}: {r:?}"); + } + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + // Null keeps the default thickness, 0.25 pt; a thickness with a unit is read as a distance. + let r = s.execute("object.pathfinder.trap", &json!({"thickness": null, "reverseTraps": null, "tintReduction": 100})).unwrap(); let bb = traps(&s, &r)[0].geometric_bounds().unwrap(); assert!(near(bb.x1, 50.25), "{bb:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("object.pathfinder.trap", &json!({"thickness": "0.1 in", "tintReduction": 100})).unwrap(); + let bb = traps(&s, &r)[0].geometric_bounds().unwrap(); + assert!(near(bb.x1, 57.2), "{bb:?}"); } #[test] diff --git a/crates/engine/src/tests/turn_direction.rs b/crates/engine/src/tests/turn_direction.rs new file mode 100644 index 000000000..f12f0d196 --- /dev/null +++ b/crates/engine/src/tests/turn_direction.rs @@ -0,0 +1,116 @@ +//! Every angle or rotation a command takes says which way it turns (#6340): creation commands turn +//! clockwise and most edits counter-clockwise, so a client can't guess. + +use crate::cmd::params::{is_key, object_body, split_top}; +use crate::command_specs; + +/// Params that are angles but not a turn, with why. +const NOT_A_TURN: &[(&str, &str, &str)] = &[ + ("addon.pathtools.cornerSet", "maxAngle", "the angle a corner's sides meet at"), + ("addon.pathtools.cornerSet", "minAngle", "the angle a corner's sides meet at"), + ("addon.shapes.box3d", "angleX", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.box3d", "angleY", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.configure", "angleX", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.configure", "angleY", "a tilt out of the page: the description says which side turns away"), + ("object.path.simplify", "cornerAngle", "the angle a corner's sides meet at"), + ("perspective.grid.define", "angle", "the viewing angle, 0 to 90°"), +]; + +/// A param name that is an angle or a turn. +fn is_turn(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + k.ends_with("angle") || k.ends_with("angles") || k.starts_with("angle") || ["rotation", "rotate", "piestart", "pieend"].contains(&k.as_str()) +} + +/// The bodies of the `{…}` objects in `text` that are not inside another one. +fn objects_in(text: &str) -> Vec<&str> { + let mut out = vec![]; + let mut rest = text; + while let Some(i) = rest.find('{') { + let Ok((body, after)) = object_body(&rest[i..]) else { break }; + out.push(body); + rest = after; + } + out +} + +/// `(key, what the description says about it)` for each key the object `body` names, and for the +/// keys of the objects inside it. A key given without a note (`a?, b?: degrees`) shares the note of +/// the next key that has one. +fn keys_and_notes(body: &str, out: &mut Vec<(String, String)>) { + let mut items: Vec<(&str, String)> = vec![]; + for entry in split_top(body, ',').into_iter().map(str::trim).filter(|e| !e.is_empty() && !e.starts_with('…')) { + for (i, alternative) in split_top(entry, '|').into_iter().enumerate() { + let head = alternative.split([':', '=', '(']).next().unwrap_or_default(); + let note = alternative.get(head.len()..).unwrap_or_default(); + let name = head.trim().trim_end_matches('?').trim_end(); + let a_key = is_key(name) && (i == 0 || head.trim_end().ends_with('?') || note.starts_with(':')); + match items.last_mut() { + Some((_, last)) if !a_key => last.push_str(alternative), + _ if a_key => items.push((name, note.to_string())), + _ => {} + } + } + } + for (i, (key, note)) in items.iter().enumerate() { + let shared = items.get(i..).unwrap_or_default().iter().map(|(_, n)| n.trim()).find(|n| !n.is_empty()).unwrap_or_default(); + out.push((key.to_string(), shared.to_string())); + for inner in objects_in(note) { + keys_and_notes(inner, out); + } + } +} + +/// The turn params of description `doc` whose note doesn't say which way they turn. +fn undirected(doc: &str) -> Vec { + let mut found = vec![]; + let mut next = Some(doc); + // `{…} or {…}`: each object the command takes. + while let Some(text) = next { + let Ok((body, rest)) = object_body(text) else { break }; + keys_and_notes(body, &mut found); + next = rest.trim_start().strip_prefix("or ").or_else(|| rest.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')); + } + found.into_iter().filter(|(k, note)| is_turn(k) && !note.to_ascii_lowercase().contains("clockwise")).map(|(k, _)| k).collect() +} + +#[test] +fn a_note_is_found_for_each_key() { + assert_eq!(undirected("{angle: deg (counter-clockwise), origin?}"), Vec::::new()); + assert_eq!(undirected("{x, rotation?: deg} → {rotation}"), ["rotation"]); + // A shared note, an alternative, a nested object, and the object after `or`. + assert_eq!(undirected("{inAngle?, outAngle?: degrees counter-clockwise, size?: pt}"), Vec::::new()); + assert_eq!(undirected("{startAngle?, endAngle?, center?: [x,y] | hSpacing? | angle?}"), ["startAngle", "endAngle", "angle"]); + assert_eq!(undirected("{gradient?: {kind?: linear|radial, angle?: deg}, id?}"), ["angle"]); + assert_eq!(undirected("{id} or {ids, rotate?: deg}"), ["rotate"]); + // Not a turn by its name, or a value rather than a key. + assert_eq!(undirected("{autoRotate: true, orientation?: portrait|landscape, mode?: rotation|scale}"), Vec::::new()); +} + +/// Each command param that is an angle or a rotation says in its description which way it turns +/// (clockwise or counter-clockwise), unless [`NOT_A_TURN`] says why it isn't a turn. +#[test] +fn every_turn_param_says_which_way_it_turns() { + let mut missing = vec![]; + for c in command_specs() { + for key in undirected(c.params) { + if !NOT_A_TURN.iter().any(|(id, k, _)| *id == c.id && *k == key) { + missing.push(format!("{}: {key}", c.id)); + } + } + } + assert!(missing.is_empty(), "{} params don't say whether they turn clockwise or counter-clockwise:\n{}", missing.len(), missing.join("\n")); + let stale: Vec<_> = NOT_A_TURN + .iter() + .filter(|(id, key, _)| !command_specs().iter().any(|c| c.id == *id && keys_named(c.params).iter().any(|k| k == key))) + .collect(); + assert!(stale.is_empty(), "NOT_A_TURN names params no command takes: {stale:?}"); +} + +fn keys_named(doc: &str) -> Vec { + let mut found = vec![]; + if let Ok((body, _)) = object_body(doc) { + keys_and_notes(body, &mut found); + } + found.into_iter().map(|(k, _)| k).collect() +} diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs index 9fd8e400b..0ebd61738 100644 --- a/crates/engine/src/tests/typeglyphbounds.rs +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -127,6 +127,30 @@ fn set_bounds_moves_and_sizes_the_ink_box() { assert!((ink(&s, t).height() - before.height()).abs() < 1e-6, "{:?}", ink(&s, t)); } +/// The font size the panels show follows the vertical scale: a typed width alone changes the +/// horizontal scale and keeps the font size, and a typed height alone changes the font size and +/// keeps the width, so the horizontal scale changes the other way. +#[test] +fn a_typed_width_changes_the_horizontal_scale_and_a_typed_height_the_font_size() { + let shown = |s: &Session, t: NodeId| { + let o = text(s, t); + let st = o.effective_char_style(&o.runs[0].style).unwrap(); + (st.size, st.h_scale) + }; + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"width": before.width() * 2.0})); + let (size, h_scale) = shown(&s, t); + assert!((size - 100.0).abs() < 1e-6 && (h_scale - 200.0).abs() < 1e-6, "{size} pt at {h_scale}%"); + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + run(&mut s, "object.setBounds", json!({"height": before.height() * 2.0})); + let (size, h_scale) = shown(&s, t); + assert!((size - 200.0).abs() < 1e-6 && (h_scale - 50.0).abs() < 1e-6, "{size} pt at {h_scale}%"); + assert!((ink(&s, t).width() - before.width()).abs() < 1e-6, "{:?}", ink(&s, t)); +} + #[test] fn set_bounds_with_the_current_values_records_no_undo_step() { let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); diff --git a/crates/engine/src/tests/typescale.rs b/crates/engine/src/tests/typescale.rs index 5bc09827b..f96193a6c 100644 --- a/crates/engine/src/tests/typescale.rs +++ b/crates/engine/src/tests/typescale.rs @@ -345,3 +345,27 @@ fn scale_text_attributes_bakes_group_scale_and_undoes() { s.execute("edit.undo", &json!({})).unwrap(); near(text(&s, id).runs[0].style.size, 12.0); } + +/// Scale Text Attributes bakes a horizontal stretch into the Horizontal Scale: tracked type stays +/// where the stretch drew it, as tracking is measured in the em the Horizontal Scale draws +/// (`vectorcraft_text::spacing_scale`). It used to lose the stretch's share of the tracking. +#[test] +fn scale_text_attributes_keeps_tracked_type_where_it_was() { + let mut s = session(); + let id = create(&mut s, 20.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 300})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let pens = |s: &Session| { + let t = text(s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + lay.glyphs.iter().map(|g| t.xf * (g.xf * Point::ZERO)).collect::>() + }; + let before = pens(&s); + s.execute("type.scaleTextAttributes", &json!({})).unwrap(); + near(text(&s, id).runs[0].style.h_scale, 200.0); + let after = pens(&s); + assert_eq!(before.len(), after.len()); + for (b, a) in before.iter().zip(&after) { + assert!((*a - *b).hypot() < 1e-6, "{before:?} {after:?}"); + } +} diff --git a/crates/engine/src/tests/units.rs b/crates/engine/src/tests/units.rs index 1608c86c6..6c859b727 100644 --- a/crates/engine/src/tests/units.rs +++ b/crates/engine/src/tests/units.rs @@ -36,6 +36,19 @@ fn preferences_general_sets_the_open_documents_units_as_one_undo_step() { assert_eq!(s.prefs.units_general, "millimeters", "undo is the document's; the preference stays"); } +#[test] +fn preferences_general_with_document_units_false_sets_only_the_preference() { + let mut s = session(); + s.execute("document.setUnits", &json!({"units": "Millimeters"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("prefs.set", &json!({"values": {"unitsGeneral": "inches"}, "documentUnits": false})).unwrap(); + assert_eq!((units(&s), s.prefs.units_general.as_str()), (Unit::Millimeters, "inches")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + let e = s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "points", "documentUnits": "no"})).unwrap_err(); + assert!(e.to_string().contains("`documentUnits` must be true or false"), "{e}"); + assert_eq!((units(&s), s.prefs.units_general.as_str()), (Unit::Millimeters, "inches"), "nothing changes on error"); +} + #[test] fn new_documents_start_in_the_preference_and_document_setup_only_changes_the_document() { let mut s = Session::new(); @@ -186,3 +199,78 @@ fn east_asian_type_unit_leaves_the_document_in_points() { assert!((lead.unwrap() - Unit::Ha.to_pt(20.0)).abs() < 1e-9, "{lead:?}"); assert!((sz - 17.0 * 72.0 / 101.6).abs() < 1e-9); } + +/// A session whose Preferences › Units › General is `unit`, with no document open. +fn general_in(unit: &str) -> Session { + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": unit})).unwrap(); + s +} + +/// `bytes` opened as `name` in `s` → the opened document's units. +fn opened_units(s: &mut Session, name: &str, bytes: &[u8]) -> Unit { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + units(s) +} + +/// The active document of a session in `unit`, exported as `format`. +fn exported(unit: &str, format: &str) -> Vec { + let mut s = session(); + s.execute("document.setUnits", &json!({"units": unit})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap(); + let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone(); + vectorcraft_format::base64_decode(data.as_str().unwrap()).unwrap() +} + +/// A PostScript `.ai` with one empty layer and `header` among its comments. +fn postscript_ai(header: &str) -> Vec { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 14.0\n{header}%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n0 A\n0 Xw\n\nLB\n%AI5_EndLayer--\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n" + ) + .into_bytes() +} + +#[test] +fn files_that_record_no_units_open_in_the_general_unit() { + let pdf = vectorcraft_testkit::pdf::pdf(&[vectorcraft_testkit::pdf::PdfPage::new(120.0, 60.0, "0 0 0 rg 10 10 50 30 re f")], None); + let png = exported("Inches", "png"); + let ai = postscript_ai(""); + let mut s = general_in("Millimeters"); + assert_eq!(opened_units(&mut s, "plain.pdf", &pdf), Unit::Millimeters, "a PDF records no units (#5820)"); + assert_eq!(opened_units(&mut s, "image.png", &png), Unit::Millimeters, "nor does an image"); + assert_eq!(opened_units(&mut s, "old.ai", &ai), Unit::Millimeters, "nor a .ai without ruler units"); + // Only the units change: the page keeps its size in points. + s.execute("document.open", &json!({"name": "plain.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + let board = s.doc().unwrap().doc.artboards[0].rect; + assert!((board.width() - 120.0).abs() < 1e-6 && (board.height() - 60.0).abs() < 1e-6, "{board:?}"); + // With the preference left at points, they open in points as before. + let mut p = general_in("Points"); + assert_eq!(opened_units(&mut p, "plain.pdf", &pdf), Unit::Points); +} + +#[test] +fn files_that_record_units_keep_them_whatever_the_general_unit() { + let mut s = general_in("Millimeters"); + assert_eq!(opened_units(&mut s, "inches.ai", &postscript_ai("%AI5_RulerUnits: 0\n")), Unit::Inches, "a .ai's ruler units"); + assert_eq!(opened_units(&mut s, "points.ai", &postscript_ai("%AI5_RulerUnits: 2\n")), Unit::Points, "ruler units in points are kept too"); + assert_eq!(opened_units(&mut s, "saved.vectorcraft", &exported("Inches", "vectorcraft")), Unit::Inches, "a native file"); + let svg = r#""#; + assert_eq!(opened_units(&mut s, "inches.svg", svg.as_bytes()), Unit::Inches, "an SVG's root size"); +} + +#[test] +fn revert_reopens_a_file_without_units_in_the_general_unit() { + let dir = std::env::temp_dir().join(format!("vc-5820-revert-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plain.pdf"); + std::fs::write(&path, vectorcraft_testkit::pdf::pdf(&[vectorcraft_testkit::pdf::PdfPage::new(120.0, 60.0, "0 0 0 rg 10 10 50 30 re f")], None)) + .unwrap(); + let mut s = general_in("Millimeters"); + s.execute("document.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(units(&s), Unit::Millimeters); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + s.execute("document.setUnits", &json!({"units": "Inches"})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(units(&s), Unit::Millimeters, "as the file opens"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index b096922e9..e9aa3c532 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -766,7 +766,7 @@ fn stroke_width(s: &Session, id: NodeId) -> f64 { } fn guide_positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } /// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 13fb87ccd..b222f3210 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -22,6 +22,8 @@ pub struct ViewInfo { /// View → Show Guides: the ruler guides show (and, unlocked, can be picked). pub guides: bool, pub snap_to_grid: bool, + /// View → Pixel Preview: while it is on, the tools use no Smart Guides. + pub pixel_preview: bool, pub show_bbox: bool, /// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi). pub snap_to_pixel: bool, @@ -46,6 +48,7 @@ impl Default for ViewInfo { smart_guides: true, guides: true, snap_to_grid: false, + pixel_preview: false, show_bbox: true, snap_to_pixel: false, snap_to_point: true, @@ -335,6 +338,7 @@ impl Session { { let Some(st) = self.active.and_then(|i| self.docs.get(i)) else { return R::default() }; let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit()); + let smart_guides = view.smart_guides && !view.pixel_preview; let cx = ToolContext { doc: &st.doc, ruler_origin: st.doc.ruler_origin_for(view.artboard), @@ -344,7 +348,7 @@ impl Session { isolation: st.isolation, paint: &self.paint, outline: view.outline, - smart_guides: view.smart_guides, + smart_guides, guides: view.guides && !self.menu.guides_locked, snap_to_grid: view.snap_to_grid, show_bbox: view.show_bbox, @@ -354,7 +358,7 @@ impl Session { snap_to_point: view.snap_to_point, snap_tangent: self.prefs.snap_tangent, snap_perpendicular: self.prefs.snap_perpendicular, - snap_to_glyph: view.snap_to_glyph && view.smart_guides && !view.snap_to_grid, + snap_to_glyph: view.snap_to_glyph && smart_guides && !view.snap_to_grid, glyph_snap: self.prefs.glyph_snap, corner_widgets: view.corner_widgets, fill_active: self.fill_active, @@ -704,20 +708,39 @@ impl Session { Ok(()) } + /// The artboard a guide made from a ruler belongs to: with the Artboard tool, its active + /// artboard (an artboard guide); with any other tool, none (a canvas guide). + pub fn ruler_guide_artboard(&self) -> Option { + let boards = self.active().map_or(0, |d| d.doc.artboards.len()); + let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten(); + active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards) + } + /// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a /// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the /// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over /// the canvas, snapped as a moved guide is, and is made where the button is released over it /// (one undo step); released anywhere else, none is. With the Artboard tool it is an artboard - /// guide of the active artboard. + /// guide of the active artboard. With Preferences › Guides & Grid › Artboard Guides with + /// Cmd/Ctrl + Ruler Drag on, `mods.cmd` on the drag's first event swaps the two: with another + /// tool the guide is one of the view's active artboard (`view.artboard`), shown and made only + /// where it lies within that artboard, and with the Artboard tool it is a canvas guide. pub fn ruler_guide(&mut self, vertical: bool, ev: &PointerEvent, on_canvas: bool, view: ViewInfo) -> Result<()> { + use vectorcraft_tools::rulerguide::NewGuide; let g = match self.ruler_guide.take() { Some(g) => Some(g), None => { - let boards = self.active().map_or(0, |d| d.doc.artboards.len()); - let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten(); - let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards); - self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::NewGuide::new(cx, vertical, artboard))) + let artboard_tool = self.tool.id() == "artboard"; + let swap = self.prefs.cmd_ruler_drag_artboard_guides && ev.mods.cmd; + let artboard = self.ruler_guide_artboard(); + let view_board = view.artboard; + self.with_tool_cx(view, |_, cx| { + Some(match (swap, artboard_tool) { + (false, _) => NewGuide::new(cx, vertical, artboard), + (true, true) => NewGuide::new(cx, vertical, None), + (true, false) => NewGuide::within(cx, vertical, view_board), + }) + }) } }; let Some(mut g) = g else { return Ok(()) }; @@ -1298,6 +1321,9 @@ impl Session { let outer = std::mem::replace(&mut self.in_tool_actions, true); let r = self.apply_tool_actions(acts); self.in_tool_actions = outer; + if !outer && self.depth == 0 { + self.trim_histories(); + } r } diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index edad5c532..26fd9ba14 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -44,7 +44,7 @@ impl Measure { } /// The unit a Units preference value names (points when it names none). -fn pref_unit(value: &str) -> Unit { +pub(crate) fn pref_unit(value: &str) -> Unit { Unit::named(value).unwrap_or_default() } diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index d97560af3..0cc0edc4d 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -9,6 +9,7 @@ use serde_json::json; use vectorcraft_color::cms; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::colormgmt::{PROFILES_KEY, doc_profiles}; +use vectorcraft_engine::store::MAX_PRESET_FILE; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; use vectorcraft_testkit::fixtures::{exec, id_of, rect, select, session}; @@ -426,3 +427,108 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() { assert!(after.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {after:?}, want {want:?}"); assert!(redrawn, "the canvas redraws"); } + +/// A folder of its own for the test `tag`, empty. +fn fresh_dir(tag: &str) -> std::path::PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +#[allow(clippy::unwrap_used, reason = "a test helper: a missing built-in profile fails the test")] +fn icc_named(name: &str) -> Vec { + let builtin = cms::DISPLAY_P3; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).unwrap(); + bytes[at..at + to.len()].copy_from_slice(&to); + bytes +} + +/// Puts the default Color Settings back, unregisters `profile` and deletes `paths` when dropped, +/// also after a failed assertion, so the next test starts from the defaults. +struct ColorSettingsReset { + profile: Option<&'static str>, + paths: Vec, +} + +impl Drop for ColorSettingsReset { + /// The defaults back, the profile unregistered and the paths deleted. + fn drop(&mut self) { + let _ = cms::set_active(&cms::ColorSettings::default()); + if let Some(name) = self.profile { + cms::unregister_icc(name); + } + for path in &self.paths { + let _ = std::fs::remove_dir_all(path).or_else(|_| std::fs::remove_file(path)); + } + } +} + +/// #6335: Edit › Color Settings come back in the next session: the working spaces (a loaded +/// profile among them, registered again by the profile store first), the intent and black point +/// compensation. A session nobody attached a file to keeps nothing, and nothing is written before +/// the settings change. +#[test] +fn color_settings_come_back_in_the_next_session() { + let _g = settings(); + let root = fresh_dir("color-settings"); + let (profiles, file) = (root.join("Color Profiles"), root.join("Color Settings.json")); + let loaded = "StoreRGB-6"; + let icc = vectorcraft_testkit::temp_dir("store-color-settings").join("loaded.icc"); + let _reset = ColorSettingsReset { profile: Some(loaded), paths: vec![root.clone(), icc.clone()] }; + std::fs::write(&icc, icc_named(loaded)).unwrap(); + + let mut a = Session::new(); + a.attach_profile_store(profiles.clone()); + a.attach_color_settings(file.clone()); + a.execute("color.loadProfile", &json!({"path": icc.to_str().unwrap()})).unwrap(); + a.execute("edit.colorSettings", &json!({})).unwrap(); + assert!(!file.exists(), "nothing to keep before the settings change"); + a.execute("edit.colorSettings", &json!({"rgb": loaded, "cmyk": cms::DEVICE_CMYK, "intent": "perceptual", "bpc": false})).unwrap(); + let chosen = cms::ColorSettings { rgb: loaded.into(), cmyk: cms::DEVICE_CMYK.into(), intent: cms::Intent::Perceptual, bpc: false }; + assert_eq!(cms::active_settings(), chosen); + + // The next start begins with the defaults and without the loaded profile, which the profile + // store registers again before the settings name it. + cms::set_active(&cms::ColorSettings::default()).unwrap(); + assert!(cms::unregister_icc(loaded)); + let mut b = Session::new(); + b.attach_profile_store(profiles); + b.attach_color_settings(file.clone()); + assert_eq!(cms::active_settings(), chosen, "the saved settings are the active ones again"); + + // Without a file, a change is kept only for the process. + let mut c = Session::new(); + c.execute("edit.colorSettings", &json!({"bpc": true})).unwrap(); + let saved: cms::ColorSettings = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!(saved, chosen, "the file is left alone"); +} + +/// #6335: saved Color Settings that can't be used (a profile that is gone, a damaged or oversized +/// file) leave the defaults and the file as it is; the next change writes over it. +#[test] +fn saved_color_settings_that_cannot_be_used_leave_the_defaults() { + let _g = settings(); + let dir = fresh_dir("color-settings-bad"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + let file = dir.join("Color Settings.json"); + std::fs::create_dir_all(&dir).unwrap(); + let gone = json!({"rgb": cms::SRGB, "cmyk": "A Profile That Is Gone", "intent": "perceptual", "bpc": false}).to_string().into_bytes(); + let oversized = vec![b' '; MAX_PRESET_FILE as usize + 1]; + for bytes in [gone, b"not json".to_vec(), oversized] { + cms::set_active(&cms::ColorSettings::default()).unwrap(); + std::fs::write(&file, &bytes).unwrap(); + let mut s = Session::new(); + s.attach_color_settings(file.clone()); + assert_eq!(cms::active_settings(), cms::ColorSettings::default()); + assert_eq!(std::fs::read(&file).unwrap(), bytes, "left as it is"); + s.execute("edit.colorSettings", &json!({"bpc": false})).unwrap(); + let saved: cms::ColorSettings = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert!(!saved.bpc && saved.cmyk == cms::GENERIC_CMYK, "written over by the next change: {saved:?}"); + } +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index de14c38b5..947cf559d 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -9,10 +9,11 @@ use vectorcraft_engine::{Session, command_specs}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::Fixture; use vectorcraft_testkit::invariants::{check_all, check_session, doc_json}; -use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys}; +use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value}; /// Commands that replace/close the active document or touch the filesystem are fine to call, but a -/// few need their paths redirected into a temp dir (handled by `junk_params`). +/// few need their file and folder params redirected into a temp dir (`safe_value`, which +/// `junk_params` applies). fn safe_path() -> String { vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string() } @@ -230,6 +231,54 @@ fn tool_edit_state(s: &Session) -> ToolEditState { (docs, s.active_index(), s.paint.clone()) } +/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and +/// off, with a string under a file or folder key replaced by a location inside `path`'s folder. +fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { + let mut cases = junk_params(c.params, path); + if heavy(c.id) { + cases.truncate(12); + } + for k in param_keys(c.params) { + for v in [json!(true), json!(false), json!(1), json!("on")] { + cases.push(json!({ k.as_str(): safe_value(&k, &v, path) })); + } + } + // Find Objects selects only with a filter, which one key at a time does not give it. + if c.id == "document.find" { + cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]); + } + cases +} + +/// Every string the sweeps send under a key that names a file or folder to write is inside the +/// sweep's temp folder, so no run writes into the working directory. +#[test] +fn sweep_cases_name_files_only_inside_the_temp_folder() { + let path = safe_path(); + let dir = vectorcraft_testkit::temp_dir("sweep"); + let keys = ["path", "diffPath", "folder", "destinationFolder"]; + let mut seen = std::collections::BTreeSet::new(); + let mut outside = std::collections::BTreeSet::new(); + for c in command_specs() { + let mut cases = junk_params(c.params, &path); + cases.extend(tool_edit_cases(c, &path)); + for p in &cases { + let Some(o) = p.as_object() else { continue }; + for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) { + seen.insert(k.clone()); + if let Some(s) = v.as_str() + && !std::path::Path::new(s).starts_with(&dir) + { + outside.insert(format!("{} {{\"{k}\": {v}}}", c.id)); + } + } + } + } + assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}"); + let outside: Vec = outside.into_iter().collect(); + assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n")); +} + /// Every command that runs at once while the active tool computes an edit on another thread (a /// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the /// documents' revisions and selections, the active document and the current paint as they were, @@ -241,19 +290,7 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() { let mut failures = vec![]; for fx in [Fixture::Single, Fixture::Multi] { for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) { - let mut cases = junk_params(c.params, &path); - if heavy(c.id) { - cases.truncate(12); - } - for k in param_keys(c.params) { - for v in [json!(true), json!(false), json!(1), json!("on")] { - cases.push(json!({ k.as_str(): v })); - } - } - // Find Objects selects only with a filter, which one key at a time does not give it. - if c.id == "document.find" { - cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]); - } + let cases = tool_edit_cases(c, &path); for p in &cases { let mut s = fx.session(); let before = tool_edit_state(&s); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 424f30cdd..f729ed394 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -663,6 +663,26 @@ proptest! { survive(&svg, || vectorcraft_svg::import(&svg).ok())?; } + /// Clip paths made of text, of `` elements showing text, and of both with shapes, each with + /// hostile attributes, clipping art that may clip by them in turn. + #[test] + fn svg_text_clip_paths_never_panic( + text in prop::collection::vec(arb_svg_attr(), 0..5), + used in prop::collection::vec(arb_svg_attr(), 0..5), + clip in prop::collection::vec(arb_svg_attr(), 0..3), + body in prop::collection::vec(arb_svg_element(1), 0..3), + ) { + let svg = format!( + "abcClipit{}", + text.concat(), + clip.concat(), + text.concat(), + used.concat(), + body.concat() + ); + survive(&svg, || vectorcraft_svg::import(&svg).ok())?; + } + #[test] fn svg_mutated_export_never_panics( cut in 0usize..20_000, diff --git a/crates/engine/tests/it/affinity_features.rs b/crates/engine/tests/it/affinity_features.rs index 511fbb25f..6a0338ee1 100644 --- a/crates/engine/tests/it/affinity_features.rs +++ b/crates/engine/tests/it/affinity_features.rs @@ -568,12 +568,14 @@ fn artistic_text_aligns_its_lines_within_the_longest() { assert!(matches!(t.kind, TextKind::Point)); let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines; let (wide, mid) = (&lines[0], &lines[1]); - let w = wide.x1 - wide.x0; + // Each line as wide as it is aligned: without the tracking after its last character. + let width = |l: &vectorcraft_text::LineInfo| l.x1 - l.trailing - l.x0; + let w = width(wide); assert!(w > 0.0); // The block stays where the first paragraph's alignment puts it on the anchor. let start = [0.0, -0.5, -1.0][first as usize] * w; assert!((wide.x0 - start).abs() < 1e-6, "{first}/{second}: {} vs {start}", wide.x0); - let expected = wide.x0 + (w - (mid.x1 - mid.x0)) * at; + let expected = wide.x0 + (w - width(mid)) * at; assert!((mid.x0 - expected).abs() < 1e-6, "{first}/{second}: {} vs {expected}", mid.x0); // Each paragraph keeps its own alignment. use vectorcraft_doc::text::Justify; diff --git a/crates/engine/tests/it/beyond_canvas.rs b/crates/engine/tests/it/beyond_canvas.rs index 9dee868d5..6ac31398f 100644 --- a/crates/engine/tests/it/beyond_canvas.rs +++ b/crates/engine/tests/it/beyond_canvas.rs @@ -51,7 +51,7 @@ fn an_artboard_a_guide_or_a_ruler_origin_beyond_the_canvas_no_longer_blocks_edit v["document"]["ruler_origin"] = json!([1e17, 0.0]); let (s, warnings) = open_and_move(&v, "setup"); let d = &s.doc().unwrap().doc; - assert_eq!((d.artboards.len(), d.guides.len(), d.ruler_origin.x), (1, 1, 0.0)); + assert_eq!((d.artboards.len(), d.ruler_guides().len(), d.ruler_origin.x), (1, 1, 0.0)); assert!(warnings.to_string().contains("1 artboard, 1 guide beyond the canvas"), "{warnings}"); assert!(warnings.to_string().contains("ruler origin"), "{warnings}"); } diff --git a/crates/engine/tests/it/clear_commands.rs b/crates/engine/tests/it/clear_commands.rs index 3b7fb79e9..a50c70c18 100644 --- a/crates/engine/tests/it/clear_commands.rs +++ b/crates/engine/tests/it/clear_commands.rs @@ -144,12 +144,14 @@ fn cut_compound_member_keeps_copy_and_delete_targets_consistent() { let (mut s, a, b, c) = compound(); s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); s.execute("edit.cut", &json!({})).unwrap(); + // Cut takes the member it copies, and the other member stays in the compound. assert_eq!(s.clipboard.nodes.len(), 1); - assert_eq!(s.clipboard.nodes[0].id, c); - assert!(s.active().unwrap().doc.node(c).is_none()); + assert_eq!(s.clipboard.nodes[0].id, a); + assert!(s.active().unwrap().doc.node(a).is_none()); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); s.execute("edit.pasteInPlace", &json!({})).unwrap(); let pasted = s.active().unwrap().selection.objects[0]; - assert_eq!(s.active().unwrap().doc.node(pasted).unwrap().children().unwrap().len(), 2); + assert!(s.active().unwrap().doc.node(pasted).unwrap().path_data().is_some(), "an ordinary path is pasted"); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c)); @@ -175,12 +177,12 @@ fn clear_guides_and_empty_explicit_targets_keep_their_separate_behavior() { s.execute("edit.clear", &json!({"ids": []})).unwrap(); assert_eq!(s.active().unwrap().history.undo.len(), history); assert_eq!(s.active().unwrap().selection, selection); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); s.execute("edit.clear", &json!({})).unwrap(); - assert!(s.active().unwrap().doc.guides.is_empty()); + assert!(s.active().unwrap().doc.ruler_guides().is_empty()); assert!(s.active().unwrap().doc.node(a).is_some()); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); } #[test] diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index c2713befe..14c2a45b1 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -367,13 +367,14 @@ fn copy_when_all_segments_selected_preserves_live_shape() { } #[test] -fn compound_member_selected_whole_with_direct_selection_promotes_member() { +fn compound_member_selected_whole_with_direct_selection_copies_only_the_member() { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap()); let inner = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]})); let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); let standalone = rect(&mut s, 120.0, 10.0, 50.0, 50.0); @@ -394,17 +395,24 @@ fn compound_member_selected_whole_with_direct_selection_promotes_member() { assert!(s.active().unwrap().selection.partial(outer).is_none()); assert!(s.active().unwrap().selection.partial(standalone).is_some()); + let undo_before = s.doc().unwrap().history.undo.len(); run(&mut s, "edit.copy", json!({})); - // Clipboard should have: - // 1. The compound path (outer promoted to compound #786) - // 2. The extracted segment piece from standalone + // The clipboard holds the member as an ordinary path with the compound's paint, and the + // piece extracted from the standalone path. let clip_ids: Vec = s.clipboard.nodes.iter().map(|n| n.id).collect(); - assert_eq!(clip_ids.len(), 2, "promoted compound plus the extracted standalone piece"); - assert!(clip_ids.contains(&compound), "whole compound member must be promoted to compound parent"); + assert_eq!(clip_ids.len(), 2, "the member plus the extracted standalone piece"); + assert!(clip_ids.contains(&outer), "the member is copied itself: {clip_ids:?}"); + assert!(!clip_ids.contains(&compound), "the compound is not copied"); + let member = s.clipboard.nodes.iter().find(|n| n.id == outer).unwrap(); + assert!(matches!(member.kind, NodeKind::Path { .. })); + assert_eq!(member.appearance, s.doc().unwrap().doc.node(compound).unwrap().appearance); + // Copy leaves the document and its history as they were. + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + assert_eq!(s.doc().unwrap().doc.node(compound).unwrap().children().unwrap().len(), 2); } #[test] -fn cut_whole_compound_member_with_direct_selection_cuts_compound_and_extracted_piece() { +fn cut_whole_compound_member_with_direct_selection_cuts_member_and_extracted_piece() { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap()); @@ -426,16 +434,411 @@ fn cut_whole_compound_member_with_direct_selection_cuts_compound_and_extracted_p }), ); + let undo_before = s.doc().unwrap().history.undo.len(); run(&mut s, "edit.cut", json!({})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "Cut is one undo step"); let clip_ids: Vec = s.clipboard.nodes.iter().map(|n| n.id).collect(); - assert_eq!(clip_ids.len(), 2, "promoted compound plus the extracted standalone piece"); - assert!(clip_ids.contains(&compound), "whole compound member must be promoted to compound parent"); + assert_eq!(clip_ids.len(), 2, "the member plus the extracted standalone piece"); + assert!(clip_ids.contains(&outer) && !clip_ids.contains(&compound), "{clip_ids:?}"); - // Compound was removed because member was selected whole + // The member left; the other member stays in the compound. let doc = &s.doc().unwrap().doc; - assert!(doc.node(compound).is_none(), "compound path must be removed on cut"); + assert!(doc.node(outer).is_none(), "the cut member is removed"); + assert_eq!(doc.node(compound).and_then(|n| n.children()).map(|c| c.iter().map(|m| m.id).collect::>()), Some(vec![inner])); // Standalone has cut pieces remaining assert!(doc.node(standalone).is_some(), "partially cut standalone path remains"); + check_session(&s).unwrap(); + + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc.node(compound).unwrap().children().unwrap().len(), 2, "Undo puts the member back"); + run(&mut s, "edit.redo", json!({})); + assert!(s.doc().unwrap().doc.node(outer).is_none()); + check_session(&s).unwrap(); +} + +/// A compound path of `n` rectangles side by side (x = 10, 60, 110, …), filled red, with its id +/// and its members' ids. +fn compound_of(s: &mut Session, n: usize) -> (NodeId, Vec) { + let members: Vec = (0..n).map(|i| rect(s, 10.0 + 50.0 * i as f64, 10.0, 40.0, 40.0)).collect(); + run(s, "select.set", json!({"ids": members.iter().map(|m| m.0).collect::>()})); + let compound = NodeId(run(s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"color": "#ff0000"})); + (compound, members) +} + +/// Every anchor of the four-anchor rectangle `id` picked with Direct Selection, which selects it +/// whole. +fn pick_all_anchors(s: &mut Session, id: NodeId) { + run(s, "select.anchorsMany", json!({"items": [{"id": id.0, "anchors": [[0, 0], [0, 1], [0, 2], [0, 3]]}]})); + assert!(s.doc().unwrap().selection.contains(id) && s.doc().unwrap().selection.partial(id).is_none(), "the member is selected whole"); +} + +fn members_of(s: &Session, compound: NodeId) -> Option> { + s.doc().unwrap().doc.node(compound).and_then(|n| n.children()).map(|c| c.iter().map(|m| m.id).collect()) +} + +#[test] +fn cut_of_one_member_of_two_leaves_a_compound_of_one() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + pick_all_anchors(&mut s, m[0]); + let undo_before = s.doc().unwrap().history.undo.len(); + run(&mut s, "edit.cut", json!({})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "Cut is one undo step"); + assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "the other member stays a compound of one, as after Clear"); + assert_eq!(s.clipboard.nodes.len(), 1); + assert_eq!(s.clipboard.nodes[0].id, m[0]); + assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { .. }), "the clipboard holds an ordinary path"); + check_session(&s).unwrap(); + + run(&mut s, "edit.undo", json!({})); + assert_eq!(members_of(&s, compound), Some(m.clone()), "Undo puts the member back in its place"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m[1]])); + check_session(&s).unwrap(); +} + +#[test] +fn cut_of_the_middle_member_of_three_leaves_the_other_two() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 3); + pick_all_anchors(&mut s, m[1]); + run(&mut s, "edit.cut", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m[0], m[2]])); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [m[1]]); + check_session(&s).unwrap(); +} + +#[test] +fn clear_of_a_whole_member_removes_only_the_member_and_keeps_the_clipboard() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let standalone = rect(&mut s, 10.0, 120.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [standalone.0]})); + run(&mut s, "edit.copy", json!({})); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.clear", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m[1]])); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [standalone]); +} + +#[test] +fn group_selection_of_one_member_copies_and_cuts_only_that_member() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m[1].0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [m[1]]); + assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { .. })); + assert_eq!(members_of(&s, compound), Some(m.clone()), "Copy leaves both members"); + + run(&mut s, "edit.cut", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m[0]])); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [m[1]]); + check_session(&s).unwrap(); +} + +#[test] +fn every_member_selected_whole_copies_and_cuts_the_compound() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m[0].0, m[1].0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [compound]); + run(&mut s, "edit.cut", json!({})); + assert!(s.doc().unwrap().doc.node(compound).is_none(), "the compound leaves whole"); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [compound]); + check_session(&s).unwrap(); +} + +#[test] +fn a_copied_member_keeps_the_compounds_transparency_mask_and_fill_rule() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let mask = rect(&mut s, 0.0, 0.0, 120.0, 60.0); + run(&mut s, "select.set", json!({"ids": [compound.0, mask.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + let mask_art = s.doc().unwrap().doc.node(compound).unwrap().extras().mask.clone(); + assert!(mask_art.is_some(), "the compound has an opacity mask"); + run(&mut s, "select.set", json!({"ids": [compound.0]})); + run(&mut s, "transparency.set", json!({"opacity": 50, "blend": "Multiply", "isolate": true})); + run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"})); + assert!(matches!(s.doc().unwrap().doc.node(m[0]).unwrap().kind, NodeKind::Path { rule: FillRule::NonZero, .. }), "the member keeps its own rule"); + let (opacity, blend) = { + let c = s.doc().unwrap().doc.node(compound).unwrap(); + (c.opacity, c.blend) + }; + assert_eq!(opacity, 0.5); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.iter().map(|n| (n.id, n.opacity, n.blend)).collect::>(), [(m[0], opacity, blend)]); + assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { rule: FillRule::EvenOdd, .. })); + assert!(s.clipboard.nodes[0].isolate, "the member keeps the compound's isolated blending"); + assert_eq!(s.clipboard.nodes[0].extras().mask, mask_art, "the member keeps the compound's opacity mask"); +} + +#[test] +fn a_copied_member_with_its_own_appearance_takes_the_compounds() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + // The member carries an appearance of its own, as an imported member can; the compound's + // appearance is the one drawn. + run(&mut s, "paint.setFill", json!({"ids": [compound.0], "color": "#00ff00"})); + let own_look = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone(); + run(&mut s, "paint.setFill", json!({"ids": [compound.0], "color": "#0000ff"})); + let compound_look = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone(); + assert_ne!(own_look, compound_look); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(m[0]).unwrap().appearance = own_look.clone(); + assert!(!s.doc().unwrap().doc.node(m[0]).unwrap().appearance.items.is_empty(), "the member has its own appearance"); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes[0].appearance, compound_look); +} + +/// A member copied or cut on its own, whole or in part, takes the compound's graphic style link +/// with its look, or no link when the compound has none or the member no longer has the style's +/// look (a gradient pinned to the compound's bounds); the style the member had before Make Compound +/// Path does not go with it. +#[test] +fn a_copied_member_takes_the_compounds_graphic_style() { + let style_of = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style; + let clipboard_styles = |s: &Session| s.clipboard.graphic_styles.iter().map(|g| g.name.clone()).collect::>(); + for (compound_styled, gradient) in [(false, false), (true, false), (true, true)] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let m0 = rect(&mut s, 10.0, 10.0, 40.0, 40.0); + run(&mut s, "paint.setFill", json!({"ids": [m0.0], "color": "#00ff00"})); + run(&mut s, "select.set", json!({"ids": [m0.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Member"})); + let member_style = style_of(&s, m0); + assert!(member_style.is_some(), "the path is linked to the style made from it"); + let m1 = rect(&mut s, 60.0, 10.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [m0.0, m1.0]})); + let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap()); + let fill = + if gradient { json!({"ids": [compound.0], "gradient": {"kind": "linear"}}) } else { json!({"ids": [compound.0], "color": "#ff0000"}) }; + run(&mut s, "paint.setFill", fill); + assert_eq!(style_of(&s, m0), member_style, "the member keeps its old link inside the compound"); + let compound_style = if compound_styled { + run(&mut s, "select.set", json!({"ids": [compound.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Compound"})); + style_of(&s, compound) + } else { + None + }; + assert_eq!(compound_style.is_some(), compound_styled); + // A gradient fitted to the compound is pinned to its bounds on the copy, which is no longer + // the style's look. + let link = compound_style.filter(|_| !gradient); + let expected: Vec = if link.is_some() { vec!["Compound".into()] } else { vec![] }; + let case = format!("compound styled: {compound_styled}, gradient: {gradient}"); + + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m0.0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes[0].id, m0); + assert_eq!(s.clipboard.nodes[0].extras().graphic_style, link, "whole member, {case}"); + assert_eq!(clipboard_styles(&s), expected, "whole member, {case}"); + + run(&mut s, "select.segments", json!({"id": m0.0, "segments": [[0, 0]]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.len(), 1); + assert_eq!(s.clipboard.nodes[0].extras().graphic_style, link, "one segment, {case}"); + assert_eq!(clipboard_styles(&s), expected, "one segment, {case}"); + + run(&mut s, "select.set", json!({"ids": [m0.0]})); + run(&mut s, "edit.cut", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m1])); + let pasted = NodeId(run(&mut s, "edit.pasteInPlace", json!({}))["ids"][0].as_u64().unwrap()); + assert_eq!(style_of(&s, pasted), link, "Cut and Paste in Place, {case}"); + check_session(&s).unwrap(); + } +} + +#[test] +fn every_segment_of_every_member_picked_copies_and_cuts_the_compound() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let all: Vec> = (0..4).map(|i| vec![0, i]).collect(); + run(&mut s, "select.segments", json!({"id": m[0].0, "segments": all})); + run(&mut s, "select.segments", json!({"id": m[1].0, "segments": all, "mode": "add"})); + let sel = &s.doc().unwrap().selection; + assert!(sel.segments.contains_key(&m[0]) && sel.segments.contains_key(&m[1]), "both members keep their picked segments"); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [compound]); + run(&mut s, "edit.cut", json!({})); + assert!(s.doc().unwrap().doc.node(compound).is_none(), "the compound leaves whole"); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [compound]); + check_session(&s).unwrap(); +} + +#[test] +fn clear_of_every_segment_or_anchor_of_every_member_leaves_no_empty_compound() { + let all: Vec> = (0..4).map(|i| vec![0, i]).collect(); + let picks: [(&str, Value, Value); 3] = [ + ("every segment", json!({"segments": all}), json!({"segments": all, "mode": "add"})), + ("every anchor", json!({"anchors": all}), json!({"anchors": all})), + ("one member whole, every segment of the other", json!({"anchors": all}), json!({"segments": all, "mode": "add"})), + ]; + for (label, first, second) in picks { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let layer = s.doc().unwrap().doc.parent_of(compound).unwrap(); + if first.get("segments").is_some() { + run(&mut s, "select.segments", json!({"id": m[0].0, "segments": first["segments"]})); + } else { + run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": first["anchors"]}]})); + } + if second.get("segments").is_some() { + run(&mut s, "select.segments", json!({"id": m[1].0, "segments": second["segments"], "mode": "add"})); + } else { + run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": all}, {"id": m[1].0, "anchors": all}]})); + } + let undo_before = s.doc().unwrap().history.undo.len(); + run(&mut s, "edit.clear", json!({})); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(compound).is_none(), "{label}: Clear removes the compound with its members, as Cut does"); + assert!(doc.node(layer).and_then(|l| l.children()).is_some_and(|c| c.is_empty()), "{label}: nothing is left in the layer"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "{label}: Clear is one undo step"); + check_session(&s).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(members_of(&s, compound), Some(m.clone()), "{label}: Undo puts the compound back"); + } +} + +#[test] +fn a_copied_member_keeps_the_compounds_gradient_where_it_was() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + // Members at x 10..50 and 60..100: the compound's gradient spans x 10..100. + let (compound, m) = compound_of(&mut s, 2); + run(&mut s, "paint.setFill", json!({"ids": [compound.0], "gradient": {"kind": "linear"}})); + let (mut expected, bounds) = { + let c = s.doc().unwrap().doc.node(compound).unwrap(); + (c.appearance.clone(), c.geometric_bounds().unwrap()) + }; + assert!(expected.has_unplaced_gradient(), "the compound's gradient is fitted to its bounds"); + assert_eq!((bounds.x0, bounds.x1), (10.0, 100.0)); + expected.pin_gradients(bounds); + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m[0].0]})); + run(&mut s, "edit.copy", json!({})); + let member = &s.clipboard.nodes[0]; + assert_eq!(member.id, m[0]); + assert!(!member.appearance.has_unplaced_gradient(), "the member's gradient is not refitted to its own bounds"); + assert_eq!(member.appearance, expected, "the gradient keeps the compound's fit"); + + // The member cut and pasted in place draws the gradient over the compound's stretch. + run(&mut s, "edit.cut", json!({})); + let pasted = NodeId(run(&mut s, "edit.pasteInPlace", json!({}))["ids"][0].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.node(pasted).unwrap().appearance, expected); +} + +#[test] +fn the_only_member_of_a_compound_of_one_copies_the_compound() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.cut", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "Cut leaves a compound of one"); + // Its one member selected whole is every member selected whole. + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m[1].0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [compound]); + assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Compound { .. })); +} + +#[test] +fn copy_of_a_member_then_paste_in_front_adds_a_member_to_the_compound() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.copy", json!({})); + // The member is still selected: a path pasted in front of it goes into its compound. + let pasted = NodeId(run(&mut s, "edit.pasteInFront", json!({}))["ids"][0].as_u64().unwrap()); + let doc = &s.doc().unwrap().doc; + assert!(matches!(doc.node(pasted).unwrap().kind, NodeKind::Path { .. })); + assert_eq!(members_of(&s, compound), Some(vec![m[0], pasted, m[1]]), "the pasted path sits just above the member"); + check_session(&s).unwrap(); +} + +#[test] +fn cut_of_a_whole_member_with_anchors_of_another_member_takes_each_by_its_own_rule() { + let after = |command: &str| { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let whole: Vec> = (0..4).map(|i| vec![0, i]).collect(); + run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": whole}, {"id": m[1].0, "anchors": [[0, 1], [0, 2]]}]})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + (s, compound, m) + }; + let (s, compound, m) = after("edit.cut"); + // The whole member and the segment between the two picked anchors went to the clipboard. + assert_eq!(s.clipboard.nodes.len(), 2); + assert!(s.clipboard.nodes.iter().any(|n| n.id == m[0])); + assert!(s.clipboard.nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // The partly picked member stays in the compound, with what Clear leaves of its outline. + assert_eq!(members_of(&s, compound).map(|c| c.len()), Some(1)); + assert_eq!(contours(&s), contours(&after("edit.clear").0)); + assert_eq!(contours(&s).len(), 1); +} + +#[test] +fn cut_of_a_member_of_a_compound_inside_a_group_leaves_the_group_and_compound() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let other = rect(&mut s, 10.0, 120.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [compound.0, other.0]})); + let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.cut", json!({})); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.parent_of(compound), Some(group)); + assert_eq!(members_of(&s, compound), Some(vec![m[1]])); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [m[0]]); + check_session(&s).unwrap(); +} + +#[test] +fn paste_in_place_and_in_front_of_a_cut_member_paste_an_ordinary_path() { + for command in ["edit.pasteInPlace", "edit.pasteInFront"] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let fill = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone(); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.cut", json!({})); + let pasted = run(&mut s, command, json!({})); + let ids = pasted["ids"].as_array().unwrap(); + assert_eq!(ids.len(), 1, "{command}"); + let id = NodeId(ids[0].as_u64().unwrap()); + let doc = &s.doc().unwrap().doc; + let node = doc.node(id).unwrap(); + assert!(matches!(node.kind, NodeKind::Path { .. }), "{command}"); + assert_eq!(node.appearance, fill, "{command}: the path keeps the compound's paint"); + assert!(doc.parent_of(id).and_then(|p| doc.node(p)).is_some_and(|p| p.is_layer()), "{command}: pasted into the layer"); + let anchors: Vec<[f64; 2]> = node.path_data().unwrap().subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect(); + assert!(anchors.contains(&[10.0, 10.0]) && anchors.contains(&[50.0, 50.0]), "{command}: where it was: {anchors:?}"); + assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "{command}"); + check_session(&s).unwrap(); + } } #[test] diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 16deb3d24..56ee67be1 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -59,9 +59,11 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() { run(&mut reopened, "object.scale", json!({"sx":50,"sy":100})); assert_eq!(text(&reopened, id), "10.00 in"); run(&mut reopened, "edit.clear", json!({})); - assert!(reopened.doc().unwrap().doc.node(NodeId(id)).is_none()); + assert_eq!(text(&reopened, id), "10.00 in", "deleting the art leaves the dimension"); + assert!(recipe(&reopened, id).source.is_none(), "no longer linked"); run(&mut reopened, "edit.undo", json!({})); assert_eq!(text(&reopened, id), "10.00 in"); + assert!(recipe(&reopened, id).source.is_some(), "undo links it again"); } /// Documents without dimensions skip the refresh, and lose the flag when the last goes. @@ -210,6 +212,313 @@ fn clicking_two_points_then_placing_measures_between_them() { assert_eq!(text(&s, id), "72.00 pt"); } +/// After two clicked points, a pointer beside them (between the perpendiculars at the points) +/// gives the distance between them (aligned), as does clicking again on the second point. Past +/// either point, the quarter that holds the direction straight up or down from it gives their +/// horizontal distance, and the quarter that holds the direction straight left or right gives +/// their vertical distance. The dimension stays linked to the anchors it measures. +#[test] +fn two_points_measure_horizontally_vertically_or_aligned() { + use vectorcraft_doc::dimension::Orientation; + for ((x, y), want, orientation) in [ + ((210.0, 120.0), "100.00 pt", Orientation::Horizontal), + ((90.0, 380.0), "100.00 pt", Orientation::Horizontal), + ((280.0, 220.0), "100.00 pt", Orientation::Vertical), + ((40.0, 310.0), "100.00 pt", Orientation::Vertical), + ((120.0, 160.0), "141.42 pt", Orientation::Aligned), + ((200.0, 300.0), "141.42 pt", Orientation::Aligned), + ((200.0, 200.0), "141.42 pt", Orientation::Aligned), + ] { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200}))["id"].as_u64().unwrap(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for (px, py) in [(100.0, 300.0), (200.0, 200.0), (x, y)] { + ev(&mut s, PointerKind::Move, px, py); + ev(&mut s, PointerKind::Down, px, py); + ev(&mut s, PointerKind::Up, px, py); + } + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!((text(&s, id), recipe(&s, id).orientation), (want.to_string(), orientation), "pointer at ({x}, {y})"); + assert!(recipe(&s, id).source.is_some_and(|src| src.nodes() == vec![NodeId(line), NodeId(line)])); + if orientation != Orientation::Aligned { + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":200})); + assert_eq!(text(&s, id), "200.00 pt", "follows the art"); + } + } +} + +/// A third click a little past the second point, still within reach of it, places an aligned +/// dimension, also when Shift moved the second point away from where the pointer was: the click +/// counts as clicking again on the second point. +#[test] +fn clicking_again_by_the_second_point_places_an_aligned_dimension() { + use vectorcraft_doc::dimension::Orientation; + let click = |s: &mut Session, x: f64, y: f64, shift: bool| { + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + let e = PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Mods::default() }); + s.pointer(&e, ViewInfo::default()).unwrap(); + } + }; + // Free points, the third click 2.2 pt past the second. + let mut s = session(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + click(&mut s, 100.0, 300.0, false); + click(&mut s, 200.0, 200.0, false); + click(&mut s, 202.0, 201.0, false); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!((text(&s, id), recipe(&s, id).orientation), ("141.42 pt".to_string(), Orientation::Aligned)); + // Shift puts the second point on the 45 degree line, about 21 pt from the pointer. + let mut s = session(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + click(&mut s, 100.0, 300.0, false); + click(&mut s, 260.0, 170.0, true); + click(&mut s, 262.0, 168.0, false); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!((text(&s, id), recipe(&s, id).orientation), ("206.16 pt".to_string(), Orientation::Aligned)); +} + +/// Dragged out from a slanted segment, the dimension follows the drag the same way, wherever on +/// the segment the press was: beside the segment it measures its length, and past either end +/// its horizontal or vertical length. +#[test] +fn a_segment_drag_measures_horizontally_vertically_or_aligned() { + use vectorcraft_doc::dimension::Orientation; + for (press, (x, y), want, orientation) in [ + ((150.0, 250.0), (210.0, 120.0), "100.00 pt", Orientation::Horizontal), + ((110.0, 290.0), (210.0, 120.0), "100.00 pt", Orientation::Horizontal), + ((150.0, 250.0), (280.0, 220.0), "100.00 pt", Orientation::Vertical), + ((190.0, 210.0), (40.0, 310.0), "100.00 pt", Orientation::Vertical), + ((150.0, 250.0), (120.0, 160.0), "141.42 pt", Orientation::Aligned), + ((110.0, 290.0), (200.0, 300.0), "141.42 pt", Orientation::Aligned), + ] { + let mut s = session(); + run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200})); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + ev(&mut s, PointerKind::Move, press.0, press.1); + ev(&mut s, PointerKind::Down, press.0, press.1); + ev(&mut s, PointerKind::Drag, (press.0 + x) / 2.0, (press.1 + y) / 2.0); + ev(&mut s, PointerKind::Drag, x, y); + ev(&mut s, PointerKind::Up, x, y); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!((text(&s, id), recipe(&s, id).orientation), (want.to_string(), orientation), "pressed at {press:?}, dragged to ({x}, {y})"); + } +} + +/// Two points on one vertical line give an aligned dimension wherever the pointer goes: their +/// vertical distance is the distance between them, and they have no horizontal distance. +#[test] +fn points_on_a_vertical_line_measure_aligned() { + use vectorcraft_doc::dimension::Orientation; + for (x, y) in [(60.0, 200.0), (160.0, 80.0), (100.0, 40.0), (40.0, 340.0), (100.0, 300.0)] { + let mut s = session(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for (px, py) in [(100.0, 100.0), (100.0, 300.0), (x, y)] { + ev(&mut s, PointerKind::Move, px, py); + ev(&mut s, PointerKind::Down, px, py); + ev(&mut s, PointerKind::Up, px, py); + } + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!((text(&s, id), recipe(&s, id).orientation), ("200.00 pt".to_string(), Orientation::Aligned), "pointer at ({x}, {y})"); + } +} + +/// The ends of a dimension's generated paths (extension lines first, then the dimension line), +/// rounded. +fn path_ends(s: &Session, id: u64) -> Vec> { + let round = |v: f64| (v * 1e6).round() / 1e6; + let doc = &s.doc().unwrap().doc; + let art = doc.node(NodeId(id)).unwrap().children().unwrap(); + art.iter().filter_map(|n| n.path_data()).map(|p| p.subpaths[0].anchors.iter().map(|a| (round(a.p.x), round(a.p.y))).collect()).collect() +} + +/// A horizontal dimension's line is horizontal and a vertical one's vertical, through the point +/// it was placed at, and each extension line runs from its point to the dimension line. Points +/// given right to left or bottom to top draw the same lines. +#[test] +fn horizontal_and_vertical_dimensions_draw_lines_along_their_axis() { + let mut s = session(); + let r = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"orientation":"horizontal","offset":-150})); + assert_eq!(r["label"], "100.00 pt"); + // Extension lines: 4 pt off each point, 6 pt past the line at y = 150. + let horizontal = vec![vec![(100.0, 296.0), (100.0, 144.0)], vec![(200.0, 196.0), (200.0, 144.0)], vec![(100.0, 150.0), (200.0, 150.0)]]; + assert_eq!(path_ends(&s, r["id"].as_u64().unwrap()), horizontal); + let r = run(&mut s, "dimension.create", json!({"a":[200,200],"b":[100,300],"orientation":"horizontal","offset":-50})); + let swapped = vec![vec![(200.0, 196.0), (200.0, 144.0)], vec![(100.0, 296.0), (100.0, 144.0)], vec![(200.0, 150.0), (100.0, 150.0)]]; + assert_eq!(path_ends(&s, r["id"].as_u64().unwrap()), swapped, "the line stays on the side the offset puts it"); + // A vertical dimension 50 pt left of the first point: the line at x = 50. + let r = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"orientation":"vertical","offset":50})); + assert_eq!(r["label"], "100.00 pt"); + let vertical = vec![vec![(96.0, 300.0), (44.0, 300.0)], vec![(196.0, 200.0), (44.0, 200.0)], vec![(50.0, 300.0), (50.0, 200.0)]]; + assert_eq!(path_ends(&s, r["id"].as_u64().unwrap()), vertical); +} + +/// A horizontal dimension stays horizontal when it or the art it measures turns: it shows the +/// new horizontal distance, and 0 when the points come one above the other. It is not removed. +#[test] +fn turned_horizontal_dimensions_stay_horizontal() { + use vectorcraft_doc::dimension::Orientation; + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[100,200],"b":[200,260],"orientation":"horizontal","offset":-50}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.rotate", json!({"angle":90,"origin":[150,230]})); + assert_eq!((text(&s, id), recipe(&s, id).orientation), ("60.00 pt".to_string(), Orientation::Horizontal)); + // The points are now at y = 180 and 280: the line stays 50 pt above the upper one instead of + // crossing between them. + let d = recipe(&s, id); + let mut ys = [d.a.y.round(), d.b.y.round()]; + ys.sort_by(f64::total_cmp); + assert_eq!(ys, [180.0, 280.0]); + assert!(path_ends(&s, id)[2].iter().all(|(_, y)| (y - 130.0).abs() < 1e-6), "{:?}", path_ends(&s, id)); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":400,"x2":200,"y2":400}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":line,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":"horizontal"}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.rotate", json!({"angle":90})); + assert_eq!((text(&s, id), recipe(&s, id).orientation), ("0.00 pt".to_string(), Orientation::Horizontal)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, id), "100.00 pt"); +} + +/// The line of a linked horizontal dimension keeps its gap from the outermost point on its side +/// when the art turns, so it stays clear of the art: after a half turn the line is still 50 pt +/// above the upper point, which is now the other anchor. +#[test] +fn a_linked_horizontal_dimension_keeps_its_gap_from_turned_art() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":line,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":"horizontal","offset":-150}))["id"].as_u64().unwrap(); + assert_eq!(path_ends(&s, id)[2], vec![(100.0, 150.0), (200.0, 150.0)]); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.rotate", json!({"angle":180,"origin":[150,250]})); + assert_eq!(text(&s, id), "100.00 pt"); + assert_eq!(path_ends(&s, id)[2], vec![(200.0, 150.0), (100.0, 150.0)]); + // Scaled to twice the size about the lower left point, the upper point is at y = 100. + run(&mut s, "object.scale", json!({"sx":200,"sy":200,"origin":[100,300]})); + assert_eq!(text(&s, id), "200.00 pt"); + assert_eq!(path_ends(&s, id)[2], vec![(300.0, 50.0), (100.0, 50.0)]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(path_ends(&s, id)[2], vec![(100.0, 150.0), (200.0, 150.0)]); +} + +/// An unlinked horizontal dimension reflected on its own has its line where the reflection puts +/// it: a line above the points is below them after a flip top to bottom. +#[test] +fn a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side() { + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"orientation":"horizontal","offset":-150}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,250]})); + let d = recipe(&s, id); + assert_eq!((d.a.y.round(), d.b.y.round()), (200.0, 300.0)); + assert_eq!(path_ends(&s, id)[2], vec![(100.0, 350.0), (200.0, 350.0)]); + assert_eq!(text(&s, id), "100.00 pt"); +} + +/// The line of a linked horizontal dimension below both points stays below them when the art +/// turns a half turn; a line between the points keeps its distance from the first point. +#[test] +fn a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art() { + for (offset, want) in [(50.0, vec![(200.0, 350.0), (100.0, 350.0)]), (-50.0, vec![(200.0, 150.0), (100.0, 150.0)])] { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":line,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":"horizontal","offset":offset}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.rotate", json!({"angle":180,"origin":[150,250]})); + assert_eq!(text(&s, id), "100.00 pt"); + assert_eq!(path_ends(&s, id)[2], want, "offset {offset}"); + } +} + +/// A dimension recipe without an orientation reads as aligned. +#[test] +fn a_dimension_without_an_orientation_reads_as_aligned() { + use vectorcraft_doc::dimension::{Dimension, Orientation}; + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[30,40]}))["id"].as_u64().unwrap(); + let v = serde_json::to_value(recipe(&s, id)).unwrap(); + assert!(v.get("orientation").is_none()); + let d: Dimension = serde_json::from_value(v).unwrap(); + assert_eq!(d.orientation, Orientation::Aligned); +} + +/// `orientation` is checked on create and update, and only linear dimensions take one. +#[test] +fn dimension_orientation_is_checked() { + let mut s = session(); + for (p, want) in [ + (json!({"a":[0,0],"b":[30,40],"orientation":"horizontal"}), Some("30.00 pt")), + (json!({"a":[0,0],"b":[30,40],"orientation":"vertical"}), Some("40.00 pt")), + (json!({"a":[0,0],"b":[30,40],"orientation":"aligned"}), Some("50.00 pt")), + (json!({"a":[0,0],"b":[0,40],"orientation":"horizontal"}), Some("0.00 pt")), + (json!({"a":[0,0],"b":[30,40],"orientation":"diagonal"}), None), + (json!({"a":[0,0],"b":[30,40],"orientation":7}), None), + (json!({"a":[0,0],"b":[30,40],"orientation":null}), None), + (json!({"mode":"angular","a":[0,0],"b":[30,40],"vertex":[10,0],"orientation":"horizontal"}), None), + ] { + let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + match (s.execute("dimension.create", &p), want) { + (Ok(r), Some(want)) => assert_eq!(r["label"], want, "{p}"), + (Err(_), None) => assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before, "{p}"), + (r, _) => panic!("{p}: {r:?}"), + } + } + // An aligned dimension is written as before, without the field. + let doc = serde_json::to_string(&*s.doc().unwrap().doc).unwrap(); + assert_eq!(doc.matches("\"orientation\"").count(), 3, "only the horizontal and vertical ones"); + // dimension.update turns an existing linear dimension, and refuses the same bad values. + let id = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[30,40]}))["id"].as_u64().unwrap(); + let angle = run(&mut s, "dimension.create", json!({"mode":"angular","a":[0,0],"b":[30,40],"vertex":[10,0]}))["id"].as_u64().unwrap(); + for (orientation, want) in [("horizontal", "30.00 pt"), ("vertical", "40.00 pt"), ("aligned", "50.00 pt")] { + run(&mut s, "dimension.update", json!({"ids":[id],"orientation":orientation})); + assert_eq!(text(&s, id), want); + } + run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"vertical"})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, id), "50.00 pt", "one undo step"); + let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + for p in [ + json!({"ids":[id],"orientation":"diagonal"}), + json!({"ids":[id],"orientation":7}), + json!({"ids":[angle],"orientation":"vertical"}), + // A linear and an angular dimension together: neither changes. + json!({"ids":[id, angle],"orientation":"vertical"}), + ] { + assert!(s.execute("dimension.update", &p).is_err(), "{p}"); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before, "{p}"); + } + // The orientation it already has, or aligned for an angular one, changes nothing and records + // no undo step: the undo after it goes back to the vertical dimension of the earlier update. + for p in [json!({"ids":[id],"orientation":"aligned"}), json!({"ids":[angle],"orientation":"aligned"})] { + run(&mut s, "dimension.update", p); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before); + } + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, id), "40.00 pt"); +} + +/// A dimension turned with `dimension.update` keeps its line on the side of the points it was +/// on: an aligned line 30 pt above a pair made right to left stays 30 pt above as horizontal, +/// and an aligned line below and right of slanted points goes 30 pt below the lower point. +#[test] +fn a_turned_dimension_keeps_its_line_on_its_side() { + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[200,200],"b":[100,200],"offset":30}))["id"].as_u64().unwrap(); + assert_eq!(path_ends(&s, id)[2], vec![(200.0, 170.0), (100.0, 170.0)]); + run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"horizontal"})); + assert_eq!(path_ends(&s, id)[2], vec![(200.0, 170.0), (100.0, 170.0)]); + // The middle of the aligned line (about 171, 271) lies between the points' heights. + let id = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"offset":30}))["id"].as_u64().unwrap(); + run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"horizontal"})); + assert_eq!(path_ends(&s, id)[2], vec![(100.0, 330.0), (200.0, 330.0)]); +} + /// Dragging out from a corner of two straight segments measures the angle between them. #[test] fn dragging_out_from_a_corner_measures_its_angle() { @@ -260,6 +569,425 @@ fn dimension_anchor_links_and_native_file_survive_source_moves() { assert_eq!(text(&s, id), "100 pt"); } +/// The anchors of path `id`'s first subpath. +fn anchors(s: &Session, id: u64) -> Vec { + let doc = &s.doc().unwrap().doc; + doc.node(NodeId(id)).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect() +} +fn near(p: vectorcraft_geom::Point, q: vectorcraft_geom::Point) -> bool { + p.distance(q) < 1e-6 +} +/// Clicks with the Dimension tool at each point in turn. +fn clicks(s: &mut Session, points: &[(f64, f64)]) -> u64 { + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for &(x, y) in points { + ev(s, PointerKind::Move, x, y); + ev(s, PointerKind::Down, x, y); + ev(s, PointerKind::Up, x, y); + } + s.doc().unwrap().selection.objects[0].0 +} + +/// A dimension from an anchor to a point on a straight segment follows the art at both ends: the +/// segment end keeps its place along the segment when the art is scaled, moved or reflected. +#[test] +fn a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let length = recipe(&s, id).length; + for (command, p) in [ + ("object.scale", json!({"sx":150,"sy":150})), + ("object.move", json!({"dx":40,"dy":-30})), + ("object.rotate", json!({"angle":30})), + ("object.reflect", json!({"axis":"vertical"})), + ] { + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, command, p); + let (d, at) = (recipe(&s, id), anchors(&s, rect)); + assert!(near(d.a, at[0]), "{command}: {:?} {:?}", d.a, at[0]); + assert!(near(d.b, at[1].midpoint(at[2])), "{command}: {:?} {:?}", d.b, at[1].midpoint(at[2])); + assert!(d.source.is_some(), "{command}: still linked"); + } + assert!((recipe(&s, id).length - 1.5 * length).abs() < 1e-6); +} + +/// A point off the art is held against the anchors of the art at the other end, and moves with it. +#[test] +fn a_dimension_to_a_point_off_the_art_moves_with_the_art() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(200.0, 200.0), (330.0, 260.0), (260.0, 330.0)]); + let before = recipe(&s, id); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + let v = vectorcraft_geom::Vec2::new(40.0, -30.0); + assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}"); + assert!((d.length - before.length).abs() < 1e-6 && (d.offset - before.offset).abs() < 1e-6); + run(&mut s, "object.scale", json!({"sx":200,"sy":200})); + assert!((recipe(&s, id).length - 2.0 * before.length).abs() < 1e-6); +} + +/// When the art it measures is deleted or gets another number of anchors, a dimension stays where +/// it was, no longer linked: later edits of the art do not move it, and undo links it again. +#[test] +fn a_dimension_whose_art_changes_its_anchors_stays_where_it_was() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let before = recipe(&s, id); + let label = text(&s, id); + run(&mut s, "path.insertAnchor", json!({"id":rect,"segment":3,"t":0.5})); + let d = recipe(&s, id); + assert!(d.source.is_none(), "no longer linked"); + assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label.clone())); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.scale", json!({"sx":150,"sy":150})); + assert_eq!((recipe(&s, id).a, recipe(&s, id).b), (before.a, before.b), "does not follow the art"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(recipe(&s, id).source, before.source, "undo links it again"); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "edit.clear", json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none()); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label)); +} + +/// A linked dimension whose art is reflected has its line on the reflected side: a line above the +/// top edge is below that edge's points after a flip top to bottom. +#[test] +fn a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side() { + use vectorcraft_doc::dimension::Orientation; + for orientation in ["aligned", "horizontal"] { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let at = anchors(&s, rect); + let top = |p: vectorcraft_geom::Point| (p.y - 100.0).abs() < 1e-9; + let (i, j) = (at.iter().position(|p| top(*p)).unwrap(), at.iter().rposition(|p| top(*p)).unwrap()); + let anchor = |anchor: usize| json!({"node":rect,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(i),"b":anchor(j)}); + // The line 30 pt above the top edge: the offset is along the left normal of a → b when + // aligned, downward when horizontal. + let offset = if orientation == "aligned" && at[i].x > at[j].x { 30.0 } else { -30.0 }; + let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":orientation,"offset":offset}))["id"].as_u64().unwrap(); + let line_y = |s: &Session| recipe(s, id).line_ends().0.y; + assert!((line_y(&s) - 70.0).abs() < 1e-6, "{orientation}: {}", line_y(&s)); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal"})); + let d = recipe(&s, id); + assert!(d.source.is_some()); + assert!(((d.a.y, d.b.y) == (200.0, 200.0)), "{orientation}: {d:?}"); + assert!((line_y(&s) - 230.0).abs() < 1e-6, "{orientation}: line at {}", line_y(&s)); + let want = if orientation == "aligned" { Orientation::Aligned } else { Orientation::Horizontal }; + assert_eq!(d.orientation, want); + run(&mut s, "edit.undo", json!({})); + assert!((line_y(&s) - 70.0).abs() < 1e-6); + } +} + +/// Reflected across the line through the two points it measures, an arch keeps those points +/// where they were, and the dimension's line goes to the other side with the arch. +#[test] +fn a_dimension_of_art_reflected_across_its_points_changes_sides() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":-30}))["id"].as_u64().unwrap(); + assert!((recipe(&s, id).line_ends().0.y - 170.0).abs() < 1e-6); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + let d = recipe(&s, id); + assert_eq!((d.a, d.b), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(200.0, 200.0))); + assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below with the arch: {:?}", d.line_ends()); + // Reflected together with the art, it ends in the same place. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "select.set", json!({"ids":[curve, id]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6, "{:?}", recipe(&s, id).line_ends()); +} + +/// A dimension between two paths that are each reflected in their own way in one edit is not +/// mirrored by the map of one of them. +#[test] +fn a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset() { + let mut s = session(); + let low = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let high = json!([{"x":300,"y":200,"out":[300,100]},{"x":400,"y":200,"in":[400,100]}]); + let first = run(&mut s, "path.create", json!({"anchors":low,"closed":false}))["id"].as_u64().unwrap(); + let second = run(&mut s, "path.create", json!({"anchors":high,"closed":false}))["id"].as_u64().unwrap(); + let src = json!({"kind":"points","a":{"node":first,"subpath":0,"anchor":0},"b":{"node":second,"subpath":0,"anchor":1}}); + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[first, second]})); + run(&mut s, "object.transformEach", json!({"reflectY":true})); + let d = recipe(&s, id); + assert!(d.source.is_some() && (d.a.y - d.b.y).abs() > 1.0, "{d:?}"); + assert!((d.offset - 30.0).abs() < 1e-6, "{d:?}"); +} + +/// A recipe's free points are checked: weights that do not add up to 1, or an end that is both an +/// anchor and a free point, are refused without changing the document. +#[test] +fn dimension_free_points_are_checked() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let original = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + let w = |anchor: u64, weight: f64| json!({"subpath":0,"anchor":anchor,"weight":weight}); + let held = |anchors: Value, across: f64| json!({"kind":"anchors","node":rect,"anchors":anchors,"across":across}); + let on = |segment: u64, t: f64| json!({"kind":"onSegment","edge":{"node":rect,"subpath":0,"segment":segment},"t":t}); + let anchor = json!({"node":rect,"subpath":0,"anchor":0}); + for free in [ + held(json!([w(1, 0.5), w(2, 0.6)]), 0.0), + held(json!([w(1, 1.0)]), 0.0), + held(json!([w(1, 1e300), w(2, -1e300)]), 0.0), + held(json!([w(1, 0.5), w(99, 0.5)]), 0.0), + held(json!([w(1, 0.5), w(2, f64::MAX)]), 0.0), + held(json!([w(1, 0.5), w(2, 0.5)]), 1e300), + held(json!([w(1, 0.5), w(2, 0.25), w(3, 0.25)]), 0.5), + on(1, 1.5), + on(1, -0.1), + on(99, 0.5), + json!({"kind":"elsewhere","node":rect}), + ] { + let p = json!({"source":{"kind":"points","a":anchor,"freeB":free}}); + assert!(s.execute("dimension.create", &p).is_err(), "{p}"); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original); + } + let both = json!({"source":{"kind":"points","a":anchor,"freeA":held(json!([w(1, 0.5), w(2, 0.5)]), 0.0),"b":anchor}}); + assert!(s.execute("dimension.create", &both).is_err()); + let at = anchors(&s, rect); + for (free, want) in [(held(json!([w(1, 0.5), w(2, 0.5)]), 0.0), at[1].midpoint(at[2])), (on(1, 0.5), at[1].midpoint(at[2]))] { + let ok = json!({"source":{"kind":"points","a":anchor,"freeB":free}}); + let id = run(&mut s, "dimension.create", ok)["id"].as_u64().unwrap(); + assert!(near(recipe(&s, id).b, want), "{:?}", recipe(&s, id).b); + } +} + +/// A point off a straight two-anchor line is held by its distance along and across the line: it +/// moves, turns and scales with the line. +#[test] +fn a_dimension_to_a_point_off_a_line_follows_the_line() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(line), NodeId(line)]), "{:?}", d.source); + run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"aligned"})); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.rotate", json!({"angle":90,"origin":[100,100]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":200,"origin":[100,100]})); + run(&mut s, "object.move", json!({"dx":10,"dy":20})); + let at = anchors(&s, line); + let (o, u) = (at[0], at[1] - at[0]); + // 50 along the line and 60 across it before, now in the line's new frame at twice the size. + let want = o + u * 0.5 + vectorcraft_geom::Vec2::new(-u.y, u.x) * 0.6; + let d = recipe(&s, id); + assert!(near(d.a, o) && near(d.b, want), "{d:?} want {want:?}"); + assert!((d.length - 2.0 * (50.0f64.hypot(60.0))).abs() < 1e-6); +} + +/// A point on a straight segment stays on the segment when its handles are edited into a curve. +#[test] +fn a_point_on_a_segment_stays_on_it_when_the_segment_curves() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let at = anchors(&s, rect); + assert!(near(at[1], vectorcraft_geom::Point::new(200.0, 100.0)) && near(at[2], vectorcraft_geom::Point::new(200.0, 200.0)), "{at:?}"); + let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y}); + let mut curved = vec![a(at[0]), a(at[1]), a(at[2]), a(at[3])]; + curved[1]["out"] = json!([240, 110]); + curved[2]["in"] = json!([240, 190]); + run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":curved,"closed":true}]})); + let d = recipe(&s, id); + assert!(d.source.is_some(), "still linked"); + assert!(near(d.b, vectorcraft_geom::Point::new(230.0, 150.0)), "on the curve: {:?}", d.b); +} + +/// The line of a linked dimension of a curved two-anchor path goes to the reflected side when the +/// path is reflected: its handles tell a reflection from a turn. +#[test] +fn a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + // The line 30 pt below the two points (the left normal of a → b points down). + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap(); + assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"vertical"})); + let d = recipe(&s, id); + assert!((d.a.y, d.b.y) == (200.0, 200.0) && d.a.x > d.b.x, "{d:?}"); + assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below the points: {:?}", d.line_ends()); + run(&mut s, "object.reflect", json!({"axis":"horizontal"})); + let d = recipe(&s, id); + assert!((d.line_ends().0.y - (d.a.y - 30.0)).abs() < 1e-6, "above the points: {d:?}"); +} + +/// A point off an arch, whose anchors lie on one line, is held against its anchors and handles: +/// it is reflected with the arch, to the same side as the dimension's line. +#[test] +fn a_point_off_an_arch_is_reflected_with_the_arch() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 120.0), (60.0, 120.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "{:?}", d.b); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + let d = recipe(&s, id); + assert!(near(d.a, vectorcraft_geom::Point::new(100.0, 200.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 280.0)), "below with the arch: {:?}", d.b); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]})); + let d = recipe(&s, id); + assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 200.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "above with the arch: {:?}", d.b); +} + +/// A point beside a curve that is almost straight is still held against it. +#[test] +fn a_point_beside_an_almost_straight_curve_follows_it() { + let mut s = session(); + let flat = json!([{"x":100,"y":200,"out":[100,199.999]},{"x":200,"y":200}]); + let curve = run(&mut s, "path.create", json!({"anchors":flat,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 150.0), (60.0, 120.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + assert!(near(d.b, vectorcraft_geom::Point::new(190.0, 120.0)), "{:?}", d.b); +} + +/// A point off a loop whose two anchors are in one place is held against the loop's handles. +#[test] +fn a_point_off_a_loop_with_its_anchors_in_one_place_follows_it() { + let mut s = session(); + let tear = json!([{"x":100,"y":200,"out":[60,120]},{"x":100,"y":200,"in":[140,120]}]); + let curve = run(&mut s, "path.create", json!({"anchors":tear,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (100.0, 60.0), (30.0, 130.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + assert!(near(d.b, vectorcraft_geom::Point::new(140.0, 30.0)), "{:?}", d.b); +} + +/// Beside a straight two-anchor line, a point is moved by a reflection as a half turn would move +/// it: the line's positions alone do not tell the two apart. +#[test] +fn a_point_beside_a_reflected_line_moves_as_a_half_turn_would() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]})); + let d = recipe(&s, id); + assert!(d.source.is_some()); + assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 100.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 40.0)), "{:?}", d.b); +} + +/// Opening a closed path that a dimension measures leaves the dimension where it was, no longer +/// linked, and undo links it again. +#[test] +fn a_dimension_whose_art_is_opened_stays_where_it_was() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let before = recipe(&s, id); + let label = text(&s, id); + let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y}); + let open: Vec = anchors(&s, rect).into_iter().map(a).collect(); + run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":open,"closed":false}]})); + let d = recipe(&s, id); + assert!(d.source.is_none(), "no longer linked"); + assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(recipe(&s, id).source, before.source, "undo links it again"); +} + +/// An angle whose arm becomes a curve, and an arc radius whose arc stops being circular, stay where +/// they were, no longer linked. +#[test] +fn angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were() { + let mut s = session(); + let first = edge(&mut s); + let second = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":100,"y2":172}))["id"].as_u64().unwrap(); + let angle = json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}}}); + let id = run(&mut s, "dimension.create", angle)["id"].as_u64().unwrap(); + let label = text(&s, id); + let curved = json!([{"x":100,"y":100,"out":[140,130]},{"x":100,"y":172}]); + run(&mut s, "path.setAnchors", json!({"id":second,"subpaths":[{"anchors":curved,"closed":false}]})); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label)); + let circle = run(&mut s, "shape.ellipse", json!({"x":300,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap(); + let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}}); + let id = run(&mut s, "dimension.create", arc)["id"].as_u64().unwrap(); + assert_eq!(text(&s, id), "1.0 in"); + run(&mut s, "select.set", json!({"ids":[circle]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, "1.0 in".to_string())); +} + +/// Saved and opened again, a dimension to points that are not anchors still follows its art. +#[test] +fn free_points_survive_a_native_file() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(200.0, 150.0), (330.0, 260.0), (260.0, 330.0)]); + let before = recipe(&s, id); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let mut reopened = Session::new(); + reopened.add_document(doc, None); + assert_eq!(recipe(&reopened, id).source, before.source); + run(&mut reopened, "select.set", json!({"ids":[rect]})); + run(&mut reopened, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&reopened, id); + let v = vectorcraft_geom::Vec2::new(40.0, -30.0); + assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}"); +} + +/// A linked dimension opened from a file with options it cannot be drawn with keeps its art, as a +/// plain group, when the art it measures is deleted. +#[test] +fn a_dimension_that_cannot_be_drawn_again_keeps_its_art() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 100.0), (150.0, 80.0)]); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let mut v: Value = serde_json::from_slice(&saved).unwrap(); + fn bad_precision(v: &mut Value) { + match v { + Value::Object(m) => { + if let Some(Value::Object(o)) = m.get_mut("dimension").and_then(|d| d.get_mut("options")) { + o.insert("precision".into(), json!(99)); + } + m.values_mut().for_each(bad_precision); + } + Value::Array(a) => a.iter_mut().for_each(bad_precision), + _ => {} + } + } + bad_precision(&mut v); + let mut reopened = Session::new(); + reopened.add_document(vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(), None); + let art = reopened.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap().clone(); + run(&mut reopened, "select.set", json!({"ids":[rect]})); + run(&mut reopened, "edit.clear", json!({})); + let n = reopened.doc().unwrap().doc.node(NodeId(id)).cloned().expect("the dimension's art stays"); + assert!(n.extras().dimension.is_none(), "no longer a live dimension"); + assert_eq!(n.children().unwrap(), &art); +} + /// A linked dimension moved on its own keeps its new place when its art changes later; moved with /// its art, it keeps its distance from it. #[test] @@ -346,7 +1074,8 @@ fn an_expanded_dimension_stays_plain_art() { assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); run(&mut s, "edit.clear", json!({})); assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); - assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none()); + assert_eq!(text(&s, other), "20.00 in", "the live dimension stays, no longer linked"); + assert!(recipe(&s, other).source.is_none()); // Saved and opened again, it is still plain art. let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let mut reopened = Session::new(); @@ -435,16 +1164,10 @@ fn an_expand_that_changes_no_art_leaves_a_dimension_live() { assert_eq!(art(&s, id), live); } -/// A dimension selected with the art it measures, or held by a selected group, expands the same -/// way: outlining that art does not remove it. +/// A dimension held by a selected group without the art it measures expands as a dimension +/// selected on its own does. #[test] -fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { - let mut s = session(); - let line = edge(&mut s); - let id = dim(&mut s, line); - run(&mut s, "select.set", json!({"ids":[line, id]})); - run(&mut s, "object.expand", json!({})); - stays_plain_art(&mut s, id); +fn a_dimension_expanded_in_a_group_without_its_art_stays_plain_art() { let mut s = session(); let line = edge(&mut s); let id = dim(&mut s, line); @@ -454,8 +1177,176 @@ fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { stays_plain_art(&mut s, id); } -/// Expand replacing only the art a selected dimension measures leaves the dimension's art as it -/// is, without a link to the art that went. +/// `command` on a dimension selected with the art it measures, or on a group holding both, leaves +/// the dimension's own lines and label as they are and replaces only the art: the dimension stays +/// where it was, no longer linked, and one undo links it again. +fn keeps_its_own_lines_and_label(command: &str) { + for grouped in [false, true] { + let context = format!("{command}, grouped: {grouped}"); + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "appearance.addStroke", json!({})); + let id = dim(&mut s, art_id); + let live = art(&s, id); + let label = text(&s, id); + let at = bounds(&s, id); + let linked = recipe(&s, id); + let measured = s.doc().unwrap().doc.node(NodeId(art_id)).unwrap().clone(); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + if grouped { + run(&mut s, "object.group", json!({})); + } + let steps = s.doc().unwrap().history.undo.len(); + let result = run(&mut s, command, json!({})); + if command == "effect.expandAppearance" { + let mut own = vec![]; + s.doc().unwrap().doc.node(NodeId(id)).unwrap().walk(&mut |c| own.push(json!(c.id.0))); + assert!(result["ids"].as_array().unwrap().iter().all(|i| !own.contains(i)), "{context}: {result}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{context}: one undo step"); + assert_ne!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}: the art is replaced"); + assert_eq!(art(&s, id), live, "{context}: its own lines and label"); + assert_eq!(bounds(&s, id), at, "{context}: where it was"); + stays_unlinked(&mut s, id, &label, &context); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "{context}"); + assert_eq!(recipe(&s, id), linked, "{context}: one undo links it again"); + assert_eq!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}"); + } +} + +#[test] +fn expand_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("object.expand"); +} + +#[test] +fn expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("effect.expandAppearance"); +} + +#[test] +fn outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("object.path.outlineStroke"); +} + +/// A selected group whose blur Expand Appearance turns into an image takes the dimension it holds, +/// with the art it measures, into that image. +#[test] +fn a_blurred_group_expanded_takes_its_dimension_into_the_image() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + let id = dim(&mut s, art_id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[group]})); + run(&mut s, "effect.apply", json!({"effect":"blur.gaussian"})); + run(&mut s, "effect.expandAppearance", json!({})); + let d = &s.doc().unwrap().doc; + assert!(d.node(NodeId(id)).is_none(), "the dimension is part of the image"); + let mut images = 0; + d.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + assert_eq!(images, 1); +} + +/// Object › Expand keeps a dimension selected with its art in the export assets that list it and +/// bound to its variables. +#[test] +fn expand_keeps_a_dimension_in_its_asset_and_its_variable_binding() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + let id = dim(&mut s, art_id); + let asset = run(&mut s, "assets.add", json!({"ids":[id]}))["assets"][0].as_u64().unwrap(); + run(&mut s, "variable.define", json!({"name":"Shown","kind":"visibility"})); + run(&mut s, "variable.bind", json!({"variable":"Shown","ids":[id]})); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({})); + let d = &s.doc().unwrap().doc; + assert!(d.node(NodeId(id)).is_some_and(|n| n.extras().dimension.is_some()), "still a dimension"); + let listed = d.assets.iter().find(|a| a.id == asset).map(|a| a.nodes.clone()); + assert_eq!(listed, Some(vec![NodeId(id)]), "in its asset"); + assert!(d.variables.bindings.get(&NodeId(id)).is_some_and(|b| b.contains("Shown")), "bound to its variable"); +} + +/// A selected group with a geometry effect of its own bakes that effect into everything it holds +/// when its appearance expands, the dimension's lines and label included: the dimension becomes a +/// plain group of that art, as one selected without its art does. +#[test] +fn a_group_effect_expanded_reaches_the_dimension_it_holds() { + for command in ["effect.expandAppearance", "object.expand"] { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + let id = dim(&mut s, art_id); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[group]})); + run(&mut s, "effect.apply", json!({"effect":"distort.puckerBloat"})); + run(&mut s, command, json!({})); + let d = &s.doc().unwrap().doc; + let n = d.node(NodeId(id)).unwrap_or_else(|| panic!("{command}: the dimension's group is still there")); + assert!(n.extras().dimension.is_none(), "{command}: a plain group"); + assert_ne!(art(&s, id), live, "{command}: its art is baked with the group's effect"); + } +} + +/// When the dimension selected with its art is all there is to change, the command fails and +/// changes nothing. +#[test] +fn a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "paint.setStroke", json!({"none": true})); + // A plain path: Expand would make a live rectangle a path. + run(&mut s, "object.expand", json!({})); + let id = dim(&mut s, art_id); + for grouped in [false, true] { + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + if grouped { + run(&mut s, "object.group", json!({})); + } + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!(info["appearance"], json!(false), "grouped: {grouped}: Object › Expand… is not dimmed for the dimension's label"); + assert_eq!(info["stroke"], json!(false), "grouped: {grouped}: {info}"); + assert!(!vectorcraft_engine::cmd::expand::has_appearance(&s), "grouped: {grouped}: the menus read this"); + for (command, p) in [ + ("object.expand", json!({})), + ("object.expand", json!({"object":false,"fill":false})), + ("object.path.outlineStroke", json!({})), + ("effect.expandAppearance", json!({})), + ] { + assert!(s.execute(command, &p).is_err(), "grouped: {grouped}: {command} {p}"); + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "grouped: {grouped}: {command} {p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "grouped: {grouped}: {command} {p}"); + } + } +} + +/// The dimension `id` is a dimension that measures nothing, still showing `label`, and a move +/// takes it along without redrawing it from other art. +fn stays_unlinked(s: &mut Session, id: u64, label: &str, context: &str) { + let live = art(s, id); + assert!(live.2, "{context}: still a dimension"); + assert!(recipe(s, id).source.is_none(), "{context}: no longer linked"); + assert_eq!(text(s, id), label, "{context}"); + let at = bounds(s, id); + run(s, "select.set", json!({"ids":[id]})); + run(s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(s, id), live, "{context}"); + assert_eq!(text(s, id), label, "{context}"); + let moved = bounds(s, id); + assert!((moved[0] - at[0] - 30.0).abs() < 1e-6 && (moved[1] - at[1]).abs() < 1e-6, "{context}: {at:?} moved to {moved:?}"); + run(s, "edit.undo", json!({})); +} + +/// Expand replacing only the art a selected dimension measures leaves the dimension where it was, +/// still a dimension, without a link to the art that went; undo links it again. #[test] fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { for circle in [false, true] { @@ -468,18 +1359,21 @@ fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { } else { dim(&mut s, art_id) }; - let (nodes, labels, _) = art(&s, id); + let live = art(&s, id); + let label = text(&s, id); run(&mut s, "select.set", json!({"ids":[art_id, id]})); run(&mut s, "object.expand", json!({"object":false,"stroke":false})); - assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); - run(&mut s, "select.set", json!({"ids":[id]})); - run(&mut s, "object.move", json!({"dx":30,"dy":0})); - assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); + stays_unlinked(&mut s, id, &label, &format!("circle: {circle}")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "circle: {circle}"); + assert!(recipe(&s, id).source.is_some(), "circle: {circle}: one undo links it again"); } } -/// Outline Stroke and Expand Appearance replacing the art a selected dimension measures leave the -/// dimension as a plain group, whether or not they change its own art. +/// Outline Stroke and Expand Appearance replacing only the art a selected dimension measures +/// leave the dimension where it was, still a dimension, no longer linked; undo links it again. +/// Here the dimension's lines are guides, which neither command changes, and the selection holds +/// the whole dimension or one of its lines. #[test] fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { for command in ["object.path.outlineStroke", "effect.expandAppearance"] { @@ -496,19 +1390,44 @@ fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { }; run(&mut s, "select.set", json!({"ids":lines})); run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + let label = text(&s, id); let picked = if whole { id } else { lines[0] }; run(&mut s, "select.set", json!({"ids":[art_id, picked]})); run(&mut s, command, json!({})); assert!(s.doc().unwrap().doc.node(NodeId(id)).is_some(), "{command} removed the dimension, whole: {whole}"); - let plain = art(&s, id); - assert!(!plain.2, "{command}: no recipe left, whole: {whole}"); - run(&mut s, "select.set", json!({"ids":[id]})); - run(&mut s, "object.move", json!({"dx":30,"dy":0})); - assert_eq!(art(&s, id), plain, "{command}, whole: {whole}"); + stays_unlinked(&mut s, id, &label, &format!("{command}, whole: {whole}")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "{command}, whole: {whole}"); + assert!(recipe(&s, id).source.is_some(), "{command}, whole: {whole}: one undo links it again"); } } } +/// Object › Expand that outlines only the stroke of the art a selected dimension measures leaves +/// the dimension a dimension, no longer linked, as Outline Stroke does. +#[test] +fn a_dimension_outlasts_the_stroke_expand_outlined() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + let id = dim(&mut s, art_id); + let lines: Vec = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect() + }; + run(&mut s, "select.set", json!({"ids":lines})); + run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + let label = text(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({"object":false,"fill":false})); + stays_unlinked(&mut s, id, &label, "object.expand stroke"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live); + assert!(recipe(&s, id).source.is_some(), "one undo links it again"); +} + /// A part of a dimension expanded on its own ends the link too, so the move does not undo it. #[test] fn expanding_a_part_of_a_dimension_ends_its_link() { @@ -542,10 +1461,11 @@ fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() { stays_plain_art(&mut s, root); } -/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step. +/// Create Outlines on a dimension, also one selected with the art it measures, or on its label +/// alone, ends the link in the same undo step. #[test] fn outlining_a_dimension_or_its_label_ends_its_link() { - for label_alone in [false, true] { + for target in ["dimension", "label", "with its art"] { let mut s = session(); let line = edge(&mut s); let id = dim(&mut s, line); @@ -554,8 +1474,12 @@ fn outlining_a_dimension_or_its_label_ends_its_link() { let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id }; - let target = if label_alone { label.0 } else { id }; - run(&mut s, "select.set", json!({"ids":[target]})); + let ids = match target { + "label" => vec![label.0], + "with its art" => vec![line, id], + _ => vec![id], + }; + run(&mut s, "select.set", json!({"ids":ids})); run(&mut s, "type.createOutlines", json!({})); stays_plain_art(&mut s, id); run(&mut s, "edit.undo", json!({})); @@ -668,7 +1592,9 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() { run(&mut s, "object.scale", json!({"sx":200,"sy":200})); assert_eq!(text(&s, id), "4 in"); run(&mut s, "object.scale", json!({"sx":200,"sy":100})); - assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none()); + // An ellipse has no single radius: the dimension stays where it was, no longer linked. + assert_eq!(text(&s, id), "4 in"); + assert!(recipe(&s, id).source.is_none()); assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err()); } diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index 6c84c66b3..c9b5ac466 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -1,8 +1,8 @@ //! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`) //! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these //! tests run both over one set of scripts and fail on any disagreement about the lines a script has, -//! its preprocessor lines, and where a string, comment or regular expression that is never closed -//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which +//! its preprocessor lines, where a string, comment or regular expression that is never closed +//! starts, and the characters a name may hold. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which //! only the checker reads). use vectorcraft_js::{Options, TokenKind, parse, tokenize}; @@ -88,6 +88,42 @@ fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { } } +/// Characters on both sides of the identifier rule: `Other_ID_Start`, `Other_ID_Continue`, connector +/// punctuation, marks, digits and letters of other scripts, and a superscript digit, a +/// `Pattern_Syntax` letter and punctuation that no name holds. +const NAME_CHARS: &[char] = &[ + '\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{200C}', '\u{200D}', '\u{203F}', + '\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}', '\u{FF65}', +]; + +/// Whether the parser reads `src` as one identifier spelling `name`. +fn one_identifier(src: &str, name: &str) -> bool { + let want = TokenKind::Ident(name.to_string()); + tokenize(src, &js_options()).is_ok_and(|tokens| tokens.iter().filter(|t| t.kind != TokenKind::Eof).map(|t| &t.kind).eq([&want])) +} + +/// Whether the checker lists the member `app.` as `app.`. +fn checker_member(src: &str, name: &str) -> bool { + let report = check("script.jsx", format!("app.{src};\n").as_bytes(), None, &Limits::default()); + report.members.iter().any(|m| m.name == format!("app.{name}")) +} + +#[test] +fn both_lexers_take_the_same_characters_into_names() { + assert!(checker_member("a\u{2118}b", "a\u{2118}b") && one_identifier("a\u{2118}b", "a\u{2118}b")); + for &c in NAME_CHARS { + let escape = format!("\\u{:04X}", u32::from(c)); + for (src, name) in [ + (format!("a{c}b"), format!("a{c}b")), + (format!("{c}b"), format!("{c}b")), + (format!("a{escape}b"), format!("a{c}b")), + (format!("{escape}b"), format!("{c}b")), + ] { + assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c)); + } + } +} + #[test] fn the_parser_reads_a_regexp_wherever_the_checker_does() { // Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7f85e5acd..9227ee199 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -27,6 +27,8 @@ mod param_keys; mod paste_many; mod pen_click_after_smooth; mod pen_endpoint_picking; +mod ruler_guide_nodes; +mod ruler_guide_perf; mod ruler_origin; mod selection_commands; mod stroke_visibility; diff --git a/crates/engine/tests/it/ruler_guide_nodes.rs b/crates/engine/tests/it/ruler_guide_nodes.rs new file mode 100644 index 000000000..95625d65d --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_nodes.rs @@ -0,0 +1,439 @@ +//! Ruler guides held as nodes of a layer: commands that work on a layer's art leave them out and +//! keep them above the art. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_doc::{Node, NodeId, NodeKind}; +use vectorcraft_engine::Session; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id(&s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()) +} + +/// A ruler guide added on top of `layer`'s contents. +fn guide(s: &mut Session, layer: NodeId, pos: f64) -> NodeId { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + d.insert(Some(layer), usize::MAX, g).unwrap() +} + +fn children(s: &Session, layer: NodeId) -> Vec { + s.doc().unwrap().doc.children(Some(layer)).unwrap().iter().map(|c| c.id).collect() +} + +/// A document with a layer holding two rectangles and two ruler guides: (layer, [art], [guides]). +fn session() -> (Session, NodeId, Vec, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let art = vec![rect(&mut s, 10.0), rect(&mut s, 50.0)]; + let guides = vec![guide(&mut s, layer, 5.0), guide(&mut s, layer, 7.0)]; + (s, layer, art, guides) +} + +#[test] +fn layer_commands_keep_ruler_guides_above_the_art() { + // Send to Current Layer puts the object below the current layer's guides. + let (mut s, a, art, guides) = session(); + let b = id(&s.execute("layer.new", &json!({})).unwrap()); + let moved = rect(&mut s, 90.0); + s.execute("layer.setCurrent", &json!({"id": a.0})).unwrap(); + s.execute("select.set", &json!({"ids": [moved.0]})).unwrap(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, guides[0], guides[1]]); + // Already on top of the art: nothing changes and no undo step is added. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + + // Moving a row on top of a layer's contents puts it below the guides. + let other = rect(&mut s, 130.0); + s.execute("layer.move", &json!({"id": other.0, "target": a.0})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, other, guides[0], guides[1]]); + + // Merging layers keeps every guide above the merged art. + let gb = guide(&mut s, b, 9.0); + let late = { + s.execute("layer.setCurrent", &json!({"id": b.0})).unwrap(); + rect(&mut s, 170.0) + }; + s.execute("layer.merge", &json!({"ids": [a.0, b.0]})).unwrap(); + let merged = children(&s, b); + let first_guide = merged.iter().position(|c| [guides[0], guides[1], gb].contains(c)).unwrap(); + assert_eq!(merged.len() - first_guide, 3, "{merged:?}"); + assert!(merged.contains(&late)); +} + +#[test] +fn release_and_clipping_leave_ruler_guides_alone() { + let (mut s, layer, art, guides) = session(); + // Make Clipping Mask clips by the top object, not by a guide above it. + assert_eq!(s.execute("layer.clippingMask.toggle", &json!({"id": layer.0})).unwrap()["clip"], json!(true)); + assert_eq!(children(&s, layer), [art[1], art[0], guides[0], guides[1]]); + s.execute("edit.undo", &json!({})).unwrap(); + + // Release to Layers makes layers of the art only, below the guides. + s.execute("layer.releaseToLayers", &json!({"id": layer.0})).unwrap(); + let ch = children(&s, layer); + assert_eq!(ch.len(), 4); + assert_eq!(ch[2..], [guides[0], guides[1]]); + let d = &s.doc().unwrap().doc; + assert!(ch[..2].iter().all(|c| d.node(*c).is_some_and(Node::is_layer))); +} + +#[test] +fn ruler_guides_are_not_art_to_select_paint_or_inspect() { + let (mut s, layer, art, guides) = session(); + // Target Contents selects the layer's objects only. + let out = s.execute("appearance.targetContents", &json!({"ids": [layer.0]})).unwrap(); + assert_eq!(out["ids"], json!([art[0].0, art[1].0])); + // Painting the layer paints its objects and leaves the guides with no appearance. + s.execute("paint.setFill", &json!({"ids": [layer.0], "color": "#ff0000"})).unwrap(); + let d = &s.doc().unwrap().doc; + for g in &guides { + assert_eq!(d.node(*g).unwrap().appearance, vectorcraft_doc::Appearance::default()); + } + // Document inspection lists the objects, not the guides. + assert_eq!(s.execute("document.find", &json!({"kind": "Guide"})).unwrap()["total"], json!(0)); + let layer_row = s.execute("document.inspect", &json!({"ids": [layer.0]})).unwrap(); + let listed = layer_row.to_string(); + assert!(guides.iter().all(|g| !listed.contains(&format!("\"id\":{}", g.0))), "{listed}"); + // Unlock All does not reach a locked guide. + Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(guides[0]).unwrap().locked = true; + s.execute("object.unlockAll", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(guides[0]).unwrap().locked); +} + +#[test] +fn flattening_a_sublayers_art_keeps_its_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = rect(&mut s, 10.0); + s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 50})).unwrap(); + let g = guide(&mut s, sub, 5.0); + s.execute("object.flattenTransparency", &json!({"ids": [sub.0]})).unwrap(); + // The sublayer stands for its art: the art is flattened and the guide stays on top of it. + let d = &s.doc().unwrap().doc; + assert!(d.node(r).is_none(), "the art was flattened"); + assert!(d.node(g).is_some_and(Node::is_ruler_guide), "the guide is kept"); + assert_eq!(d.parent_of(g), Some(sub)); + assert_eq!(children(&s, sub).len(), 2); + assert_eq!(children(&s, sub).last(), Some(&g)); + assert_eq!(d.parent_of(sub), Some(layer)); +} + +#[test] +fn object_commands_refuse_a_ruler_guide_id() { + let (mut s, _, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0]})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len()); + for (command, params) in [ + ("select.set", json!({"ids": [guides[0].0]})), + ("select.set", json!({"ids": [art[1].0, guides[1].0]})), + ("edit.clear", json!({"ids": [guides[0].0]})), + ("object.lock", json!({"ids": [guides[0].0]})), + ("object.move", json!({"ids": [guides[0].0], "dx": 5, "dy": 0})), + ("paint.setFill", json!({"ids": [guides[1].0], "color": "#ff0000"})), + ] { + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains("is a ruler guide, not an object"), "{command}: {e}"); + let d = s.doc().unwrap(); + assert_eq!((&d.doc, &d.selection, d.history.undo.len()), (&before.0, &before.1, before.2), "{command} changed something"); + } + // A selection holding a guide id as an object loses it when pruned; the guide stays. + let d = &s.doc().unwrap().doc; + let mut sel = vectorcraft_doc::Selection { objects: vec![art[0], guides[0]], key: Some(guides[1]), ..Default::default() }; + sel.prune(d); + assert_eq!((sel.objects, sel.key), (vec![art[0]], None)); + assert!(d.node(guides[0]).is_some_and(Node::is_ruler_guide)); +} + +#[test] +fn a_ruler_guide_stays_directly_in_a_layer() { + let (mut s, layer, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0, art[1].0]})).unwrap(); + let group = id(&s.execute("object.group", &json!({})).unwrap()); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + // Reorder refuses a group as a guide's parent, and the top level. + for parent in [json!(group.0), json!(null)] { + assert!(s.execute("node.move", &json!({"id": guides[0].0, "parent": parent, "index": 0})).is_err(), "{parent}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&before.0, before.1)); + } + // So do the document's own insert and move. + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + assert!(d.move_node(guides[1], Some(group), 0).is_err()); + let loose = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + assert!(d.insert(Some(group), 0, loose.clone()).is_err()); + assert!(d.insert(None, 0, loose.clone()).is_err()); + assert_eq!(d.ruler_guides().iter().filter_map(|g| g.id).collect::>(), guides); + assert_eq!(children(&s, layer), [group, guides[0], guides[1]]); + // Within its layer it still moves among the guides. + s.execute("node.move", &json!({"id": guides[1].0, "parent": layer.0, "index": 0})).unwrap(); + assert_eq!(children(&s, layer), [group, guides[1], guides[0]]); +} + +/// Arrange counts only a layer's art: the guides above it are no place to move to. +#[test] +fn arranging_counts_only_a_layers_art() { + let (mut s, layer, art, guides) = session(); + let top = rect(&mut s, 90.0); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); + s.execute("select.set", &json!({"ids": [art[0].0, top.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "the selection keeps its order at the top of the art"); + // The topmost art brought to the front or forward: nothing changes and no undo step is added. + s.execute("select.set", &json!({"ids": [top.0]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + for command in ["object.arrange.bringToFront", "object.arrange.bringForward"] { + s.execute(command, &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "{command}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{command}"); + } + s.execute("select.set", &json!({"ids": [art[1].0]})).unwrap(); + s.execute("object.arrange.bringForward", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); +} + +/// A guide made while an opacity mask, a pattern or a symbol is edited goes on the temporary +/// editing layer, and stays in the document when editing ends; a save made during mask editing +/// keeps it. +#[test] +fn guides_made_while_editing_a_mask_a_pattern_or_a_symbol_stay() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let edit_layer = s.doc().unwrap().doc.mask_edit.expect("editing the mask").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 40})).unwrap(); + let made = s.doc().unwrap().doc.ruler_guides(); + assert_eq!(made.iter().map(|g| (g.layer, g.pos)).collect::>(), [(Some(edit_layer), 40.0)]); + let saved = s.doc().unwrap().doc.without_edit_modes().ruler_guides(); + assert_eq!(saved.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)], "a save keeps the guide"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("object.pattern.make", &json!({})).unwrap(); + let tile_layer = s.doc().unwrap().doc.pattern_edit.as_ref().expect("editing the pattern").layer; + s.execute("guide.add", &json!({"vertical": false, "pos": 25})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(tile_layer)); + s.execute("object.pattern.done", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [25.0]); + assert!(kept[0].layer.is_some_and(|l| l != tile_layer && d.node(l).is_some_and(Node::is_layer))); + let tile = &d.patterns.last().expect("the new pattern").art; + assert!(tile.iter().all(|n| !n.is_ruler_guide()), "the guide is not part of the tile"); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + let instance = id(&s.execute("symbol.new", &json!({"name": "Mark"})).unwrap()); + s.execute("symbol.edit", &json!({"ids": [instance.0]})).unwrap(); + let edit_layer = s.doc().unwrap().doc.symbol_edit.as_ref().expect("editing the symbol").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 15})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(edit_layer)); + s.execute("symbol.exitEditing", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [15.0]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + let art = &d.symbols.iter().find(|x| x.name == "Mark").expect("the symbol").art; + let mut in_symbol = false; + art.walk(&mut |n| in_symbol |= n.is_ruler_guide()); + assert!(!in_symbol, "the guide is not part of the symbol"); +} + +/// The bytes of each export format (PDF without the embedded native document, which keeps the +/// guides), and the box Fit All in Window fits (the art and every artboard). +fn exports(s: &mut Session) -> (Vec<(String, String)>, Option) { + let files = [json!({"format": "svg"}), json!({"format": "pdf", "preserveEditing": false}), json!({"format": "png"}), json!({"format": "psd"})] + .iter() + .map(|p| { + let out = s.execute("document.export", p).unwrap(); + (p["format"].as_str().unwrap().to_string(), out["dataBase64"].as_str().unwrap().to_string()) + }) + .collect(); + let d = &s.doc().unwrap().doc; + let fit = d.art_bounds().map(|a| d.artboards.iter().fold(a, |r, ab| r.union(ab.rect))); + (files, fit) +} + +/// Ruler guides on a layer, on a sublayer and on an artboard change no export and not the box +/// Fit All in Window fits. +#[test] +fn exports_and_fit_all_are_the_same_with_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + rect(&mut s, 10.0); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 60, "y": 30, "width": 40, "height": 30})).unwrap(); + let without = exports(&mut s); + assert_eq!(exports(&mut s), without, "the exports are repeatable"); + // Guides far outside the art too: they must not widen any box. + s.execute("guide.add", &json!({"vertical": true, "pos": -5000, "layer": layer.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 9000, "layer": sub.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 120, "artboard": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); + let with = exports(&mut s); + for ((format, a), (_, b)) in without.0.iter().zip(&with.0) { + assert!(a == b, "{format} export changed with ruler guides present"); + } + assert_eq!(with.1, without.1, "Fit All"); + // A PDF that preserves editing carries them in its native document, and opens with them. + let guides = s.doc().unwrap().doc.ruler_guides(); + let pdf = s.execute("document.export", &json!({"format": "pdf"})).unwrap()["dataBase64"].clone(); + s.execute("document.open", &json!({"name": "guides.pdf", "dataBase64": pdf})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides(), guides); +} + +/// One step of [`guide_commands_follow_a_list_model`]. +#[derive(Clone, Debug)] +enum GuideOp { + Add { vertical: bool, pos: u16, layer: usize }, + Remove(usize), + Copy(usize), + Paste, + SetCurrent(usize), + Undo, + Redo, +} + +fn guide_op() -> impl proptest::strategy::Strategy { + use proptest::prelude::*; + prop_oneof![ + 3 => (any::(), 0u16..400, 0usize..3).prop_map(|(vertical, pos, layer)| GuideOp::Add { vertical, pos, layer }), + 2 => (0usize..16).prop_map(GuideOp::Remove), + 1 => (0usize..16).prop_map(GuideOp::Copy), + 1 => Just(GuideOp::Paste), + 1 => (0usize..3).prop_map(GuideOp::SetCurrent), + 2 => Just(GuideOp::Undo), + 1 => Just(GuideOp::Redo), + ] +} + +/// A guide as `guide.list` reports it: (vertical, pos, layer). +type Line = (bool, f64, u64); + +fn listed(s: &mut Session) -> Vec { + let rows = s.execute("guide.list", &json!({})).unwrap(); + rows.as_array() + .unwrap() + .iter() + .enumerate() + .map(|(i, r)| { + assert_eq!(r["index"], json!(i)); + (r["vertical"].as_bool().unwrap(), r["pos"].as_f64().unwrap(), r["layer"].as_u64().unwrap()) + }) + .collect() +} + +/// Runs `ops` on a document with two layers and a sublayer, checking `guide.list` against a plain +/// list after every step: Add appends (a new guide takes the highest id), Remove takes the entry +/// out, Paste appends the copied guide on the current layer at the same place, and Undo and +/// Redo bring back the list as it was, the same indexes included. +fn run_guide_ops(ops: &[GuideOp]) -> Result<(), proptest::test_runner::TestCaseError> { + use proptest::prop_assert_eq; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let bottom = s.doc().unwrap().doc.layers[0].id; + let top = id(&s.execute("layer.new", &json!({})).unwrap()); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": bottom.0})).unwrap()); + let layers = [bottom, top, sub]; + s.execute("layer.setCurrent", &json!({"id": bottom.0})).unwrap(); + let mut current = bottom; + let mut model: Vec = vec![]; + let mut clip: Option<(bool, f64)> = None; + let (mut undo, mut redo): (Vec>, Vec>) = (vec![], vec![]); + for (step, op) in ops.iter().enumerate() { + let steps = s.doc().unwrap().history.undo.len(); + let before = model.clone(); + // Whether the step is an edit (one undo step). + let edit = match *op { + GuideOp::Add { vertical, pos, layer } => { + let l = layers[layer]; + let r = s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "layer": l.0})).unwrap(); + model.push((vertical, f64::from(pos), l.0)); + prop_assert_eq!(&r["index"], &json!(model.len() - 1), "step {}", step); + true + } + GuideOp::Remove(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.remove", &json!({"index": i})).unwrap(); + model.remove(i); + true + } + GuideOp::Copy(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.select", &json!({"indexes": [i]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + clip = Some((model[i].0, model[i].1)); + false + } + GuideOp::Paste => match clip { + Some((vertical, pos)) => { + s.execute("edit.paste", &json!({})).unwrap(); + model.push((vertical, pos, current.0)); + true + } + None => false, + }, + GuideOp::SetCurrent(l) => { + current = layers[l]; + s.execute("layer.setCurrent", &json!({"id": current.0})).unwrap(); + false + } + GuideOp::Undo => { + if let Some(m) = undo.pop() { + s.execute("edit.undo", &json!({})).unwrap(); + redo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Redo => { + if let Some(m) = redo.pop() { + s.execute("edit.redo", &json!({})).unwrap(); + undo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Remove(_) | GuideOp::Copy(_) => false, + }; + let after = s.doc().unwrap().history.undo.len(); + if edit { + prop_assert_eq!(after, steps + 1, "step {} {:?}: one undo step", step, op); + undo.push(before); + redo.clear(); + } else if !matches!(op, GuideOp::Undo | GuideOp::Redo) { + prop_assert_eq!(after, steps, "step {} {:?}: no undo step", step, op); + } + prop_assert_eq!(listed(&mut s), model.clone(), "step {} {:?}", step, op); + } + Ok(()) +} + +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config { cases: 64, failure_persistence: None, ..proptest::test_runner::Config::default() })] + + /// Add, remove, copy, paste, undo and redo keep `guide.list` in step with a plain list, on + /// three layers. + #[test] + fn guide_commands_follow_a_list_model(ops in proptest::collection::vec(guide_op(), 1..40)) { + run_guide_ops(&ops)?; + } +} diff --git a/crates/engine/tests/it/ruler_guide_perf.rs b/crates/engine/tests/it/ruler_guide_perf.rs new file mode 100644 index 000000000..1b5de5c5e --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_perf.rs @@ -0,0 +1,130 @@ +//! Timings of a document with 10,000 ruler guides and 100,000 objects: save, load, open, the guide +//! list, hovering and dragging with the Selection tool, an edit that does not touch guides and one +//! that does. Ignored by default; it prints its timings and sets no bound on them: +//! `cargo test -p vectorcraft-engine --test it ruler_guide_perf -- --ignored --nocapture`. + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::Document; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::Rect; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +const GUIDES: usize = 10_000; +const LAYERS: usize = 4; +const GROUPS_PER_LAYER: usize = 250; +const RECTS_PER_GROUP: usize = 100; + +/// The `.vectorcraft` file of the document: 100,000 rectangles in groups of 100 on four layers, +/// and the ruler guides in its saved list, spread over the layers. +fn file() -> Vec { + let mut b = DocBuilder::new(2000.0, 2000.0); + let mut layers = vec![b.layer]; + for l in 0..LAYERS { + if l > 0 { + layers.push(b.layer(&format!("Layer {}", l + 1))); + } + for g in 0..GROUPS_PER_LAYER { + let kids = (0..RECTS_PER_GROUP) + .map(|r| { + let (x, y) = (((g * RECTS_PER_GROUP + r) % 400) as f64 * 5.0, (l * GROUPS_PER_LAYER + g) as f64 * 2.0); + b.rect_node(Rect::new(x, y, x + 3.0, y + 1.5), Color::rgb(0.2, 0.4, 0.6)) + }) + .collect(); + b.group(kids, false); + } + } + let d: Document = b.build(); + let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&d, false)).unwrap(); + v["document"]["guides"] = + (0..GUIDES).map(|i| json!({"vertical": i % 2 == 0, "pos": (i / 2) as f64 * 0.37 + 0.1, "layer": layers[i % LAYERS]})).collect(); + serde_json::to_vec(&v).unwrap() +} + +/// The median of `n` runs of `f`. +fn median(n: usize, mut f: impl FnMut()) -> Duration { + let mut t: Vec = (0..n) + .map(|_| { + let start = Instant::now(); + f(); + start.elapsed() + }) + .collect(); + t.sort(); + t[n / 2] +} + +fn report(what: &str, d: Duration) { + eprintln!("ruler_guide_perf: {what:<44} {:>10.3} ms", d.as_secs_f64() * 1000.0); +} + +fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); +} + +#[test] +#[ignore = "a benchmark: run with --ignored --nocapture"] +fn ruler_guide_perf() { + let bytes = file(); + eprintln!("ruler_guide_perf: file of {} bytes", bytes.len()); + report("load (format::load), median of 5", median(5, || drop(vectorcraft_format::load(&bytes).unwrap()))); + let doc = vectorcraft_format::load(&bytes).unwrap(); + report("save (format::save), median of 5", median(5, || drop(vectorcraft_format::save(&doc, false)))); + + let mut s = Session::new(); + let data = vectorcraft_format::base64_encode(&bytes); + let start = Instant::now(); + s.execute("document.open", &json!({"name": "perf.vectorcraft", "dataBase64": data})).unwrap(); + report("document.open", start.elapsed()); + let listed = s.execute("guide.list", &json!({})).unwrap().as_array().map_or(0, Vec::len); + assert_eq!(listed, GUIDES); + report("guide.list, median of 11", median(11, || drop(s.execute("guide.list", &json!({})).unwrap()))); + + s.select_tool("selection", ViewInfo::default()).unwrap(); + let mut i = 0.0; + report( + "hover (Selection tool move), median of 201", + median(201, || { + i += 1.0; + pointer(&mut s, PointerKind::Move, 600.0 + i * 0.13, 300.0 + i * 0.07); + }), + ); + // A drag of the first rectangle's group (the rectangle is at 0,0 to 3,1.5), with its + // snapping: one undo step. + let steps = s.doc().unwrap().history.undo.len(); + pointer(&mut s, PointerKind::Down, 1.5, 0.75); + let mut j = 0.0; + report( + "drag (Selection tool drag step), median of 51", + median(51, || { + j += 1.0; + pointer(&mut s, PointerKind::Drag, 1.5 + j * 0.9, 0.75 + j * 0.4); + }), + ); + let start = Instant::now(); + pointer(&mut s, PointerKind::Up, 1.5 + j * 0.9, 0.75 + j * 0.4); + report("drag release (commit)", start.elapsed()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "the drag moved the group"); + + let mut k = 0.0; + report( + "edit without guides (shape.rectangle), median of 21", + median(21, || { + k += 1.0; + s.execute("shape.rectangle", &json!({"x": k, "y": 1900, "width": 5, "height": 5})).unwrap(); + }), + ); + report("undo, median of 11", median(11, || drop(s.execute("edit.undo", &json!({})).unwrap()))); + let mut m = 0.0; + report( + "guide edit (guide.move), median of 21", + median(21, || { + m += 1.0; + s.execute("guide.move", &json!({"index": GUIDES / 2, "pos": 100.0 + m})).unwrap(); + }), + ); + report("file.saveCopy, median of 3", median(3, || drop(s.execute("file.saveCopy", &json!({})).unwrap()))); +} diff --git a/crates/engine/tests/it/ruler_origin.rs b/crates/engine/tests/it/ruler_origin.rs index 4b5c06286..f6a910515 100644 --- a/crates/engine/tests/it/ruler_origin.rs +++ b/crates/engine/tests/it/ruler_origin.rs @@ -78,7 +78,7 @@ fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero assert_eq!(back.ruler_origin_for(0), doc.ruler_origin_for(0), "v{version}, compression {compress}"); assert_eq!((back.rulers, &back.artboard_ruler_origins), (doc.rulers, &doc.artboard_ruler_origins), "v{version}, compression {compress}"); assert_eq!(back.layers, doc.layers); - assert_eq!(back.guides, doc.guides); + assert_eq!(back.ruler_guides(), doc.ruler_guides()); } } diff --git a/crates/engine/tests/plugin_call.rs b/crates/engine/tests/plugin_call.rs new file mode 100644 index 000000000..71f2619a3 --- /dev/null +++ b/crates/engine/tests/plugin_call.rs @@ -0,0 +1,239 @@ +//! A filter plug-in whose manifest's `commandAllowlist` names commands calls them with `vc_call`: +//! each call sees what the calls before it left, the calls and the filter's output are one undo +//! step, and a run that fails leaves the document as it was. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +/// The command imports and the strings the plug-ins pass: the ids "select.all" (0x2000, 10 bytes), +/// "object.move" (0x2010, 11), "no.such.cmd" (0x2020, 11), "effect.apply" (0x2030, 12) and +/// "select.globalEditOptions" (0x2040, 24), and the arguments `{"dx":5,"dy":0}` (0x2100, 15), +/// `{"dx":"x"}` (0x2200, 10), `{"effect":"plugin.test.call.fx"}` (0x2300, 32) and `{"size":true}` +/// (0x2400, 13). +const CALLS: &str = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_call_result" (func $vc_call_result (param i32 i32) (result i32))) + (data (i32.const 0x2000) "select.all") + (data (i32.const 0x2010) "object.move") + (data (i32.const 0x2020) "no.such.cmd") + (data (i32.const 0x2030) "effect.apply") + (data (i32.const 0x2040) "select.globalEditOptions") + (data (i32.const 0x2100) "{\22dx\22:5,\22dy\22:0}") + (data (i32.const 0x2200) "{\22dx\22:\22x\22}") + (data (i32.const 0x2300) "{\22effect\22:\22plugin.test.call.fx\22}") + (data (i32.const 0x2400) "{\22size\22:true}")"#; + +/// Selects everything. +const SELECT_ALL: &str = "(drop (call $vc_call (i32.const 0x2000) (i32.const 10) (i32.const 0) (i32.const 0)))"; +/// Moves the selection 5 to the right. +const MOVE: &str = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2100) (i32.const 15)))"; + +const EMPTY: &str = r#"{"objects":[]}"#; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// Installs filter `id` with `permissions` (a JSON list) and `allowlist` (a JSON list), whose +/// `vc_run` runs `first`, then returns `output` → what `plugin.install` returned, or why it refused. +fn install(s: &mut Session, id: &str, permissions: &str, allowlist: &str, first: &str, output: &str) -> Result { + let manifest = + format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{permissions},"commandAllowlist":{allowlist}}}"#); + let wasm = wat::parse_str(tpl::with_imports(&manifest, CALLS, first, output)).unwrap(); + let grant: Value = serde_json::from_str(permissions).unwrap(); + let data = vectorcraft_format::base64_encode(&wasm); + s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant, "replace": true})).map_err(|e| e.to_string()) +} + +/// A filter `id` that may read and change the document and call `select.all` and `object.move`. +fn install_mover(s: &mut Session, id: &str, first: &str, output: &str) { + install(s, id, r#"["doc.read","doc.write","commands"]"#, r#"["select.all","object.move"]"#, first, output).unwrap(); +} + +/// A document with two rectangles and nothing selected. +fn document() -> Session { + let mut s = Session::new(); + s.allow_plugin_grants_from_requests(); + run(&mut s, "file.new", json!({"width": 400, "height": 200})); + run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})); + run(&mut s, "shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})); + run(&mut s, "select.none", json!({})); + s +} + +/// The active document's layers, its undo and redo steps and how many objects are selected. +fn state(s: &mut Session) -> (Value, usize, usize, usize) { + let content = run(s, "document.inspect", json!({}))["layers"].take(); + let d = s.doc().unwrap(); + (content, d.history.undo.len(), d.history.redo.len(), d.selection.len()) +} + +/// Runs filter `id` on nothing, so only its calls change the document. +fn run_plugin(s: &mut Session, id: &str) -> Result { + s.execute("plugin.run", &json!({"id": id, "ids": []})).map_err(|e| e.to_string()) +} + +#[test] +fn calls_see_each_others_changes_and_make_one_undo_step() { + let mut s = document(); + let before = state(&mut s); + install_mover(&mut s, "test.call.mover", &[SELECT_ALL, MOVE].concat(), EMPTY); + run_plugin(&mut s, "test.call.mover").unwrap(); + let after = state(&mut s); + // `object.move` moved what `select.all` selected: both rectangles. + let xs: Vec<&Value> = after.0[0]["children"].as_array().unwrap().iter().map(|c| &c["bounds"]["x"]).collect(); + assert_eq!(xs, [&json!(105.0), &json!(15.0)]); + assert_eq!(after.1, before.1 + 1, "one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|u| u.label.as_str()), Some("Test test.call.mover")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(state(&mut s).0, before.0); + run(&mut s, "edit.redo", json!({})); + assert_eq!(state(&mut s).0, after.0); + // Without `select.all` nothing is selected, so `object.move` has nothing to move. + run(&mut s, "select.none", json!({})); + install_mover(&mut s, "test.call.mover", MOVE, EMPTY); + assert!(run_plugin(&mut s, "test.call.mover").is_err()); + run(&mut s, "plugin.remove", json!({"id": "test.call.mover"})); +} + +#[test] +fn a_run_that_fails_after_its_calls_changes_nothing() { + let mut s = document(); + let before = state(&mut s); + // A trap, an output that isn't JSON, and a command that fails, each after the move. + let bad_move = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2200) (i32.const 10)))"; + for (first, output) in + [([SELECT_ALL, MOVE, "unreachable"].concat(), EMPTY), ([SELECT_ALL, MOVE].concat(), "nope"), ([SELECT_ALL, MOVE, bad_move].concat(), EMPTY)] + { + install_mover(&mut s, "test.call.fails", &first, output); + assert!(run_plugin(&mut s, "test.call.fails").is_err(), "{first} {output}"); + assert_eq!(state(&mut s), before, "{first} {output}"); + } + run(&mut s, "plugin.remove", json!({"id": "test.call.fails"})); + // Session state a call changed outside the document goes back too: Global Edit's options. + let options = "(drop (call $vc_call (i32.const 0x2040) (i32.const 24) (i32.const 0x2400) (i32.const 13))) unreachable"; + let allowlist = r#"["select.globalEditOptions"]"#; + install(&mut s, "test.call.options", r#"["doc.read","commands"]"#, allowlist, options, EMPTY).unwrap(); + assert!(run_plugin(&mut s, "test.call.options").is_err()); + assert_eq!(run(&mut s, "select.globalEditOptions", json!({}))["size"], json!(false)); + run(&mut s, "plugin.remove", json!({"id": "test.call.options"})); +} + +/// A command that changes state a failed run can't put back (the preferences, a tool's options, a +/// drag's state) is refused at install, by name. +#[test] +fn install_refuses_reviewed_denials() { + let mut s = document(); + for refused in ["object.liquify", "object.pathfinder.options", "addon.pen.nextSegment"] { + let allowlist = format!(r#"["select.all","{refused}"]"#); + let e = install(&mut s, "test.call.reviewed", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err(); + assert!(e.contains(refused) && e.contains("may not call"), "{e}"); + } +} + +#[test] +fn install_refuses_a_command_a_plugin_may_not_call_and_reports_unknown_ones() { + let mut s = document(); + let first = "(drop (call $vc_call (i32.const 0x2020) (i32.const 11) (i32.const 0) (i32.const 0)))"; + for refused in ["document.save", "edit.undo", "plugin.run", "script.run"] { + let allowlist = format!(r#"["select.all","{refused}"]"#); + let e = install(&mut s, "test.call.refused", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err(); + assert!(e.contains(refused) && e.contains("may not call"), "{e}"); + } + let r = install(&mut s, "test.call.unknown", r#"["doc.read","commands"]"#, r#"["select.all","no.such.cmd"]"#, first, EMPTY).unwrap(); + assert_eq!(r["unknownCommands"], json!(["no.such.cmd"])); + let e = run_plugin(&mut s, "test.call.unknown").unwrap_err(); + assert!(e.contains("no.such.cmd"), "{e}"); + run(&mut s, "plugin.remove", json!({"id": "test.call.unknown"})); +} + +#[test] +fn a_call_that_names_a_plugin_is_refused() { + let mut s = document(); + let before = state(&mut s); + let first = format!("{SELECT_ALL} (drop (call $vc_call (i32.const 0x2030) (i32.const 12) (i32.const 0x2300) (i32.const 32)))"); + install(&mut s, "test.call.fx", r#"["doc.read","doc.write","commands"]"#, r#"["select.all","effect.apply"]"#, &first, EMPTY).unwrap(); + let e = run_plugin(&mut s, "test.call.fx").unwrap_err(); + assert!(e.contains("may not call"), "{e}"); + assert_eq!(state(&mut s), before); + run(&mut s, "plugin.remove", json!({"id": "test.call.fx"})); +} + +#[test] +fn without_doc_write_a_call_that_edits_is_refused() { + let mut s = document(); + let before = state(&mut s); + let allowlist = r#"["select.all","object.move"]"#; + install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, &[SELECT_ALL, MOVE].concat(), EMPTY).unwrap(); + let e = run_plugin(&mut s, "test.call.reader").unwrap_err(); + assert!(e.contains("may not call"), "{e}"); + assert_eq!(state(&mut s), before); + // Selecting changes no art, so it runs. + install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, SELECT_ALL, EMPTY).unwrap(); + run_plugin(&mut s, "test.call.reader").unwrap(); + run(&mut s, "plugin.remove", json!({"id": "test.call.reader"})); +} + +/// Installs filter `id` that may read and change the document and call `allowlist`, whose `vc_run` +/// makes `calls` (command id and arguments) in order, dropping their codes, then returns nothing. +fn install_calls(s: &mut Session, id: &str, allowlist: &str, calls: &[(&str, &str)]) { + let mut imports = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32)))"#.to_string(); + let mut first = String::new(); + for (i, (cmd, args)) in calls.iter().enumerate() { + let at = 0x3000 + i * 0x400; + imports += &format!(r#" (data (i32.const {at}) "{}") (data (i32.const {}) "{}")"#, tpl::string(cmd), at + 0x100, tpl::string(args)); + first += &format!("(drop (call $vc_call (i32.const {at}) (i32.const {}) (i32.const {}) (i32.const {})))", cmd.len(), at + 0x100, args.len()); + } + let manifest = format!( + r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","commands"],"commandAllowlist":{allowlist}}}"# + ); + let wasm = wat::parse_str(tpl::with_imports(&manifest, &imports, &first, EMPTY)).unwrap(); + let data = vectorcraft_format::base64_encode(&wasm); + let grant = json!(["doc.read", "doc.write", "commands"]); + run(s, "plugin.install", json!({"dataBase64": data, "grant": grant, "replace": true})); +} + +/// Arguments that carry a file or folder key at any depth, such as an effect's +/// `textureImage.path`, are refused before the command runs, so no file is read; arguments nested +/// without one run. +#[test] +fn a_call_whose_arguments_name_a_file_is_refused() { + let mut s = document(); + let before = state(&mut s); + let allowlist = r#"["select.all","effect.apply","effect.setParams"]"#; + let select = ("select.all", ""); + for (cmd, args) in [ + ("effect.apply", r#"{"effect":"distort.glass","params":{"textureImage":{"path":"missing-texture.png"}}}"#), + ("effect.setParams", r#"{"index":0,"params":{"textureImage":{"path":"missing-texture.png"}}}"#), + ("effect.apply", r#"{"effect":"distort.glass","params":{"layers":[{"name":"a"},{"file":"missing-texture.png"}]}}"#), + ] { + install_calls(&mut s, "test.call.files", allowlist, &[select, (cmd, args)]); + let e = run_plugin(&mut s, "test.call.files").unwrap_err(); + assert!(e.contains("may not call") && e.contains(cmd), "{args}: {e}"); + assert_eq!(state(&mut s), before, "{args}"); + } + // The same effect with ordinary nested params runs. + install_calls(&mut s, "test.call.files", allowlist, &[select, ("effect.apply", r#"{"effect":"distort.glass","params":{"distortion":5}}"#)]); + run_plugin(&mut s, "test.call.files").unwrap(); + assert_eq!(state(&mut s).1, before.1 + 1, "one undo step"); + run(&mut s, "plugin.remove", json!({"id": "test.call.files"})); +} + +/// A command's result describes the document, so a plug-in without `doc.read` may call nothing. +#[test] +fn without_doc_read_every_call_is_refused() { + let mut s = document(); + let before = state(&mut s); + install(&mut s, "test.call.blind", r#"["commands"]"#, r#"["select.all"]"#, SELECT_ALL, EMPTY).unwrap(); + let e = run_plugin(&mut s, "test.call.blind").unwrap_err(); + assert!(e.contains("may not call") && e.contains("select.all"), "{e}"); + assert_eq!(state(&mut s), before); + // The same for an id no command has: without `doc.read` the answer never depends on the command. + let unknown = "(drop (call $vc_call (i32.const 0x2020) (i32.const 11) (i32.const 0) (i32.const 0)))"; + install(&mut s, "test.call.blind", r#"["commands"]"#, r#"["no.such.cmd"]"#, unknown, EMPTY).unwrap(); + let e = run_plugin(&mut s, "test.call.blind").unwrap_err(); + assert!(e.contains("may not call") && e.contains("no.such.cmd"), "{e}"); + run(&mut s, "plugin.remove", json!({"id": "test.call.blind"})); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 0bc8615d6..58cc30227 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -15,7 +15,7 @@ use proptest::prelude::*; use serde_json::{Map, Value, json}; use vectorcraft_engine::Session; use vectorcraft_plugins::wat as tpl; -use vectorcraft_plugins::{Error, Kind, Limits, Plugin}; +use vectorcraft_plugins::{BYTES_COPIED_PER_FUEL, Error, FUEL_SLICE, Kind, Limits, Plugin}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::rich_session; @@ -187,9 +187,6 @@ fn arb_paint() -> impl Strategy { /// Bytes in the passive data segment that bulk memory cases initialize memory from. const SEGMENT_LEN: u32 = 4096; -/// Fuel the host hands a run per slice (`FUEL_SLICE` in `vectorcraft_plugins`' runtime). -const FUEL_SLICE: u64 = 20_000_000; - /// Where a bulk memory plug-in keeps its output (the manifest sits at 16). const BULK_OUTPUT_AT: u32 = 0x8000; @@ -319,10 +316,10 @@ impl BulkCase { } } - /// A generous estimate of the fuel the instructions use: a unit per 64 bytes moved, and a few - /// per pass. + /// A generous estimate of the fuel the instructions use: a unit per [`BYTES_COPIED_PER_FUEL`] + /// bytes moved, and a few per pass. fn fuel(self) -> u64 { - u64::from(self.runs()) * (u64::from(self.len) / 64 + 16) + u64::from(self.runs()) * (u64::from(self.len) / u64::from(BYTES_COPIED_PER_FUEL) + 16) } /// The plug-in module in WAT, with `manifest`. @@ -352,11 +349,12 @@ impl BulkCase { } } -/// Lengths: zero, a byte, around the 64 bytes a unit of fuel pays for, the data segment's length, -/// a page, a mebibyte, or anything up to four pages. +/// Lengths: zero, a byte, around the bytes a unit of fuel pays for, the data segment's length, a +/// page, a mebibyte, or anything up to four pages. fn arb_bulk_len() -> impl Strategy { + let per = BYTES_COPIED_PER_FUEL; prop_oneof![ - 3 => prop::sample::select(vec![0u32, 1, 63, 64, 65, SEGMENT_LEN - 1, SEGMENT_LEN, SEGMENT_LEN + 1, 0xffff, 0x1_0000, 0x1_0001, 1 << 20]), + 3 => prop::sample::select(vec![0u32, 1, per - 1, per, per + 1, SEGMENT_LEN - 1, SEGMENT_LEN, SEGMENT_LEN + 1, 0xffff, 0x1_0000, 0x1_0001, 1 << 20]), 1 => 0u32..0x4_0000, ] } @@ -394,7 +392,7 @@ fn arb_bulk_case() -> impl Strategy { let repeat = match repeat { Repeat::Times(n) => n, Repeat::AroundSlice(k) => { - let slice = (FUEL_SLICE * 64 / u64::from(len.max(1))) as i64; + let slice = (FUEL_SLICE * u64::from(BYTES_COPIED_PER_FUEL) / u64::from(len.max(1))) as i64; (slice + k).clamp(1, 2_000) as u32 } }; diff --git a/crates/engine/tests/plugin_safe_mode.rs b/crates/engine/tests/plugin_safe_mode.rs index 878ec6bf5..2c5fc1c30 100644 --- a/crates/engine/tests/plugin_safe_mode.rs +++ b/crates/engine/tests/plugin_safe_mode.rs @@ -462,3 +462,43 @@ fn expand_appearance_keeps_a_plug_in_effect_safe_mode_skips() { safe_mode::set_active(Some(safe_mode::Source::Flag)); s.execute("effect.expandAppearance", &json!({})).unwrap(); } + +/// In Safe Mode, a plug-in effect on a dimension selected with the art it measures does not stop +/// Expand Appearance: the dimension is left as it is, its effect included, and the art expands. +#[test] +fn expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art() { + let _f = fresh(); + let mut s = Session::new(); + let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.dim", "effect", "{}"), r#"{"objects":[]}"#)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let art = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 72, "height": 72})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [art], "color": "#000000"})).unwrap(); + s.execute("appearance.addStroke", &json!({})).unwrap(); + let source = json!({"kind": "segment", "edge": {"node": art, "subpath": 0, "segment": 0}}); + let dim = s.execute("dimension.create", &json!({"source": source})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [dim]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "plugin.test.sm.dim"})).unwrap(); + s.execute("select.set", &json!({"ids": [art, dim]})).unwrap(); + let node = |s: &mut Session, id: &Value| { + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + let mut found = Value::Null; + let mut stack = vec![inspect["layers"].clone()]; + while let Some(v) = stack.pop() { + match v { + Value::Array(a) => stack.extend(a), + Value::Object(o) if o.get("id") == Some(id) => found = Value::Object(o), + Value::Object(o) => stack.extend(o.into_iter().map(|(_, v)| v)), + _ => {} + } + } + found + }; + let kept = node(&mut s, &dim); + let measured = node(&mut s, &art); + safe_mode::set_active(Some(safe_mode::Source::Flag)); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + safe_mode::set_active(None); + assert_ne!(node(&mut s, &art), measured, "the art expands"); + assert_eq!(node(&mut s, &dim), kept, "the dimension and its effect stay as they are"); +} diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 78058a7f9..67ca443a7 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -525,6 +525,51 @@ fn many_objects_of_one_name_are_numbered_in_turn() { assert!(text.contains("(Dot_00000000000000019999_)")); } +/// A document with one top-level layer for each of the 27 layer colour presets, named after its +/// position. +fn every_layer_preset() -> Document { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + for i in 0..vectorcraft_doc::LAYER_COLORS.len() { + let id = doc.alloc_id(); + doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), LayerColor::Preset(u8::try_from(i).unwrap())))); + } + doc +} + +/// #6502: Yellow, Magenta and Cyan (presets 4, 5 and 6) are written as the file's indexes 6, 4 +/// and 5, with their own RGB beside them; every other preset keeps its position. +#[test] +fn layer_presets_are_written_with_the_files_colour_index() { + let text = program(&every_layer_preset()); + for (i, (_, [r, g, b])) in vectorcraft_doc::LAYER_COLORS.iter().enumerate() { + let index = match i { + 4 => 6, + 5 => 4, + 6 => 5, + i => i, + }; + let want = format!(" {index} {r} {g} {b} 0 50 0 Lb\n(P{i}) Ln\n"); + assert!(text.contains(&want), "missing {want:?}"); + } +} + +/// #6502: every preset comes back as itself after a save and an open. +#[test] +fn layer_presets_survive_a_round_trip() { + let doc = read_back(&every_layer_preset()); + let colors: Vec = doc + .layers + .iter() + .map(|l| match l.kind { + NodeKind::Layer { color, .. } => color, + _ => panic!("{:?}", l.kind), + }) + .collect(); + let want: Vec = (0..27u8).map(LayerColor::Preset).collect(); + assert_eq!(colors, want); +} + #[test] fn layers_have_fourteen_operands() { let text = program(&sample()); @@ -3449,6 +3494,9 @@ fn typed() -> Document { v_scale: 90.0, stroke: solid(Color::rgb(0.0, 0.5, 0.0)), stroke_width: 0.5, + stroke_cap: vectorcraft_doc::LineCap::Square, + stroke_join: vectorcraft_doc::LineJoin::Bevel, + stroke_miter_limit: 2.5, all_caps: true, ..regular.clone() }; @@ -3565,7 +3613,7 @@ fn type_lines(doc: &Document) -> Vec { for r in &t.runs { let s = &r.style; out.push(format!( - " run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={} roman={}", + " run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} {:?} {:?} {} caps={} small={:?} tcy={} roman={}", r.text, s.font_family, s.font_style, @@ -3578,6 +3626,9 @@ fn type_lines(doc: &Document) -> Vec { paint(&s.fill), paint(&s.stroke), r3(s.stroke_width), + s.stroke_cap, + s.stroke_join, + r3(s.stroke_miter_limit), s.all_caps, s.small_caps.map(r3), s.tate_chu_yoko, @@ -3718,6 +3769,22 @@ fn type_comes_back_as_type() { assert_eq!(back.text_threads.len(), 1, "the thread comes back"); } +/// A run without a stroke whose stroke's cap, join and miter limit aren't the defaults loses +/// nothing that draws: the save reports no attributes left out. +#[test] +fn an_unstroked_run_with_a_stroke_style_loses_nothing() { + use vectorcraft_doc::{CharStyle, LineCap, LineJoin, TextObject}; + for miter in [3.0, f64::NAN] { + let st = CharStyle { stroke_cap: LineCap::Round, stroke_join: LineJoin::Bevel, stroke_miter_limit: miter, ..style(14.0, Color::BLACK) }; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Plain", st)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{miter}: {:?}", out.warnings); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); @@ -4271,6 +4338,36 @@ fn scaled_area_type_and_type_on_a_path_stay_type() { assert!(!program(&doc).contains("/AI11Text")); } +/// Type on a path with Dynamic Text is written as type at the sizes it is drawn at, with a warning +/// that the setting is not written. +#[test] +fn dynamic_text_is_written_at_its_drawn_sizes() { + use vectorcraft_doc::{TextKind, TextObject}; + let mut doc = Document::new(600.0, 300.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK)); + t.kind = TextKind::OnPath { + path: shapes::line(vectorcraft_geom::Point::new(50.0, 200.0), vectorcraft_geom::Point::new(550.0, 200.0)), + start: 0.0, + end: None, + }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let drawn = 12.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).fit_scale; + assert!(drawn > 50.0, "{drawn}"); + let node = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings); + let back = read_back(&doc); + let texts: Vec<&TextObject> = + back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect(); + assert_eq!(texts.len(), 1); + assert!(!texts[0].dynamic_text); + let k = texts[0].xf.as_coeffs()[0]; + assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn)); +} + /// See-through type with the knockout shape flag comes back with the flag and writes no warning. #[test] fn the_knockout_shape_of_type_comes_back() { @@ -7377,3 +7474,65 @@ fn bindings_and_values_the_reader_cant_take_are_left_out() { assert_eq!(back.variables.datasets[1].values.get("picture"), None); assert_eq!(bound_objects(&back).len(), 4); } + +/// Type in a variable font's named instance names the instance's place in the font's design space +/// (`/4`, each axis as 16.16 fixed) and the font's version (`/5`) in its font record, so the app that opens the file doesn't take +/// the font's default instance (#6388); a run's own Variable Font setting is a font of its own, and +/// a static font's record has no design vector. Each comes back as the style it was set in. +#[test] +fn variable_font_instances_name_their_design_space() { + use vectorcraft_doc::{CharStyle, TextObject}; + use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, variable_font}; + vectorcraft_text::FontDb::global().add_font(variable_font().unwrap()); + let set = |doc: &mut Document, y: f64, style: &str, axes: &[(&str, f64)]| { + let st = CharStyle { + font_family: VARIABLE_FAMILY.into(), + font_style: style.into(), + font_axes: axes.iter().map(|(t, v)| (t.to_string(), *v)).collect(), + ..style_of(12.0) + }; + let n = text_node(doc, TextObject::point(vectorcraft_geom::Point::new(10.0, y), style, st)); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + }; + let mut doc = Document::new(300.0, 200.0); + set(&mut doc, 20.0, "Regular", &[]); + set(&mut doc, 40.0, "Bold", &[]); + set(&mut doc, 60.0, "Bold", &[("wght", 650.0)]); + let static_text = TextObject::point(vectorcraft_geom::Point::new(10.0, 80.0), "Static", style_of(12.0)); + let n = text_node(&mut doc, static_text); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + + let td = text_document(&program(&doc)); + // 400 × 65536, 700 × 65536 and 650 × 65536. + assert!(td.contains("VaritestSans-Regular) /2 1 /4 [ 26214400 ]"), "{td}"); + assert!(td.contains("VaritestSans-Bold) /2 1 /4 [ 45875200 ]"), "{td}"); + assert!(td.contains("VaritestSans-Bold) /2 1 /4 [ 42598400 ]"), "a setting of its own is a font of its own: {td}"); + let static_name = vectorcraft_text::FontDb::global().face(&style_of(12.0).font_family, "Regular").unwrap().postscript_name(); + assert!(td.contains(&format!("{static_name}) /2 1 >>")), "a static font has no design vector: {td}"); + // Each variable font's record names the font's version (name ID 5) after its design vector. + // (The string is UTF-16 after a byte order mark, so the match starts after it.) + assert_eq!(td.matches("] /5 (").count(), 3, "{td}"); + assert_eq!(td.matches("Version 1.000) >>").count(), 3, "{td}"); + assert_eq!(td.matches("/CoolTypeFont").count(), 5, "four fonts and the hidden characters' font"); + + let back = read_back(&doc); + let styles: Vec<(String, String)> = back.layers[0] + .children() + .unwrap() + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((t.runs[0].style.font_family.clone(), t.runs[0].style.font_style.clone())), + _ => None, + }) + .collect(); + assert_eq!( + styles[..3], + [(VARIABLE_FAMILY.into(), "Regular".into()), (VARIABLE_FAMILY.into(), "Bold".into()), (VARIABLE_FAMILY.into(), "Bold".into())] + ); +} + +fn style_of(size: f64) -> vectorcraft_doc::CharStyle { + style(size, Color::BLACK) +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 0c3587e58..afa641256 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath}; use vectorcraft_pathops::BoolOp; @@ -64,6 +64,7 @@ pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; pub(super) const OBJECT_LIMIT: &str = "art past 4,000,000 objects in all is left out of the editing data for other apps"; const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; +pub(super) const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at in the editing data for other apps"; /// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]), /// the opacity, Isolate Blending, Knockout Group (0 off, 1 on, 2 neutral) and Opacity & Mask Define @@ -449,14 +450,7 @@ impl<'a> Writer<'a> { /// (`Xy`, `0 0 Xd`, `7 () XW`) when it has any, `LB`, `%AI5_EndLayer--`. fn write_layer(&mut self, n: &Node, after: &[Arc]) { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); if *template { self.warn(TEMPLATES); } @@ -467,6 +461,9 @@ impl<'a> Writer<'a> { let flag = |b: bool| u8::from(b); let dim = dim_images.unwrap_or(50); let name = n.name.as_deref().unwrap_or("Layer"); + // Ruler guides are kept in the document's native copy only. + let children: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + let children = &children; // The last flag makes the layer a clipping layer: its top object clips the rest. Only a // clipping path or compound path, outside any art cut to a clipping object, is written as // one ([`Self::clip_group`] writes the others). @@ -590,6 +587,10 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + // Ruler guides are kept in the document's native copy only: no object, state or style. + if n.is_ruler_guide() { + return; + } if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } @@ -704,6 +705,8 @@ impl<'a> Writer<'a> { } self.end_group("U", xy); } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > start { // A group's dictionary goes between its end and its state block. @@ -1201,6 +1204,9 @@ impl<'a> Writer<'a> { /// names the story of its thread, which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; + // Dynamic Text is written as the sizes its layout draws the type at. + let fitted = t.dynamic_text.then(|| vectorcraft_text::drawn(t)); + let t = fitted.as_deref().unwrap_or(&**t); let doc = self.doc; let thread = doc.text_threads.iter().find(|th| th.contains(&n.id)); // Type that can't be a stub is written as its outlines, which count their own objects. @@ -1229,6 +1235,9 @@ impl<'a> Writer<'a> { if lost { self.warn(texts::TYPE_ATTRIBUTES); } + if fitted.is_some() { + self.warn(DYNAMIC_TEXT); + } // The stub takes the transparency set before it, as a path does. Its isolation and knockout // are read only from a state block after it, as a group's are. self.set_object_xy(xy.object()); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 65fe91859..c4ae9fda3 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -28,6 +28,10 @@ pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are /// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { + // Ruler guides are not written as objects. + if n.is_ruler_guide() { + return 0; + } let own = usize::from(n.extras().graphic_style.is_some()); let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index a84fc5720..5ebd73de3 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -21,15 +21,15 @@ use std::fmt::Write; use kurbo::{ParamCurveArclen, PathSeg}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, - PathEffect, TextKind, TextObject, VerticalAlign, + AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection, + ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign, }; use vectorcraft_geom::{Affine, PathData, Point}; use crate::ps; /// What the text document holds of type, and what it leaves out. -pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and stroke weight, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; +pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; /// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it. const STREAM_VERSION: u32 = 14; @@ -347,6 +347,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { fill: unlinked(&s.fill), stroke: unlinked(&s.stroke), stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, + stroke_cap: if stroked { s.stroke_cap } else { CharStyle::default().stroke_cap }, + stroke_join: if stroked { s.stroke_join } else { CharStyle::default().stroke_join }, + stroke_miter_limit: if stroked { miter_limit(s.stroke_miter_limit) } else { CharStyle::default().stroke_miter_limit }, all_caps: s.all_caps, small_caps: s.small_caps.filter(|v| *v == default_small_caps), tate_chu_yoko: s.tate_chu_yoko, @@ -357,7 +360,19 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { }; let run_lost = |s: &CharStyle| { let stroked = !s.stroke.is_none(); - let s = CharStyle { stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, ..s.clone() }; + // Without a stroke, its weight, cap, join and miter limit draw nothing: nothing is lost. + let unstroked = CharStyle::default(); + let s = if stroked { + s.clone() + } else { + CharStyle { + stroke_width: unstroked.stroke_width, + stroke_cap: unstroked.stroke_cap, + stroke_join: unstroked.stroke_join, + stroke_miter_limit: unstroked.stroke_miter_limit, + ..s.clone() + } + }; // A Lab color is written as RGB. let lab = |p: &Paint| matches!(p, Paint::Solid { color: Color::Lab { .. }, .. }); s != held_char(&s) || lab(&s.fill) || lab(&s.stroke) || !font_kept(&s) @@ -417,26 +432,87 @@ fn baseline_direction(st: &CharStyle) -> i64 { } } -/// The name the font of style `st` is written under, and whether it's TrueType: an installed -/// face's PostScript name; else its family and style as `family-style` (the family alone for -/// Regular), the form the reader's `family_style` splits. -fn font_name(st: &CharStyle) -> (String, bool) { +/// A font of the font set, as its `CoolTypeFont` record names it. +#[derive(Clone, Debug, PartialEq)] +struct FontRecord { + /// The PostScript name (`/0`). + name: String, + /// Whether its outlines are TrueType (`/2`). + truetype: bool, + /// For a variable font, where in its design space the type is set (`/4`): each `fvar` axis in + /// the font's order, in the axis's own units. Empty for a static font, which has none: without + /// it, an app that opens the file takes a variable font's default instance (#6388). + design: Vec, + /// For a variable font, its version string (`/5`, name ID 5); empty for a static font. + version: String, +} + +/// The font the font set names for style `st`: an installed face's PostScript name, and for a +/// variable font the setting of each of its axes (the named instance's, or the style's own +/// Variable Font setting, clamped to the axis as layout clamps it) and its version; else its family +/// and style as `family-style` (the family alone for Regular), the form the reader's +/// `family_style` splits. +fn font_name(st: &CharStyle) -> FontRecord { let db = vectorcraft_text::FontDb::global(); // The face of that family and style (the font database gives a fallback face for others). + let record = |name: String| FontRecord { name, truetype: false, design: vec![], version: String::new() }; match db .face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) .filter(|f| f.family == st.font_family && f.style == st.font_style) { - Some(face) => (face.postscript_name(), face.is_truetype()), - None if st.font_style.is_empty() || st.font_style == "Regular" => (st.font_family.clone(), false), - None => (format!("{}-{}", st.font_family, st.font_style.replace(' ', "")), false), + Some(face) => { + let axes = face.axes(); + let design: Vec = axes + .iter() + .map(|a| { + let own = face.axis_value(a.tag).unwrap_or(a.default); + let set = st.font_axes.get(String::from_utf8_lossy(&a.tag).as_ref()).copied().filter(|v| v.is_finite()); + set.map_or(own, |v| (v as f32).clamp(a.min.min(a.max), a.max.max(a.min))) + }) + .collect(); + let version = if design.is_empty() { String::new() } else { face.version.clone() }; + FontRecord { name: face.postscript_name(), truetype: face.is_truetype(), design, version } + } + None if st.font_style.is_empty() || st.font_style == "Regular" => record(st.font_family.clone()), + None => record(format!("{}-{}", st.font_family, st.font_style.replace(' ', ""))), } } +/// The text document's number for a stroke's cap (key 61), as PostScript numbers them: 0 butt, 1 +/// round, 2 projecting. +fn cap_code(cap: LineCap) -> i64 { + match cap { + LineCap::Butt => 0, + LineCap::Round => 1, + LineCap::Square => 2, + } +} + +/// The text document's number for a stroke's join (key 62), as PostScript numbers them: 0 miter, 1 +/// round, 2 bevel. +fn join_code(join: LineJoin) -> i64 { + match join { + LineJoin::Miter => 0, + LineJoin::Round => 1, + LineJoin::Bevel => 2, + } +} + +/// A stroke's miter limit (key 64) as the reader takes it back: at least 1 and at most 500, 10 when +/// it isn't a number at least 1. +fn miter_limit(v: f64) -> f64 { + if v.is_finite() && v >= 1.0 { v.min(500.0) } else { 10.0 } +} + +/// `v` as the 16.16 fixed-point number a design vector holds (`wght` 700 is 45875200). +fn fixed(v: f32) -> i64 { + if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 } +} + /// Does the font of style `st` come back as itself: the family and style the reader makes of the /// name it is written under (as its `char_style` does)? fn font_kept(st: &CharStyle) -> bool { - let (family, style) = crate::family_style(&font_name(st).0); + let (family, style) = crate::family_style(&font_name(st).name); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); family == st.font_family && style == st.font_style } @@ -507,8 +583,8 @@ fn place_on(segments: &[[f64; 8]], f: f64) -> f64 { /// The text document being written. #[derive(Default)] pub(in crate::ai_editing) struct TextDoc { - /// Each font's PostScript name and whether it's TrueType. - fonts: Vec<(String, bool)>, + /// The fonts the runs name, each once: two settings of one variable font are two fonts. + fonts: Vec, frames: Vec, stories: Vec, /// The story and frame each text object of a thread is written as. @@ -533,11 +609,15 @@ impl TextDoc { /// The index of the font of `st` in the font set. fn font(&mut self, st: &CharStyle) -> i64 { - let (name, truetype) = font_name(st); - let i = match self.fonts.iter().position(|(n, _)| *n == name) { + let font = font_name(st); + // Two installed versions of one font are two fonts, so each keeps its own `/5`. + let same = |f: &FontRecord| { + f.name == font.name && f.version == font.version && f.design.iter().map(|v| fixed(*v)).eq(font.design.iter().map(|v| fixed(*v))) + }; + let i = match self.fonts.iter().position(same) { Some(i) => i, None => { - self.fonts.push((name, truetype)); + self.fonts.push(font); self.fonts.len() - 1 } }; @@ -582,7 +662,12 @@ impl TextDoc { } d.extend([(56, V::Bool(fill.is_some())), (57, V::Bool(stroke.is_some()))]); if stroke.is_some() { - d.push((63, V::Small(st.stroke_width / scale))); + d.extend([ + (61, V::Int(cap_code(st.stroke_cap))), + (62, V::Int(join_code(st.stroke_join))), + (63, V::Small(st.stroke_width / scale)), + (64, real(miter_limit(st.stroke_miter_limit))), + ]); } dict(d) } @@ -806,7 +891,9 @@ impl TextDoc { let total = bytes.saturating_add(added); let total_values = self.doc_values.saturating_add(values); // The font set: each name in UTF-16 with its dictionary round it. - let fonts: usize = self.fonts.iter().map(|(n, _)| n.encode_utf16().count() * 2 + 64).sum(); + // A design vector's numbers and a version string add to it. + let fonts: usize = + self.fonts.iter().map(|f| f.name.encode_utf16().count() * 2 + 64 + f.design.len() * 12 + f.version.encode_utf16().count() * 2).sum(); // ASCII85 writes five characters for every four bytes. if total.saturating_add(RESERVE).saturating_add(fonts).saturating_mul(5) / 4 <= crate::import::MAX_TEXT_DOCUMENT / 4 && total_values.saturating_add(RESERVE_VALUES) <= crate::import::MAX_TEXT_VALUES @@ -924,8 +1011,17 @@ impl TextDoc { let mut fonts: Vec = self .fonts .iter() - .map(|(name, truetype)| { - dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(vec![(0, V::Str(name.clone())), (2, V::Int(i64::from(*truetype)))]))]) + .map(|f| { + let mut font = vec![(0, V::Str(f.name.clone())), (2, V::Int(i64::from(f.truetype)))]; + // A variable font's place in its design space and its version (#6388); a static + // font has neither. + if !f.design.is_empty() { + font.push((4, V::List(f.design.iter().map(|v| V::Int(fixed(*v))).collect()))); + if !f.version.is_empty() { + font.push((5, V::Str(f.version.clone()))); + } + } + dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(font))]) }) .collect(); fonts.push(dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(vec![(0, V::Str(HIDDEN_GLYPH_FONT.into())), (2, V::Int(0))]))])); diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index a22c94de5..7707f4bc4 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -6,7 +6,7 @@ use std::fmt::Write; use vectorcraft_color::{Color, Gradient}; -use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer}; +use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer}; use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; use crate::ps; @@ -62,6 +62,43 @@ pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, editing: bool) } } +/// The colour index an `.ai` layer's `Lb` gives preset `preset` (its position in +/// [`LAYER_COLORS`]). The file numbers Magenta, Cyan and Yellow 4, 5 and 6, where +/// [`LAYER_COLORS`] has Yellow, Magenta and Cyan; every other preset has its own position (#6502, +/// from the index and RGB pairs of the `Lb` records in files users sent). A reader takes the index +/// and ignores the RGB beside it, so the index decides the colour. +pub(crate) fn ai_layer_index(preset: u8) -> u8 { + match preset { + 4 => 6, + 5 => 4, + 6 => 5, + i => i, + } +} + +/// The preset (position in [`LAYER_COLORS`]) of the colour index `index` in an `.ai` layer's +/// `Lb`: the inverse of [`ai_layer_index`]. +pub(crate) fn preset_of_ai_layer_index(index: u8) -> u8 { + match index { + 6 => 4, + 4 => 5, + 5 => 6, + i => i, + } +} + +/// The colour index and RGB an `.ai` layer's `Lb` writes for `color`: a preset's index +/// ([`ai_layer_index`]) with its RGB, or `-1` with a custom colour's RGB. +pub(crate) fn layer_color(color: LayerColor) -> (i64, [u8; 3]) { + match color { + LayerColor::Preset(i) => match LAYER_COLORS.get(usize::from(i)) { + Some((_, rgb)) => (i64::from(ai_layer_index(i)), *rgb), + None => (-1, [79, 128, 255]), + }, + LayerColor::Custom(rgb) => (-1, rgb), + } +} + pub(crate) fn unit(v: f32) -> f32 { if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } } diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index b353a0088..be2d679a7 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -37,6 +37,7 @@ use crate::ps; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; #[cfg(test)] pub(crate) use envelope::MAX_CHECKED; +pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub use obj::{NameEncoding, Names}; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index b601d21fd..8b3fceecf 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -19,11 +19,14 @@ //! (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the contents (`X=` … `X+`, then //! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the //! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group -//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its -//! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its -//! drawn art. +//! holds the contents and the dictionary the mesh, which in some files lies away from the drawn art +//! by a move, as if it hadn't followed the envelope: such a mesh or top object whose grid draws its +//! contents as large as the drawn art but elsewhere is tried again moved onto that art. A top +//! object's outline is the edge of its one-patch mesh. A warp's mesh is a grid of its shape: a warp +//! of type whose own warp doesn't come out as drawn opens as a mesh envelope on that grid where that +//! does. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its drawn art. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; @@ -41,8 +44,17 @@ use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compa pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use /// The note for an envelope that keeps its drawn art. pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; +/// The note for an envelope of type, which keeps its drawn art with its type hidden (unless the +/// text document gives the type nothing, which a note of its own then says). +pub(crate) const TYPE_DRAWN: &str = "type in an envelope comes in as the envelope's drawn art, not as editable type"; /// The note for warped type that keeps its drawn art, before how many. pub(crate) const WARPED_TYPE_DRAWN: &str = "warped text objects that came in as drawn art, not as editable type"; + +/// The note for an envelope that keeps its drawn art: [`TYPE_DRAWN`] for an envelope of type +/// (`typed`), else [`DRAWN`]. +pub(crate) fn drawn_note(typed: bool) -> &'static str { + if typed { TYPE_DRAWN } else { DRAWN } +} /// The most envelopes waiting for their drawn art at once. pub(super) const MAX_PENDING: usize = 64; /// The most envelopes and warped text objects one import draws to check; more keep their drawn art. @@ -52,6 +64,10 @@ const MAX_GRID: u32 = 50; /// How far, in points over width and height together, a saved size may lie from the contents' /// bounds with their strokes and still count as that box. const SAVED_SIZE_SLACK: f64 = 0.5; +/// How far, in points over width and height together, what an envelope draws may differ in size +/// from its drawn art and still count as that art moved, and how far it must lie from it to count +/// as moved at all ([`moved_onto`]). +const MOVED_SLACK: f64 = 0.5; /// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort /// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask). const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; @@ -121,6 +137,92 @@ fn saved_margin(o: &Obj, content: &[Arc]) -> Option<[f32; 4]> { envelope_stroke_margin(stroked, plain) } +/// The box of `content` an envelope of type with Distort Appearance maps from in a file: type by +/// its glyph outlines grown by as far as its strokes paint along a side (half the weight of a +/// centred stroke), without what miter joins add at corners, as are groups by their own strokes; +/// other objects by their bounds with strokes. `None` when nothing in it has bounds. +fn half_stroked_box(content: &[Arc]) -> Option { + /// How far the visible strokes of `appearance` paint along the sides of closed outlines. + fn along(appearance: &Appearance) -> f64 { + appearance.painted_strokes().map(|s| s.side_reach(true)).fold(0.0, f64::max) + } + /// `r` grown by `by` on each side, where that is a distance. + fn grown(r: Rect, by: f64) -> Rect { + if by.is_finite() && by > 0.0 { r.inflate(by, by) } else { r } + } + fn walk(n: &Arc, acc: Option) -> Option { + let one = std::slice::from_ref(n); + match &n.kind { + NodeKind::Text(t) => { + let Some((_, ink)) = envelope_content_boxes(one, Affine::IDENTITY) else { return acc }; + // The characters' own strokes as the type's bounds with strokes measure them (they + // have no miter allowance there), and the strokes of its appearance along a side. + vectorcraft_geom::union_opt(acc, Some(grown(ink, t.char_stroke_outset().max(along(&n.appearance))))) + } + NodeKind::Group { children, clip: false } => { + let members = children.iter().fold(None, |acc, c| walk(c, acc)); + vectorcraft_geom::union_opt(acc, members.map(|b| grown(b, along(&n.appearance)))) + } + _ => vectorcraft_geom::union_opt(acc, envelope_content_boxes(one, Affine::IDENTITY).map(|(stroked, _)| stroked)), + } + } + content.iter().fold(None, |acc, c| walk(c, acc)) +} + +/// Envelope `kind` mapping from its contents' glyphs grown by as far as their strokes paint along a +/// side ([`half_stroked_box`]), for an envelope with Distort Appearance whose contents have strokes it +/// doesn't map from already; `None` otherwise. +fn with_strokes(kind: &NodeKind) -> Option { + let NodeKind::Envelope { content, options, .. } = kind else { return None }; + if !options.distort_appearance { + return None; + } + let (_, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + let margin = envelope_stroke_margin(half_stroked_box(content)?, plain)?; + if margin == options.bounds_margin || margin.iter().any(|m| *m < 0.0) || margin.iter().all(|m| *m == 0.0) { + return None; + } + let mut kind = kind.clone(); + if let NodeKind::Envelope { options, .. } = &mut kind { + options.bounds_margin = margin; + } + Some(kind) +} + +/// The kind of `envelope`, a mesh or a top object in Edit Contents, with its grid moved onto +/// `drawn`, the drawn art the file saves for it, where what it draws is as large as that art (within +/// [`MOVED_SLACK`]) but lies elsewhere (further than that): moved by as far as the drawn art's box +/// lies from the box of what it draws. While the contents are edited, the grid is kept in the +/// envelope's dictionary, and in some files it lies away from the drawn art by a move, the contents +/// with the drawn art, as if the grid hadn't followed the envelope when it moved. `None` outside +/// Edit Contents, for a warp (it maps from its contents' box, which lies with them), and where +/// nothing is drawn. Symbol instances and pattern fills in the contents are measured as they are, +/// not drawn out: where that makes the sizes differ, no move is tried. +pub(super) fn moved_onto(envelope: &Node, drawn: &Node) -> Option { + let NodeKind::Envelope { kind, editing: true, .. } = &envelope.kind else { return None }; + if matches!(**kind, EnvelopeKind::Warp { .. }) { + return None; + } + // What it draws, measured as its drawn art is: the pieces in a group with its own appearance. + let mut drew = envelope.clone(); + drew.kind = NodeKind::Group { children: vectorcraft_render::expand_live(envelope).into_iter().map(Arc::new).collect(), clip: false }; + let (from, to) = (drew.visual_bounds()?, drawn.visual_bounds()?); + let by = to.center() - from.center(); + let resized = (from.width() - to.width()).abs() + (from.height() - to.height()).abs(); + if !(resized <= MOVED_SLACK && by.x.abs() + by.y.abs() > MOVED_SLACK && by.x.is_finite() && by.y.is_finite()) { + return None; + } + let mut moved = envelope.kind.clone(); + if let NodeKind::Envelope { kind, .. } = &mut moved { + match &mut **kind { + EnvelopeKind::Mesh { points, .. } => points.iter_mut().for_each(|p| *p += by), + EnvelopeKind::TopObject { path, .. } => path.transform(Affine::translate(by)), + EnvelopeKind::Warp { .. } => return None, + } + } + Some(moved) +} + /// A warp: its style, bend, distortions and orientation. fn warp(o: &Obj) -> Option { let style = whole(o, "DeformStyle")??; @@ -179,9 +281,38 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { let mut pts: Vec = m.points.iter().map(|q| (q.p, q.handles)).collect(); let at = |pts: &[GridPoint], cols: u32, r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).map_or(Point::ZERO, |q| q.0); let remap = |h: [Vec2; 4], slots: [usize; 4]| slots.map(|s| h.get(s).copied().unwrap_or(Vec2::ZERO)); - // Columns that run down the page: the grid goes the other way round. - let along = at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0); - if along.y.abs() > along.x.abs() { + // How far the grid reaches: a run much shorter than that is rounding, not a direction. + let reach = { + let first = pts.first().map_or(Point::ZERO, |q| q.0); + let bounds = pts.iter().fold(Rect::from_points(first, first), |b, q| b.union_pt(q.0)); + bounds.width().hypot(bounds.height()) * 1e-6 + }; + // How the grid runs along its rows (every row end to end, added up) and down its columns (every + // column): read from all of them, a grid bent far round (its end columns leaning more than + // halfway over) still reads the way it runs. Each sum comes with how long its parts are: where + // they nearly cancel out, as in a ring, what is left is rounding, not a direction, and the grid + // reads by its first row or column instead. + let runs = |pts: &[GridPoint], rows: u32, cols: u32| { + let (mut along, mut down, mut along_len, mut down_len) = (Vec2::ZERO, Vec2::ZERO, 0.0, 0.0); + for r in 0..=rows { + let v = at(pts, cols, r, cols) - at(pts, cols, r, 0); + (along, along_len) = (along + v, along_len + v.hypot()); + } + for c in 0..=cols { + let v = at(pts, cols, rows, c) - at(pts, cols, 0, c); + (down, down_len) = (down + v, down_len + v.hypot()); + } + let clear = |v: Vec2, len: f64| (v.hypot() >= len * 0.01 && v.hypot() > reach).then_some(v); + let first_row = at(pts, cols, 0, cols) - at(pts, cols, 0, 0); + let first_col = at(pts, cols, rows, 0) - at(pts, cols, 0, 0); + (clear(along, along_len).unwrap_or(first_row), clear(down, down_len).unwrap_or(first_col)) + }; + // How much of a run is across the page. + let level = |v: Vec2| if v.hypot() > 0.0 { v.x.abs() / v.hypot() } else { 0.0 }; + // Columns that run across the page more than rows do: the grid goes the other way round. Nearly + // as much (a grid turned an eighth round) keeps the file's way round. + let (along, down) = runs(&pts, rows, cols); + if level(down) > level(along) + 1e-3 { let mut t = Vec::with_capacity(len); for r in 0..=cols { for c in 0..=rows { @@ -195,7 +326,7 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { (rows, cols) = (cols, rows); pts = t; } - if (at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0)).x < 0.0 { + if runs(&pts, rows, cols).0.x < 0.0 { for row in pts.chunks_mut(cols as usize + 1) { row.reverse(); for (_, h) in row.iter_mut() { @@ -203,7 +334,7 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { } } } - if (at(&pts, cols, rows, 0) - at(&pts, cols, 0, 0)).y < 0.0 { + if runs(&pts, rows, cols).1.y < 0.0 { let mut flipped: Vec = pts.chunks(cols as usize + 1).rev().flatten().copied().collect(); for (_, h) in &mut flipped { h.swap(H_DOWN, H_UP); @@ -260,6 +391,23 @@ fn unwrapped(mut nodes: Vec) -> Vec> { /// The live envelope of dictionary `o`, the group on `%_` lines before it (`before`) and the art /// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one. pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option { + envelope_as(o, before, inner, false) +} + +/// Is the envelope of dictionary `o` a warp? +fn is_warp(o: &Obj) -> bool { + whole(o, "AIDeformStyle") == Some(Some(1)) +} + +/// [`live`] for a warp, as a mesh envelope on the grid its editing data saves (the mesh that shapes +/// it), its points and handles as saved: it draws as that grid does, without the warp's style, bend +/// and distortions. `None` for an envelope that isn't a warp, or whose grid doesn't read. +fn live_on_saved_grid(o: &Obj, before: Option, inner: Vec) -> Option { + envelope_as(o, before, inner, true) +} + +/// [`live`], or with `on_grid` [`live_on_saved_grid`]. +fn envelope_as(o: &Obj, before: Option, inner: Vec, on_grid: bool) -> Option { let editing = switch(o, "AIDeformEditModeKey")?; let (mut options, fidelity) = options(o)?; let before = before?; @@ -281,14 +429,14 @@ pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option warp(o)?, - 2 => { + let kind = match (whole(o, "AIDeformStyle")??, on_grid) { + (1, false) => warp(o)?, + (1, true) | (2, false) => { let (rows, cols, pts) = upright(shape?)?; let (points, handles) = pts.into_iter().unzip(); EnvelopeKind::Mesh { rows, cols, points, handles } } - 3 => { + (3, false) => { let (rows, cols, pts) = upright(shape?)?; // The outline runs clockwise from the top left corner, one segment per grid edge. let corners = [0, cols, cols.checked_add(rows)?, cols.checked_mul(2)?.checked_add(rows)?]; @@ -368,18 +516,210 @@ pub(super) struct WarpedLook { pub(super) left_out: Option, } -/// The text objects read with a Warp, and what the import has left of the budget for checks -/// ([`drawn_alike`]). Type is laid out after the layers are read, so these wait for that -/// ([`Self::settle`]). +/// An envelope of type read with its drawn art: its dictionary, the group on `%_` lines before it +/// and the art in its `AIDeformArtKey` block, as [`live`] takes them (their text slots still +/// empty), and the id of its drawn art in the document. Type is laid out after the layers are read, +/// so it waits for that ([`WarpedType::settle_envelopes`]). +#[derive(Debug)] +pub(crate) struct TypedEnvelope { + pub(super) obj: Obj, + pub(super) before: Option, + pub(super) inner: Vec, + pub(super) drawn: NodeId, +} + +/// Why an envelope of type kept its drawn art. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TypeKept { + /// Its type is set in a font that isn't available: the fallback font doesn't draw as the file. + Font, + /// A text slot of it has no type of its own (none made, or type a look draws, threaded type, + /// type bound to a variable or a wrap object). + Type, + /// Its parts don't make a live envelope, or its drawn art is gone. + Read, + /// The import has no check left. + Budget, + /// Its live envelope doesn't draw as its drawn art. + Differs, +} + +/// What [`WarpedType::settle_envelopes`] did. +#[derive(Debug, Default)] +pub(crate) struct TypedSettled { + /// How many envelopes of type opened live. + pub(crate) live: usize, + /// Why each of the others kept its drawn art. + pub(crate) kept: Vec, + /// Each live envelope's id, with what takes its place again when the layers with it don't draw + /// as the page: its hidden type, then its drawn art. + pub(crate) put_back: Vec<(NodeId, Vec)>, +} + +/// The text objects read with a Warp and the envelopes of type, and what the import has left of the +/// budget for checks ([`drawn_alike`]). Type is laid out after the layers are read, so these wait +/// for that ([`Self::settle`], [`Self::settle_envelopes`]). #[derive(Debug, Default)] pub struct WarpedType { pub(super) looks: Vec, + pub(super) envelopes: Vec, pub(super) checks: usize, pub(super) spent: Spent, pub(super) page: Option, } impl WarpedType { + /// Are envelopes of type waiting for their type? + pub(crate) fn has_envelopes(&self) -> bool { + !self.envelopes.is_empty() + } + + /// These, for layers `compared` with a page. Layers that aren't leave the envelopes of type + /// their drawn art and their hidden type, with the note ([`TYPE_DRAWN`]) added to `notes`: a + /// live envelope opens only where the page chooses it. + pub(crate) fn compared(mut self, compared: bool, notes: &mut Vec) -> Self { + if !compared && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) { + notes.push(TYPE_DRAWN.into()); + } + self + } + + /// Make each envelope of type live where it draws as its drawn art, its contents' text slots + /// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in + /// `doc` and the type the contents take in its place (the slot's visibility and lock are the + /// type's). The live envelope takes its drawn art's place and the hidden type goes. A warp that + /// doesn't draw as its drawn art is checked again as a mesh envelope on the grid the file saves + /// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays + /// editable in that shape, and the warp's style and bend are lost. With Distort Appearance, each + /// is also checked mapping from its type's glyphs grown by half their strokes' weight + /// ([`with_strokes`]). In Edit Contents, a mesh or top object whose grid draws the type + /// elsewhere than its drawn art is checked last moved onto that art ([`moved_onto`]). Each + /// check uses one of the import's checks and, for an envelope that shows, adds to what the page + /// check allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't + /// available, the drawn art and the hidden type stay. + pub(crate) fn settle_envelopes(&mut self, doc: &mut Document, type_of: impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>) -> TypedSettled { + let mut settled = TypedSettled::default(); + if self.envelopes.is_empty() { + return settled; + } + let mut shown = BTreeSet::new(); + doc.layers.iter().for_each(|l| collect_shown(l, &mut shown)); + let envelopes = std::mem::take(&mut self.envelopes); + let mut waiting = envelopes.len(); + for envelope in envelopes { + waiting -= 1; + match self.typed_live(doc, envelope, &type_of, &shown, waiting) { + Ok(put_back) => { + settled.live += 1; + settled.put_back.push(put_back); + } + Err(why) => settled.kept.push(why), + } + } + settled + } + + /// [`Self::settle_envelopes`] for `envelope`, the shown objects of `doc` being `shown` and + /// `waiting` envelopes of type still to come: the live envelope's id and what takes its place + /// again, or why its drawn art stays. + fn typed_live( + &mut self, + doc: &mut Document, + envelope: TypedEnvelope, + type_of: &impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>, + shown: &BTreeSet, + waiting: usize, + ) -> Result<(NodeId, Vec), TypeKept> { + let TypedEnvelope { obj, mut before, mut inner, drawn } = envelope; + let art = doc.node(drawn).cloned().ok_or(TypeKept::Read)?; + // The text slots in the contents, in the order the reader put their hidden copies in. + let mut slots = vec![]; + before.iter().chain(&inner).for_each(|n| text_slots_in(n, &mut slots)); + if slots.is_empty() { + return Err(TypeKept::Type); + } + let db = vectorcraft_text::FontDb::global(); + let (mut texts, mut hidden) = (HashMap::new(), vec![]); + for (slot, look) in slots { + let (id, mut text) = type_of(doc, slot).filter(|_| !look).ok_or(TypeKept::Type)?; + let NodeKind::Text(t) = &text.kind else { return Err(TypeKept::Type) }; + if t.runs.iter().any(|r| r.style.font_family.is_empty() || !db.has_family(&r.style.font_family)) { + return Err(TypeKept::Font); + } + text.id = doc.alloc_id(); + texts.insert(slot, text); + hidden.push(id); + } + for n in before.iter_mut().chain(inner.iter_mut()) { + put_type(n, &texts); + } + // A warp that VectorCraft's own warp doesn't draw as its drawn art may still draw as the + // grid the file saves for it: then it opens as a mesh envelope on that grid. + let on_grid = if is_warp(&obj) { live_on_saved_grid(&obj, before.clone(), inner.clone()) } else { None }; + // With Distort Appearance, a file's envelope may map its type from its glyphs grown by half + // their strokes' weight or from the glyphs alone, and for type the size the file saves for + // that box is often out of date (not the size of the type there now): each is checked. + let kinds: Vec = live(&obj, before, inner) + .into_iter() + .chain(on_grid) + .flat_map(|k| { + let stroked = with_strokes(&k); + std::iter::once(k).chain(stroked) + }) + .collect(); + if kinds.is_empty() { + return Err(TypeKept::Read); + } + let (id, layer) = (doc.alloc_id(), doc.alloc_id()); + let mut plain = art.clone(); + (plain.visible, plain.opacity) = (true, 1.0); + let page = self.page.filter(|_| shown.contains(&drawn)); + let mut alike = None; + // In Edit Contents, a grid that draws the type elsewhere than its drawn art is tried again + // moved onto that art ([`moved_onto`]), after every other way. + let (mut tries, mut moved, mut again, mut i) = (kinds.into_iter(), vec![], false, 0); + loop { + let Some(kind) = tries.next() else { + if again || moved.is_empty() { + break; + } + (tries, again) = (std::mem::take(&mut moved).into_iter(), true); + continue; + }; + // A second try is only made while more checks are left than there are envelopes of + // type and warped text objects still to come (some of which may need none): one + // envelope's tries don't use up the checks of the others. + if self.checks == 0 || (i > 0 && self.checks <= waiting + self.looks.len()) { + return Err(TypeKept::Budget); + } + self.checks -= 1; + i += 1; + let mut envelope = art.clone(); + (envelope.id, envelope.kind) = (id, kind); + let shared = Shared { patterns: &doc.patterns, images: &doc.images }; + let mut live_one = envelope.clone(); + (live_one.visible, live_one.opacity) = (true, 1.0); + if drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) { + alike = Some(envelope); + break; + } + if !again && let Some(kind) = moved_onto(&live_one, &plain) { + moved.push(kind); + } + } + let envelope = alike.ok_or(TypeKept::Differs)?; + if doc.node(drawn).is_none() { + return Err(TypeKept::Read); + } + // The type is the envelope's now: its hidden copy goes, kept for when the drawn art comes back. + let mut put_back: Vec = hidden.into_iter().filter_map(|id| doc.remove(id).ok()).map(Arc::unwrap_or_clone).collect(); + let id = envelope.id; + let Some(slot) = doc.node_mut(drawn) else { return Err(TypeKept::Read) }; + *slot = envelope; + put_back.push(art); + Ok((id, put_back)) + } + /// Replace each warped text object of `doc` that doesn't draw as its saved look (or that the /// budget has no check left for) with that look: `made` gives the text object a slot became. A /// frame in `threaded` stays type, since the frames of a thread share one story. A replaced @@ -392,7 +732,7 @@ impl WarpedType { threaded: &BTreeSet, restyled: &mut Vec, ) -> usize { - let Self { looks, mut checks, mut spent, page } = self; + let Self { looks, mut checks, mut spent, page, .. } = self; let mut shown_ids = BTreeSet::new(); doc.layers.iter().for_each(|l| collect_shown(l, &mut shown_ids)); let mut replaced = 0; @@ -436,6 +776,29 @@ impl WarpedType { } } +/// The ids of the text slots in `n` and its groups, in order, each with whether it is a slot of type +/// that a look draws ([`super::is_look_slot`]). +fn text_slots_in(n: &Node, out: &mut Vec<(NodeId, bool)>) { + let look = super::is_look_slot(n.name.as_deref()); + if look || super::slot_of(n.name.as_deref()).is_some() { + out.push((n.id, look)); + } else if !matches!(n.kind, NodeKind::Compound { .. }) { + n.children().into_iter().flatten().for_each(|c| text_slots_in(c, out)); + } +} + +/// `n` with each text slot in it or its groups replaced by its type in `texts`, by the slot's id, +/// shown and locked as the slot is. +fn put_type(n: &mut Node, texts: &HashMap) { + if let Some(text) = texts.get(&n.id) { + let (visible, locked) = (n.visible, n.locked); + *n = text.clone(); + (n.visible, n.locked) = (visible, locked); + } else if !matches!(n.kind, NodeKind::Compound { .. }) { + n.children_mut().into_iter().flatten().for_each(|c| put_type(Arc::make_mut(c), texts)); + } +} + /// Add to `out` the ids of `n` and what is in it, unless hidden. fn collect_shown(n: &Node, out: &mut BTreeSet) { if n.visible { @@ -498,3 +861,91 @@ fn alike_within(live: &Node, drawn: &Node, reach: Option, layer: NodeId, s *spent = Spent { any, solid }; true } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{AppearanceItem, CharStyle, LineJoin, StrokeLayer, TextObject}; + + /// An envelope of `content` with Distort Appearance, on a flat one-patch mesh. + fn envelope_of(content: Vec>) -> NodeKind { + let points = vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(0.0, 50.0), Point::new(100.0, 50.0)]; + NodeKind::Envelope { + content, + kind: Box::new(EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }), + fidelity: 50.0, + editing: false, + options: EnvelopeOptions::NEW, + frame: Box::default(), + } + } + + /// The bounds margin of the stroked candidate for an envelope of `content`. + fn stroked_margin(content: Vec>) -> Option<[f32; 4]> { + match with_strokes(&envelope_of(content)) { + Some(NodeKind::Envelope { options, .. }) => Some(options.bounds_margin), + _ => None, + } + } + + /// "AVA" at 40 points, at (10, 60). + fn type_node(id: u64, style: CharStyle) -> Node { + Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "AVA", CharStyle { size: 40.0, ..style })))) + } + + /// A 4 point stroke with miter joins at a limit of 4. + fn mitred() -> StrokeLayer { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + (stroke.join, stroke.miter_limit) = (LineJoin::Miter, 4.0); + stroke + } + + /// Type with Distort Appearance and a stroke from its appearance is tried mapped from its glyphs + /// grown by half the stroke's weight, not from its bounds with strokes, which reach as far as a + /// miter join may at a corner. + #[test] + fn stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke() { + let mut text = type_node(1, CharStyle::default()); + let mut appearance = (*text.appearance).clone(); + appearance.items.push(AppearanceItem::Stroke(mitred())); + text.appearance = appearance.into(); + let content = vec![Arc::new(text)]; + let (with, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); + let half = glyphs.inflate(2.0, 2.0); + assert!(with.width() > half.width() + 1.0, "{with:?} {half:?}"); + assert_eq!(stroked_margin(content), envelope_stroke_margin(half, glyphs)); + } + + /// Type stroked by its characters alone is tried mapped from its bounds with strokes, as before, + /// turned and stretched as well as upright. + #[test] + fn type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes() { + let style = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 4.0, ..CharStyle::default() }; + for xf in [ + Affine::IDENTITY, + Affine::rotate(std::f64::consts::FRAC_PI_4), + Affine::scale_non_uniform(std::f64::consts::SQRT_2, std::f64::consts::FRAC_1_SQRT_2), + ] { + let mut text = type_node(1, style.clone()); + if let NodeKind::Text(t) = &mut text.kind { + t.xf = xf * t.xf; + } + let content = vec![Arc::new(text)]; + let (with, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); + assert_eq!(stroked_margin(content), envelope_stroke_margin(with, glyphs), "{xf:?}"); + } + } + + /// A stroke on a group's own appearance round type grows the box by half its weight too. + #[test] + fn a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight() { + let mut group = Node::new(NodeId(2), NodeKind::Group { children: vec![Arc::new(type_node(1, CharStyle::default()))], clip: false }); + let mut appearance = (*group.appearance).clone(); + appearance.items.push(AppearanceItem::Stroke(mitred())); + group.appearance = appearance.into(); + let content = vec![Arc::new(group)]; + let (_, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); + assert_eq!(stroked_margin(content), envelope_stroke_margin(glyphs.inflate(2.0, 2.0), glyphs)); + } +} diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cb0bc1909..5a253f21a 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -557,6 +557,9 @@ struct Reader<'a> { envelope_spent: super::envelope::Spent, /// The text objects read with a Warp ([`super::envelope::WarpedType`]). warped_type: Vec, + /// The envelopes of type read with their drawn art, settled once the type is laid out + /// ([`super::envelope::TypedEnvelope`]). + typed_envelopes: Vec, /// The live envelopes made so far and the drawn art each took the place of /// ([`Structure::envelopes_drawn`]). envelopes_drawn: Vec<(NodeId, Node)>, @@ -769,6 +772,7 @@ impl<'a> Reader<'a> { envelope_checks: super::envelope::MAX_CHECKED, envelope_spent: super::envelope::Spent::default(), warped_type: Vec::new(), + typed_envelopes: Vec::new(), envelopes_drawn: Vec::new(), clip_fills: HashSet::new(), looks: HashSet::new(), diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 35c0f46ae..426416b75 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -516,16 +516,17 @@ impl<'a> Reader<'a> { slot.visible = false; self.add(slot, false)?; } - // An envelope of type keeps its drawn art and its type, hidden, as above: the - // type is filled in after reading, which the envelope can't wait for. - if typed { - return Ok(()); - } - if self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING { - self.warn(crate::import::ai::envelope::DRAWN); + // An envelope of type keeps its drawn art and its type, hidden, as above, until + // the type is filled in after reading: then it opens live where it draws as its + // drawn art ([`crate::import::ai::envelope::TypedEnvelope`]). In a symbol's or a + // pattern's art it keeps them. + let waits = !typed || (self.symbol.is_none() && self.pattern.is_none()); + if !waits || self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING { + self.warn(crate::import::ai::envelope::drawn_note(typed)); } else { let siblings = self.frames.last().map_or(0, |f| f.children.len()); - self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth: self.frames.len(), siblings }); + let depth = self.frames.len(); + self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth, siblings, typed }); } } } @@ -862,9 +863,8 @@ impl<'a> Reader<'a> { /// in containers that closed keep their drawn art. pub(super) fn end_envelope(&mut self) { let depth = self.frames.len(); - while self.envelopes.last().is_some_and(|p| p.depth > depth) { - self.envelopes.pop(); - self.warn(crate::import::ai::envelope::DRAWN); + while let Some(p) = self.envelopes.pop_if(|p| p.depth > depth) { + self.warn(crate::import::ai::envelope::drawn_note(p.typed)); } if self.envelopes.last().is_none_or(|p| p.depth != depth) { return; @@ -872,9 +872,13 @@ impl<'a> Reader<'a> { let Some(p) = self.envelopes.pop() else { return }; let drawn = self.frames.last().filter(|f| f.children.len() == p.siblings + 1).and_then(|f| f.children.last()); let Some(drawn) = drawn else { - self.warn(crate::import::ai::envelope::DRAWN); + self.warn(crate::import::ai::envelope::drawn_note(p.typed)); return; }; + if p.typed { + self.wait_for_type(p); + return; + } let live = (self.envelope_checks > 0).then(|| crate::import::ai::envelope::live(&p.obj, p.before, p.inner)).flatten(); let Some(kind) = live else { self.warn(crate::import::ai::envelope::DRAWN); @@ -909,7 +913,18 @@ impl<'a> Reader<'a> { let mut plain = drawn.clone(); (plain.visible, plain.opacity) = (true, 1.0); let page = self.page_in_doc(); - if !crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent) { + let mut alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent); + // In Edit Contents, a grid that draws the contents elsewhere than the drawn art is checked + // again moved onto that art. + if !alike + && self.envelope_checks > 0 + && let Some(kind) = crate::import::ai::envelope::moved_onto(&shown, &plain) + { + self.envelope_checks -= 1; + (envelope.kind, shown.kind) = (kind.clone(), kind); + alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent); + } + if !alike { self.warn(crate::import::ai::envelope::DRAWN); return; } @@ -931,6 +946,32 @@ impl<'a> Reader<'a> { } } +impl Reader<'_> { + /// An envelope of type `p` whose drawn art was just read (the last object of this container): + /// it waits for its type to be laid out ([`crate::import::ai::envelope::TypedEnvelope`]), unless + /// objects the reader couldn't read are in it, or too many already wait. Then it keeps its + /// drawn art and its type, hidden. + fn wait_for_type(&mut self, p: PendingEnvelope) { + let Some(drawn) = self.frames.last().and_then(|f| f.children.last()) else { return }; + let mut ids = HashSet::new(); + fn collect(n: &Node, ids: &mut HashSet) { + ids.insert(n.id); + for c in n.children().into_iter().flatten() { + collect(c, ids); + } + } + collect(drawn, &mut ids); + p.before.iter().chain(&p.inner).for_each(|n| collect(n, &mut ids)); + let unread = self.unread.iter().any(|(id, _)| ids.contains(id)); + if unread || self.typed_envelopes.len() >= crate::import::ai::envelope::MAX_CHECKED { + self.warn(crate::import::ai::envelope::TYPE_DRAWN); + return; + } + let drawn = drawn.id; + self.typed_envelopes.push(crate::import::ai::envelope::TypedEnvelope { obj: p.obj, before: p.before, inner: p.inner, drawn }); + } +} + /// Does `node` hold a text slot? fn has_text_slot(node: &Node) -> bool { let mut slots = vec![]; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 1b962064c..ad7f26603 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -648,6 +648,10 @@ impl<'a> Reader<'a> { if self.author.is_none() { self.author = outer.author; } + // Ruler units the editing data doesn't give: the file still records them (#5820). + if self.ruler_units.is_none() { + self.ruler_units = outer.ruler_units; + } self.names.absorb(outer.names); for n in outer.custom_colors { if self.custom_colors.len() < super::directives::MAX_CUSTOM_COLORS && !self.custom_colors.iter().any(|c| c.name == n.name) { @@ -1165,9 +1169,8 @@ impl<'a> Reader<'a> { self.warn(super::types::GRAPH_UNENDED); } // Envelopes whose plugin never closed them keep their drawn art. - if !self.envelopes.is_empty() { - self.envelopes.clear(); - self.warn(crate::import::ai::envelope::DRAWN); + for p in std::mem::take(&mut self.envelopes) { + self.warn(crate::import::ai::envelope::drawn_note(p.typed)); } if !self.unknown.is_empty() { let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); @@ -1210,6 +1213,7 @@ impl<'a> Reader<'a> { show_video_safe_areas: b.safe_areas, video_ruler_pixel_aspect_ratio: b.par, color: None, + guide_label: None, }) .collect(); if doc.artboards.is_empty() { @@ -1224,6 +1228,7 @@ impl<'a> Reader<'a> { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }]; } doc.opened_artboard = active.and_then(|i| doc.artboards.get(i)).map(|a| a.id); @@ -1239,9 +1244,9 @@ impl<'a> Reader<'a> { None => None, }; let pos = if vertical { p.x } else { p.y }; - pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None }) + pos.is_finite().then_some(Guide { artboard, ..Guide::new(vertical, pos) }) }; - take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0); + take_ruler_guides(&mut doc.layers, &pick, 0); } let mut brushes = self.brush_library(); if let Some((lib, _)) = &brushes { @@ -1254,6 +1259,7 @@ impl<'a> Reader<'a> { } if let Some(u) = self.ruler_units.and_then(ruler_unit) { doc.units = u; + doc.units_from_file = true; } // The file's own swatches in place of the defaults, in its order and colour groups. let own_palette = match self.palette_swatches(&doc.patterns) { @@ -1297,6 +1303,11 @@ impl<'a> Reader<'a> { for w in &mut self.warped_type { vectorcraft_doc::swatches::map_node_solid_paints(&mut w.look, &mut follow); } + for e in &mut self.typed_envelopes { + for n in e.before.iter_mut().chain(e.inner.iter_mut()) { + vectorcraft_doc::swatches::map_node_solid_paints(n, &mut follow); + } + } } let mut warnings = self.warnings; if let Some((lib, unread)) = brushes { @@ -1341,6 +1352,7 @@ impl<'a> Reader<'a> { envelopes_drawn: self.envelopes_drawn, warped_type: super::super::envelope::WarpedType { looks: self.warped_type, + envelopes: self.typed_envelopes, checks: self.envelope_checks, spent: self.envelope_spent, page, @@ -1417,9 +1429,9 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) } } -/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into -/// `guides`, each on its layer. -fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, guides: &mut Vec, depth: usize) { +/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into +/// ruler guide nodes with the same ids, moved to the top of their layer in their order. +fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, depth: usize) { if depth > MAX_DEPTH { return; } @@ -1427,16 +1439,17 @@ fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option { - guides.push(Guide { layer: Some(id), ..g }); + guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }))); false } None => true, }); - take_ruler_guides(kids, pick, guides, depth + 1); + take_ruler_guides(kids, pick, depth + 1); + kids.extend(guides); } } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 6c84eff23..c0d9852d8 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -471,8 +471,9 @@ impl<'a> Reader<'a> { a.dim = flag(4, false).then_some(dim_pct.round() as u8); let index = n.get(at).copied().unwrap_or(0.0); let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + // The file numbers some presets differently from `LAYER_COLORS` (#6502). a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { - LayerColor::Preset(index as u8) + LayerColor::Preset(crate::ai_ops::preset_of_ai_layer_index(index as u8)) } else { LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) }); diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 473a07079..6457c1cde 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -224,6 +224,9 @@ pub(super) struct PendingEnvelope { /// had: its drawn art is the one child after them. pub(super) depth: usize, pub(super) siblings: usize, + /// Its contents hold type: it is settled once the type is laid out + /// ([`super::super::envelope::TypedEnvelope`]). + pub(super) typed: bool, } /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2f0fca73b..46386d220 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -62,9 +62,14 @@ fn arrays_nested_past_the_depth_limit_are_refused() { assert!(nested(100_000)); } -/// The children of the first layer. +/// The children of the first layer, its ruler guides left out. fn art(doc: &Document) -> Vec> { - doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default() + doc.layers.first().and_then(|l| l.children()).into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect() +} + +/// The ruler guides of `doc` by their line, artboard and layer. +fn guide_lines(doc: &Document) -> Vec { + doc.ruler_guides().into_iter().map(|g| Guide { artboard: g.artboard, layer: g.layer, ..Guide::new(g.vertical, g.pos) }).collect() } fn fill(n: &Node) -> Paint { @@ -7501,6 +7506,101 @@ fn a_mesh_envelope_opens_live_with_its_points_and_handles() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A mesh envelope bent far round, its end columns leaning more than halfway over (a warp's grid +/// bent into an arc), opens live the way it runs: its columns along the arc, as in the file. +#[test] +fn a_mesh_envelope_bent_far_round_opens_the_way_it_runs() { + use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP}; + // 3 columns along an arc round (100, 0) from 160 to 20 degrees, rows at radius 60 and 90: the + // end columns lean 70 degrees over. + let at = |(i, j): (usize, usize)| { + let (a, r) = ((160.0 - i as f64 * 140.0 / 3.0).to_radians(), 60.0 + j as f64 * 30.0); + (100.0 + r * a.cos(), r * a.sin()) + }; + let handle = |p: (usize, usize), q: (usize, usize)| { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + // Written with its first index across the arc and its second along it, so that the grid reads + // turned round and has to be turned upright. + let shape = mesh_text(1, 3, &|(i, j)| at((j, i)), &|p, q| handle((p.1, p.0), (q.1, q.0)), None); + // Row r of the envelope is row 1 - r of the file (art space has y up). + let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y); + let (mut points, mut handles) = (vec![], vec![]); + for r in 0..=1usize { + for c in 0..=3usize { + let g = (c, 1 - r); + let p = doc_pt(at(g)); + points.push(p); + let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 3 && q.1 <= 1).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p); + let mut h = [kurbo::Vec2::ZERO; 4]; + h[H_RIGHT] = towards(Some((g.0 + 1, g.1))); + h[H_LEFT] = towards(g.0.checked_sub(1).map(|i| (i, g.1))); + h[H_UP] = towards(Some((g.0, g.1 + 1))); + h[H_DOWN] = towards(g.1.checked_sub(1).map(|j| (g.0, j))); + handles.push(h); + } + } + let want = EnvelopeKind::Mesh { rows: 1, cols: 3, points, handles }; + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let EnvelopeKind::Mesh { rows, cols, points: got, .. } = live_envelope(&a[0]).1 else { panic!("{a:#?}") }; + assert_eq!((*rows, *cols), (1, 3)); + let EnvelopeKind::Mesh { points: want, .. } = &want else { panic!("{want:?}") }; + assert!(got.iter().zip(want).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{got:?}"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// A mesh envelope bent halfway round, its grid running down an arc from top to bottom so that its +/// end rows cancel out, opens the same way round wherever it sits on the page and whichever way +/// round the file writes it. +#[test] +fn a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is() { + use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP}; + for cx in [100.0, 100.1, 37.0, 250.3, 12.7] { + // 4 rows down an arc round (cx, 0) from 90 to 270 degrees, columns at radius 90 and 60. + let at = |(i, j): (usize, usize)| { + let (a, r) = ((90.0 + i as f64 * 45.0_f64).to_radians(), 60.0 + j as f64 * 30.0); + (cx + r * a.cos(), r * a.sin()) + }; + let handle = |p: (usize, usize), q: (usize, usize)| { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + // Row r of the envelope is the arc's point r from the top, column c the radius 1 - c: the + // outer one is on the left. + let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y); + let (mut points, mut handles) = (vec![], vec![]); + for r in 0..=4usize { + for c in 0..=1usize { + let g = (r, 1 - c); + let p = doc_pt(at(g)); + points.push(p); + let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 4 && q.1 <= 1).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p); + let mut h = [kurbo::Vec2::ZERO; 4]; + h[H_DOWN] = towards(Some((g.0 + 1, g.1))); + h[H_UP] = towards(g.0.checked_sub(1).map(|i| (i, g.1))); + h[H_RIGHT] = towards(g.1.checked_sub(1).map(|j| (g.0, j))); + h[H_LEFT] = towards(Some((g.0, g.1 + 1))); + handles.push(h); + } + } + let want = EnvelopeKind::Mesh { rows: 4, cols: 1, points, handles }; + let along = mesh_text(4, 1, &at, &handle, None); + let across = mesh_text(1, 4, &|(i, j)| at((j, i)), &|p, q| handle((p.1, p.0), (q.1, q.0)), None); + for shape in [along, across] { + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let EnvelopeKind::Mesh { rows, cols, points: got, .. } = live_envelope(&a[0]).1 else { panic!("at {cx}: {a:#?}") }; + assert_eq!((*rows, *cols), (4, 1), "at {cx}"); + let EnvelopeKind::Mesh { points: want, .. } = &want else { panic!("{want:?}") }; + assert!(got.iter().zip(want).all(|(p, q)| (*p - *q).hypot() < 1e-6), "at {cx}: {got:?}"); + } + } +} + /// An envelope made with a top object opens as a live envelope whose top path is the edge of its /// one-patch mesh, an anchor on the edge included; its contents stay the rectangle. #[test] @@ -7785,6 +7885,63 @@ fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() { assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files it +/// lies away from the drawn art by a move, the contents with the drawn art. Where the mesh draws the +/// contents as large as the drawn art but elsewhere, the envelope opens live with its mesh moved +/// onto that art; where it draws them at another size, it keeps its drawn art. +#[test] +fn an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art() { + use kurbo::Shape as _; + use vectorcraft_doc::live::EnvelopeKind; + // The top object of `an_envelope_whose_contents_are_edited_opens_live_in_edit_contents`. + let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((0, 1), (1, 1)) => (70.0, 95.0), + ((1, 1), (0, 1)) => (130.0, 95.0), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let mesh = mesh_text(1, 1, &at, &handle, None); + let mut bp = kurbo::BezPath::new(); + bp.move_to((40.0, 20.0)); + bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0)); + bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0)); + bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0)); + bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0)); + bp.close_path(); + let shaped = |a: Affine| EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&(a * bp.clone())), corners: None }; + let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"); + // The contents and the drawn art lie 30 points right and 15 down of where the mesh in the + // dictionary draws them. + let moved = Affine::translate((30.0, 15.0)); + let contents = Rect::new(70.0, 35.0, 190.0, 95.0); + let read_with = |drawn: &EnvelopeKind| { + let block = format!("0 g\n{mesh}"); + let red = format!("{RED}{}f\n", rect_path(contents.x0, 100.0 - contents.y1, contents.x1, 100.0 - contents.y0)); + read_ok(&layer(&envelope_parts(&red, &keys, &block, 3, &drawn_box(drawn, contents, contents)))) + }; + let r = read_with(&shaped(moved)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, _, editing, _) = live_envelope(&a[0]); + assert!(editing); + let EnvelopeKind::TopObject { path, .. } = kind else { panic!("{kind:?}") }; + // Moved as far as the drawn art's box lies from the box of what the mesh draws. + let (got, want) = (path.to_bezpath().bounding_box(), (moved * bp.clone()).bounding_box()); + assert!((got.origin() - want.origin()).hypot() < 0.01 && (got.size() - want.size()).to_vec2().hypot() < 1e-6, "{got:?} {want:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(content[0].geometric_bounds(), Some(contents)); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Drawn art moved and a tenth larger isn't the mesh's art moved: it stays. + let r = read_with(&shaped(moved * Affine::scale_about(1.1, (100.0, 50.0)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + /// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape /// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art /// round it is read as ever. @@ -7992,7 +8149,7 @@ fn warped_type_on_the_page_spends_what_is_left_of_the_budget() { let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None }; let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0)); let settle = |w: &super::envelope::WarpedLook, spent| { - let warped = super::envelope::WarpedType { looks: vec![copy(w)], checks: 1, spent, page }; + let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page }; warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![]) }; let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 }; @@ -8307,17 +8464,16 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { ); let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; let top = &doc.layers[0]; - let kids = top.children().unwrap(); + let kids = art(&doc); assert_eq!(kids.len(), 2, "the square and the sublayer"); assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. })); let sub = &kids[1]; - assert!(sub.children().unwrap().is_empty()); - let want = [ - Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) }, - ]; - assert_eq!(doc.guides, want); + assert!(sub.children().unwrap().iter().all(|c| c.is_ruler_guide())); + let at = |vertical, pos, layer| Guide { layer: Some(layer), ..Guide::new(vertical, pos) }; + let want = [at(false, 60.0, top.id), at(true, 25.0, top.id), at(false, 10.0, sub.id)]; + assert_eq!(guide_lines(&doc), want); + // The guides sit above the layers' art. + assert!(top.children().unwrap()[2..].iter().all(|c| c.is_ruler_guide())); } /// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that @@ -8326,7 +8482,7 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { // The crop marks' artboard, 0 0 200 100. let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc; - assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(1), layer: Some(doc.layers[0].id), ..Guide::new(false, 70.0) }]); assert!(art(&doc).is_empty()); // The second of two artboards: art space x 400 to 600, y -150 to 0. let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ @@ -8335,7 +8491,7 @@ fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc; assert_eq!(doc.artboards[1].id, 2); - assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(2), layer: Some(doc.layers[0].id), ..Guide::new(true, 500.0) }]); } /// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects), @@ -8354,7 +8510,7 @@ fn other_guide_paths_stay_guide_paths() { ]; for body in cases { let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; - assert!(doc.guides.is_empty(), "{body}"); + assert!(doc.ruler_guides().is_empty(), "{body}"); assert_eq!(art(&doc).len(), 1, "{body}"); } } @@ -8370,20 +8526,44 @@ fn layer_with(ops: &str) -> Node { /// the colour index and the last one change nothing. #[test] fn the_template_flag_of_a_layer() { + // Index 4 is the file's Magenta (#6502). let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0"); let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; assert!(template && !printable && l.locked); - assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4))); + assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(5))); + // Index 5 is the file's Cyan (#6502). for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] { let l = layer_with(ops); let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; assert!(template && printable && !l.locked, "{ops}"); - assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}"); + assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(6)), "{ops}"); } let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0"); assert!(matches!(l.kind, NodeKind::Layer { template: false, .. })); } +/// #6502: an `.ai` file numbers Magenta, Cyan and Yellow 4, 5 and 6 (the index and RGB pairs of +/// the `Lb` records in files users sent), so they open as those presets; the index decides, not +/// the RGB beside it, and the other presets keep their position. +#[test] +fn layer_colour_indexes_follow_the_file_format() { + let color = |ops: &str| match layer_with(ops).kind { + NodeKind::Layer { color, .. } => color, + _ => panic!(), + }; + let preset = |name: &str| { + let i = vectorcraft_doc::LAYER_COLORS.iter().position(|(n, _)| *n == name).unwrap(); + LayerColor::Preset(u8::try_from(i).unwrap()) + }; + for (index, rgb, name) in + [(3, "79 79 255", "Blue"), (4, "255 79 255", "Magenta"), (5, "79 255 255", "Cyan"), (6, "255 255 79", "Yellow"), (7, "128 128 128", "Gray")] + { + assert_eq!(color(&format!("1 1 1 1 0 0 0 {index} {rgb} 0 50 0")), preset(name), "index {index}"); + } + // The RGB beside an index doesn't change the preset. + assert_eq!(color("1 1 1 1 0 0 0 4 255 255 79 0 50 0"), preset("Magenta")); +} + /// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth /// is the dimming percentage; with nine it is one place earlier still. #[test] diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 2250dea96..194c0ff1e 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -26,8 +26,8 @@ use std::collections::BTreeMap; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, - TabStop, TextKind, TextObject, TextRun, + CharStyle, HyphenationOptions, JustificationOptions, Justify, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, + PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; @@ -462,6 +462,12 @@ struct Style { fill: Option, stroke: Option, stroke_width: f64, + /// Key 61, the stroke's cap: 0 butt, 1 round, 2 projecting (square). + stroke_cap: LineCap, + /// Key 62, the stroke's join: 0 miter, 1 round, 2 bevel. + stroke_join: LineJoin, + /// Key 64, the stroke's miter limit (a multiple of the stroke weight). + stroke_miter_limit: f64, /// Key 12, the case: 1 Small Caps, 2 All Caps. caps: u8, /// Key 35 at 3: Tate-chu-yoko, the characters set across the column as one block. @@ -752,6 +758,21 @@ impl Texts { fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), + // Numbered as PostScript's `setlinecap` and `setlinejoin` operators number them + // (PostScript Language Reference, 3rd ed.): in users' files the page strokes type with + // the cap and join these give, and with key 64 as its miter limit. No file had a bevel + // join (2). Any other value keeps the default. + stroke_cap: match num("61") { + Some(1.0) => LineCap::Round, + Some(2.0) => LineCap::Square, + _ => LineCap::Butt, + }, + stroke_join: match num("62") { + Some(1.0) => LineJoin::Round, + Some(2.0) => LineJoin::Bevel, + _ => LineJoin::Miter, + }, + stroke_miter_limit: num("64").filter(|v| v.is_finite() && *v >= 1.0).map_or(10.0, |v| v.min(500.0)), caps: num("12").filter(|v| *v == 1.0 || *v == 2.0).map_or(0, |v| v as u8), tate_chu_yoko: num("35") == Some(3.0), standard_vertical_roman: num("35") == Some(1.0), @@ -1278,6 +1299,9 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { fn char_style(s: &Style, scale: f64) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // Type without a stroke keeps the stroke's defaults, as its weight is 0. + let (stroke_cap, stroke_join, stroke_miter_limit) = + if s.stroke.is_some() { (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) } else { (LineCap::Butt, LineJoin::Miter, 10.0) }; CharStyle { font_family: family, font_style: style, @@ -1293,6 +1317,9 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { fill: s.fill.map_or(Paint::None, Paint::solid), stroke: s.stroke.map_or(Paint::None, Paint::solid), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, + stroke_cap, + stroke_join, + stroke_miter_limit, all_caps: s.caps == 2, // At the default Small Caps size of Document Setup: the file's own isn't read. small_caps: (s.caps == 1).then(|| vectorcraft_doc::DocSetup::default().small_caps_size), @@ -1573,6 +1600,44 @@ mod tests { assert!(texts(b"/35 3").style(None).tate_chu_yoko && !texts(b"/35 3").style(None).standard_vertical_roman); } + /// Keys 61, 62 and 64 of a character style are its stroke's cap (0 butt, 1 round, 2 + /// projecting), join (0 miter, 1 round, 2 bevel) and miter limit, from the run, its character + /// style or the document's defaults. A value that isn't one of theirs keeps the default, a miter + /// limit past 500 is 500, and type without a stroke keeps the defaults. + #[test] + fn a_run_keeps_its_stroke_cap_join_and_miter_limit() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: read(b"/57 true /54 << /0 << /0 0 /1 [ 1.0 1.0 ] >> >> /64 4.0").unwrap(), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rounded) /6 << /61 1 /62 1 >>").unwrap()], + para_sheets: vec![], + frames: vec![], + }; + let stroke = |run: &[u8]| { + let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0); + (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) + }; + assert_eq!(stroke(b"/6 << /61 2 /62 2 /64 2.5 >>"), (LineCap::Square, LineJoin::Bevel, 2.5), "the run's own"); + assert_eq!(stroke(b"/5 1 /6 << >>"), (LineCap::Round, LineJoin::Round, 4.0), "its character style's and the defaults'"); + assert_eq!(stroke(b"/5 1 /6 << /62 0 >>"), (LineCap::Round, LineJoin::Miter, 4.0), "the run's own wins over its style"); + assert_eq!(stroke(b"/6 << >>"), (LineCap::Butt, LineJoin::Miter, 4.0), "the defaults'"); + assert_eq!(stroke(b"/6 << /64 1 >>").2, 1.0); + assert_eq!(stroke(b"/6 << /64 1e9 >>").2, 500.0); + for bad in [ + &b"/6 << /61 3 /62 3 /64 0.5 >>"[..], + b"/6 << /61 -1 /62 -1 /64 -4 >>", + b"/6 << /61 1.5 /62 0.5 /64 NaN >>", + b"/6 << /61 (1) /62 (1) /64 inf >>", + b"/6 << /61 true /62 [ 1 ] /64 (4) >>", + b"/6 << /61 1e308 /62 /Round /64 -inf >>", + ] { + assert_eq!(stroke(bad), (LineCap::Butt, LineJoin::Miter, 10.0), "{}", String::from_utf8_lossy(bad)); + } + assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. @@ -1840,6 +1905,9 @@ mod tests { fill: Some(Color::BLACK), stroke: None, stroke_width: 1.0, + stroke_cap: LineCap::Butt, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 10.0, caps: 0, tate_chu_yoko: false, standard_vertical_roman: false, diff --git a/crates/eps/src/import/data/filters.rs b/crates/eps/src/import/data/filters.rs index e3268b240..79dce67ea 100644 --- a/crates/eps/src/import/data/filters.rs +++ b/crates/eps/src/import/data/filters.rs @@ -131,6 +131,10 @@ fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { } } +/// Most pixels a DCTDecode JPEG may have: twice the most an image is read with, so every image that +/// was read before is still read. +const MAX_DCT_PIXELS: u64 = 1 << 27; + /// A JPEG's pixels (gray, RGB or CMYK, 8 bits) with its channel count, and how many bytes of /// `raw` it took. A CMYK JPEG gives its samples as stored, for the image's Decode array to read /// (Adobe's CMYK JPEGs store them inverted, which a Decode array of `[1 0 1 0 1 0 1 0]` undoes). @@ -143,6 +147,12 @@ fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { { return Ok((inks.data.into_iter().map(|v| 255 - v).collect(), end, 4)); } + // The header's size is checked before the decoder allocates for it (a progressive JPEG keeps its + // coefficients besides its output). + let size = image::ImageReader::with_format(std::io::Cursor::new(jpeg), image::ImageFormat::Jpeg).into_dimensions(); + if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > MAX_DCT_PIXELS) { + return Err(PsError::Limit("decoded data is too large")); + } let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } } diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index 33f394482..04e5431d3 100644 --- a/crates/eps/src/import/data/ops.rs +++ b/crates/eps/src/import/data/ops.rs @@ -7,6 +7,9 @@ use super::{ /// Most bytes one filter or image reads. pub(super) const MAX_DATA: usize = 1 << 28; + +/// Most data sources one image may name (a DeviceN space has up to 32 components). +const MAX_SOURCES: usize = 32; /// The longest chain of filters over one pending file (real programs stack two or three). const MAX_FILTER_CHAIN: usize = 64; @@ -389,6 +392,25 @@ impl Interp<'_> { /// Decode the filters a file waits to run over the data the program reads, from where it is /// now in the file they were made over: a file being run, or the program's own file. pub(in super::super) fn materialize(&mut self, f: &Rc>) -> Res { + let input = match &*f.borrow() { + Stream::Pending { from: Some(r), .. } => match &*r.borrow() { + Stream::Buf { data, pos, .. } => data.len().saturating_sub(*pos), + _ => 0, + }, + Stream::Pending { from: None, .. } => self.lex.data().len(), + _ => return Ok(()), + }; + let r = self.decode_pending(f); + // A decode that failed is over: the stream is empty from now on, and the work it did is + // charged, so a program that catches the error can't have it run again and again. + if r.is_err() && matches!(&*f.borrow(), Stream::Pending { .. }) { + *f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None }; + self.spend_bytes(input)?; + } + r + } + + fn decode_pending(&mut self, f: &Rc>) -> Res { let (chain, from) = match &*f.borrow() { Stream::Pending { chain, from } => (chain.clone(), from.clone()), _ => return Ok(()), @@ -663,6 +685,8 @@ impl Interp<'_> { let multi = get("MultipleDataSources").is_some_and(|o| matches!(o, Obj::Bool(true))); let sources = match (get("DataSource"), multi) { (None, _) => vec![], + // An image has a data source for each of its components, so many more is no image: refused before any copy. + (Some(Obj::Array { items, exec: false }), true) if items.len() > MAX_SOURCES => return ps_err("rangecheck", "DataSource"), (Some(Obj::Array { items, exec: false }), true) => items.borrow().iter().map(source).collect::>>()?, (Some(src), _) => vec![source(&src)?], }; diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 4920b1d66..992a8bbce 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -550,10 +550,30 @@ impl<'a> Interp<'a> { /// An object as the scanner gives it: with `immediate`, its `//name`s replaced by their /// values (down through procedures), as they are when read. - pub fn scanned(&self, o: Obj, immediate: bool) -> Res { + pub fn scanned(&mut self, o: Obj, immediate: bool) -> Res { + // Every scan builds a fresh string or procedure, so a loop that rescans one text is charged + // for each copy it keeps, not only for the text. + self.charge_scanned(&o, 0)?; if immediate { self.resolve(o, 0) } else { Ok(o) } } + /// Count the storage of the string or procedure the scanner just made against [`MAX_MEMORY`], + /// and the time to build it. + fn charge_scanned(&mut self, o: &Obj, depth: usize) -> Res { + match o { + Obj::Str(s) => { + self.alloc(s.len())?; + self.spend_bytes(s.len()) + } + Obj::Array { items, .. } if depth < MAX_DEPTH as usize => { + self.alloc(items.len().saturating_mul(std::mem::size_of::()))?; + self.spend_bytes(items.len())?; + items.borrow().iter().try_for_each(|i| self.charge_scanned(i, depth + 1)) + } + _ => Ok(()), + } + } + fn resolve(&self, o: Obj, depth: usize) -> Res { match o { Obj::Exec(n) if n.starts_with(IMMEDIATE) => { diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index e0d00ac82..c86cdb951 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -104,8 +104,7 @@ pub(super) type Built = Result<(Document, Vec), String>; /// units ([`page_scale_alone`]). The document is drawn at that scale. Else `Err(structure)`, for /// [`build_from`] with the page. pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Result> { - let Some(scale) = page_scale_alone(&structure, pages, unit).filter(|_| nothing_waits_on_page(&structure) && type_all_made(&structure, data)) - else { + let Some(scale) = alone_scale(&structure, data, pages, unit) else { return Err(Box::new(structure)); }; let styles = structure.styles_read; @@ -120,6 +119,12 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6 Ok(built) } +/// The scale [`build_without_page`] draws `structure` (the editing copy `data` read) at on `pages` +/// in units of `unit` points, when it builds it without the page. +pub(super) fn alone_scale(structure: &Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Option { + page_scale_alone(structure, pages, unit).filter(|_| nothing_waits_on_page(structure) && type_all_made(structure, data)) +} + /// Is nothing in `structure` waiting on the page: no object unread (the page's art takes its /// place), no placed file with another placement, no live envelope with the drawn art it replaced /// (the comparison with the page chooses between them), no linked image showing a placeholder @@ -424,8 +429,20 @@ pub(super) fn build_from( } // Area type flows around the wrap objects above it before it is compared with the page. resolve_text_wrap(&mut doc)?; - let bound_text = text_bound_slots(&doc, &bindings, &xml_ids); - settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, &bound_text, &mut restyled, &mut notes)?; + let bound_text = bound_slots(&doc, &bindings, &xml_ids, VariableKind::Text); + let bound_visible = bound_slots(&doc, &bindings, &xml_ids, VariableKind::Visibility); + // Without a page to compare the layers with, envelopes of type keep their drawn art: like the + // other live envelopes, one opens live only where the layers with it draw as the page. + let warped_type = warped_type.compared(frame.is_some(), &mut notes); + let bound = [&bound_text, &bound_visible]; + let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, bound, &mut restyled, &mut notes)?; + // The live envelopes and what takes the place of each again, when the layers with them don't + // draw as the page, with the note that says so. + let envelopes_drawn: Vec<(NodeId, Vec, &str)> = envelopes_drawn + .into_iter() + .map(|(id, art)| (id, vec![art], ai::ENVELOPE_DRAWN)) + .chain(typed.into_iter().map(|(id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN))) + .collect(); if empty > 0 { notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); } @@ -478,9 +495,14 @@ pub(super) fn build_from( other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow) { Ok(more) => notes.extend(more), // The objects that draw differently from the page come in as its art, from - // the placement that draws closer to it. + // the placement that draws closer to it. The layers don't draw as the page: + // envelopes of type keep their drawn art and their type, hidden. Err(why) => { - let other = other.map(|(_, other, unseen)| (other, unseen)); + let typed = type_drawn(&mut doc, &envelopes_drawn); + let other = other.map(|(_, mut other, unseen)| { + type_drawn(&mut other, &envelopes_drawn); + (other, unseen) + }); let placements = closer_first(&frame, &mut view, (doc, unseen), other, reflow); let Some((kept, unseen, kept_notes)) = placements.into_iter().find_map(|(base, unseen)| { keep_differing(&base, &frame, &mut view, &threaded_frames, &shown, reflow).map(|(kept, n)| (kept, unseen, n)) @@ -488,7 +510,8 @@ pub(super) fn build_from( return Err(why); }; doc = kept; - notes.extend(unseen_note(unseen).into_iter().chain(kept_notes)); + let typed = typed.then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n)); + notes.extend(typed.into_iter().chain(unseen_note(unseen)).chain(kept_notes)); } }, }, @@ -616,40 +639,69 @@ fn bind_variables( notes.extend(ai::bindings_note(skipped)); } -/// The text object slots of `doc` that `bindings` binds to a text variable, each named by its XML id -/// (`ids`). -fn text_bound_slots(doc: &Document, bindings: &[ai::BindingRef], ids: &std::collections::HashMap>) -> BTreeSet { +/// The objects of `doc`, text object slots among them, that `bindings` binds to a variable of +/// `kind`, each named by its XML id (`ids`). +fn bound_slots( + doc: &Document, + bindings: &[ai::BindingRef], + ids: &std::collections::HashMap>, + kind: VariableKind, +) -> BTreeSet { bindings .iter() - .filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == VariableKind::Text)) + .filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == kind)) .filter_map(|b| b.target.as_ref().and_then(|t| ids.get(t).copied().flatten())) .collect() } /// Warped type stays type where its live warp draws as its look, and where a text variable binds it -/// (`bound_text`, by slot). Elsewhere the look takes its place, and one note says how many did. +/// (`bound`'s first set, by slot). Elsewhere the look takes its place, and one note says how many did. /// `filled` says what each slot became, and the comparisons with the page leave out the boxes in /// `restyled`. Fails as [`resolve_text_wrap`] does when the looks give the wrap shapes too many -/// anchors. +/// anchors. Before that, the envelopes of type open live where they draw as their drawn art, with +/// the type of their text slots (made type, one text object for the slot, not threaded, bound to +/// no text variable and no visibility variable, `bound`'s second set, and no wrap object); the +/// others keep their drawn art, with a note. Returns each live envelope of type's id with what +/// takes its place again (its hidden type, then its drawn art). fn settle_warped_type( - warped: ai::WarpedType, + mut warped: ai::WarpedType, doc: &mut Document, filled: &std::collections::HashMap, threaded: &BTreeSet, - bound_text: &BTreeSet, + bound: [&BTreeSet; 2], restyled: &mut Vec, notes: &mut Vec, -) -> Result<(), String> { +) -> Result)>, String> { + let [bound_text, bound_visible] = bound; let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) { Some(&[one]) => Some(one), _ => None, }; + // An envelope's type: the one text object a slot became (hidden, with nothing else), named as + // the slot (made type is no longer weighed against the page: the envelope draws it). A variable + // binds the hidden text object a slot became, which the live envelope's type takes the place of + // with an id of its own: a slot bound to one keeps its type hidden, and the drawn art stays. + let type_of = |doc: &Document, slot: NodeId| { + if bound_visible.contains(&slot) { + return None; + } + let id = made(slot).filter(|id| !threaded.contains(id) && filled.get(&slot).is_some_and(|f| f.objects == [*id]))?; + let mut text = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.extras().wrap.is_none())?.clone(); + if let Some(name) = text.name.as_deref().and_then(|n| n.strip_prefix(MADE)) { + text.name = (!name.is_empty()).then(|| name.to_string()); + } + Some((id, text)) + }; + let typed = warped.settle_envelopes(doc, type_of); + if !typed.kept.is_empty() && !notes.iter().any(|n| n == ai::ENVELOPE_TYPE_DRAWN) { + notes.push(ai::ENVELOPE_TYPE_DRAWN.into()); + } let replaced = warped.settle(doc, made, threaded, restyled); if replaced > 0 { notes.push(format!("{}: {replaced}", ai::WARPED_TYPE_DRAWN)); resolve_text_wrap(doc)?; } - Ok(()) + Ok(typed.put_back) } /// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]). @@ -1254,14 +1306,15 @@ fn slot_content( /// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the /// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took /// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that; -/// else, with `trust`, the notes for `doc` as it is. An error, `why`, otherwise. The envelope note -/// is added unless `notes` has it. +/// else, with `trust`, the notes for `doc` as it is but for its envelopes of type, which keep their +/// drawn art ([`type_drawn`]). An error, `why`, otherwise. The envelope note is added unless +/// `notes` has it. #[allow(clippy::too_many_arguments)] fn drawn_or_trusted( frame: &Frame<'_>, view: &mut PageView, doc: &mut Document, - drawn: &[(NodeId, Node)], + drawn: &[(NodeId, Vec, &str)], notes: &[String], unseen: usize, trust: bool, @@ -1270,27 +1323,61 @@ fn drawn_or_trusted( ) -> Result, String> { let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); let mut put_back = doc.clone(); - let mut any = false; - for (id, art) in drawn { - if let Some(n) = put_back.node_mut(*id) { - *n = art.clone(); - any = true; - } + // Each envelope still there, in place of the art it took the place of. + let there: Vec<&(NodeId, Vec, &str)> = drawn.iter().filter(|(id, ..)| put_back.node(*id).is_some()).collect(); + // One inside another's art goes with it: only the outermost are put back (a place inside + // another place fails the whole put-back). + let mut inside = std::collections::HashSet::new(); + for n in there.iter().filter_map(|(id, ..)| put_back.node(*id)) { + n.walk(&mut |c| { + if c.id != n.id { + inside.insert(c.id); + } + }); } + let outermost = there.iter().filter(|(id, ..)| !inside.contains(id)).map(|(id, art, _)| (*id, art.clone())).collect(); + let any = !there.is_empty() && put_back.insert_beside(outermost, true).is_ok(); if any { let more = prune_unseen(frame, view, &mut put_back, reflow); if let Ok(note) = compare(frame, view, &put_back, reflow) { *doc = put_back; - let envelope = (!notes.iter().any(|n| n == ai::ENVELOPE_DRAWN)).then(|| ai::ENVELOPE_DRAWN.to_string()); + let mut envelope: Vec = vec![]; + for (.., say) in there { + if !notes.iter().chain(&envelope).any(|n| n == say) { + envelope.push(say.to_string()); + } + } return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect()); } } if trust { - return Ok(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")]).collect()); + let typed = type_drawn(doc, drawn).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n)); + let trusted = format!("{why}: they come from its editing data all the same"); + return Ok(typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect()); } Err(why) } +/// Put each live envelope of type in `drawn` (as [`drawn_or_trusted`] takes them) that `doc` still +/// has back as its hidden type and its drawn art: whether one was. An envelope of type opens live +/// only where the layers with it draw as the page, so where they don't, though they are used all +/// the same (the editing data trusted, or the objects that differ come in as the page's art), it +/// keeps its drawn art. +fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec, &str)]) -> bool { + let typed: Vec<(NodeId, Vec)> = drawn + .iter() + .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && doc.node(*id).is_some()) + .map(|(id, art, _)| (*id, art.clone())) + .collect(); + if typed.is_empty() || doc.insert_beside(typed, true).is_err() { + return false; + } + // The type put back is still marked as made from the text document, which the rest no longer + // is once the page has weighed it ([`prune_unseen`]). + unmark(&mut doc.layers, true, true, &mut vec![]); + true +} + /// `doc` with the placed files `placed` lists at their other placement (see /// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when /// no listed image survives replacement. @@ -1582,7 +1669,7 @@ fn page_boards(doc: &Document, visible: &Document, first: usize, pages: &[Rect]) /// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. fn drop_guides_off_boards(doc: &mut Document) { let kept: Vec = doc.artboards.iter().map(|a| a.id).collect(); - doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id))); + doc.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.artboard.is_none_or(|id| kept.contains(&id))); } /// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their @@ -1595,13 +1682,12 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let layer = Arc::make_mut(layer); layer.transform(a, scaling); scale_wrap_offsets(layer, k); + // A transform leaves ruler guides where they are: their positions scale here. + layer.map_ruler_guide_positions(&mut |_, pos| *pos *= k); } for board in &mut doc.artboards { board.rect = a.transform_rect_bbox(board.rect); } - for guide in &mut doc.guides { - guide.pos *= k; - } if styles { for g in doc.graphic_styles.iter_mut() { g.appearance.scale_strokes(k); @@ -2000,7 +2086,7 @@ mod candidate_tests { let warped = ai::WarpedType::unchecked(vec![(id, (*look).clone())]); let filled = std::collections::HashMap::from([(id, Filled { text: vec![id], objects: vec![id] })]); let mut notes = vec![]; - let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), &BTreeSet::new(), &mut vec![], &mut notes); + let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), [&BTreeSet::new(); 2], &mut vec![], &mut notes); assert_eq!(settled.is_ok(), ok, "{texts} text objects"); assert_eq!(notes, ["warped text objects that came in as drawn art, not as editable type: 1"]); } diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 99cafb771..34f458f32 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -122,7 +122,7 @@ pub(super) fn comparison_samples(doc: &Document) -> u32 { let (mut images, mut vector) = (false, false); doc.walk(|n| match n.kind { NodeKind::Image(_) => images = true, - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } | NodeKind::RulerGuide { .. } => {} _ => vector = true, }); if images && !vector { 4 } else { 1 } @@ -714,7 +714,8 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: } /// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin -/// for the strokes round it). +/// for the strokes round it). The type in a live envelope isn't: the envelope draws it bent, away +/// from its text object's box, and is weighed as the art it draws, as its drawn art would be. fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { for n in nodes.iter().filter(|n| n.visible) { if let NodeKind::Text(_) = n.kind @@ -723,7 +724,9 @@ fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec let px = |r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale); out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH))); - } else if let Some(c) = n.children() { + } else if !matches!(n.kind, NodeKind::Envelope { .. }) + && let Some(c) = n.children() + { type_areas(c, region, scale, out); } } diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index c0331574c..ee39aec1f 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,4 @@ -use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page, reid_page_art}; +use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_without_page, reid_page_art}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -137,7 +137,7 @@ pub fn layered_ai_from( all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); all }; - let AiLayers { data, structure } = layers; + let AiLayers { data, structure, alone } = layers; let data = match data { Ok(data) => data, Err(why) => return (visible, unread(warnings, &why), false), @@ -180,6 +180,18 @@ pub fn layered_ai_from( let built = structure .unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new)) .and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit)); + // Layers that waited on the page only for their envelopes of type open alone, as they would + // have without them, each envelope keeping its drawn art, unless one opened live with the page. + let live = matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers)); + if !live + && let Some((mut done, notes)) = alone.and_then(|(pages, unit)| { + let structure = ai::read_over_page(&data, false, names).ok()?; + build_without_page(structure, &data, &pages, unit).ok()?.ok() + }) + { + done.setup.bleed = visible.setup.bleed; + return (done, notes, true); + } match built { Ok((mut done, notes)) => { done.setup.bleed = visible.setup.bleed; @@ -191,6 +203,17 @@ pub fn layered_ai_from( } } +/// Is there an envelope of type (one whose contents hold type) in `nodes` or what they hold? +fn holds_type_envelope(nodes: &[Arc]) -> bool { + fn holds_type(n: &Node) -> bool { + matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|c| holds_type(c))) + } + nodes.iter().any(|n| match &n.kind { + NodeKind::Envelope { content, .. } => content.iter().any(|c| holds_type(c)), + _ => n.children().is_some_and(|c| holds_type_envelope(c)), + }) +} + /// The `.ai` whose editing copy is `private` (as [`layered_ai`] takes it) from its layers alone, /// without its PDF part, whose pages (each its trim box in points, width and height) are `pages` /// and are in units of `unit` points (its first page's `UserUnit`): the document and the notes of @@ -201,20 +224,27 @@ pub fn layered_ai_from( pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64, names: ai::NameEncoding) -> Result<(Document, Vec), AiLayers> { let data = match ai::decode_private(private) { Ok(data) => data, - Err(why) => return Err(AiLayers { data: Err(why), structure: None }), + Err(why) => return Err(AiLayers { data: Err(why), structure: None, alone: None }), }; if !has_layers(&data) { - return Err(AiLayers { data: Ok(data), structure: None }); + return Err(AiLayers { data: Ok(data), structure: None, alone: None }); } // An image past the hard limits is read as an unread box, which the PDF part's art replaces. let structure = match ai::read_over_page(&data, false, names) { Ok(structure) => structure, - Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }), + Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)), alone: None }), }; + // An envelope of type opens live only where the layers with it draw as the page, as the other + // live envelopes do: the PDF part is imported to compare them with. Should none open live, the + // layers open alone as they would have, the envelopes keeping their drawn art ([`layered_ai_from`]). + if structure.warped_type.has_envelopes() && alone_scale(&structure, &data, pages, unit).is_some() { + let alone = Some((pages.to_vec(), unit)); + return Err(AiLayers { data: Ok(data), structure: Some(Ok(Box::new(structure))), alone }); + } match build_without_page(structure, &data, pages, unit) { Ok(Ok((doc, notes))) => Ok((doc, notes)), - Ok(_) => Err(AiLayers { data: Ok(data), structure: None }), - Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)) }), + Ok(_) => Err(AiLayers { data: Ok(data), structure: None, alone: None }), + Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)), alone: None }), } } @@ -226,12 +256,15 @@ pub struct AiLayers { /// Its layers read with [`ai::read`] as [`layered_ai`] reads them, or why they can't be; `None` /// when they are still to be read. structure: Option, String>>, + /// The pages and their unit the layers open on without the PDF part, when they wait on it only + /// for their envelopes of type ([`ai_layers_alone`]). + alone: Option<(Vec<(f64, f64)>, f64)>, } impl AiLayers { /// The editing copy `private` (as [`layered_ai`] takes it), decoded. pub fn decode(private: &[u8]) -> Self { - Self { data: ai::decode_private(private), structure: None } + Self { data: ai::decode_private(private), structure: None, alone: None } } } diff --git a/crates/eps/src/import/preview.rs b/crates/eps/src/import/preview.rs index 9a3f5b751..d4b4efb13 100644 --- a/crates/eps/src/import/preview.rs +++ b/crates/eps/src/import/preview.rs @@ -28,7 +28,14 @@ pub(super) fn preview(bytes: &[u8], ps: &[u8], frame: Rect) -> Option { epsi(ps).and_then(|img| image_doc(img, frame)).map(|document| Preview { document, what: IMAGE }) } +/// Most pixels a TIFF preview may have: its RGBA copy is four bytes each besides the decoded image. +const MAX_PREVIEW_PIXELS: u64 = 1 << 26; + fn tiff_rgba(tiff: &[u8]) -> Option { + let size = image::ImageReader::with_format(std::io::Cursor::new(tiff), image::ImageFormat::Tiff).into_dimensions(); + if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > MAX_PREVIEW_PIXELS) { + return None; + } match image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff) { Ok(img) => Some(img.to_rgba8()), Err(_) => crate::tiff::palette_rgba(tiff), diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index f4edc0ac8..6a1df810b 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1588,3 +1588,40 @@ fn abutting_blocks_of_distinct_colours_stay_apart() { let gaps: String = (0..10).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 3)).collect(); assert_eq!(objects(&read(&gaps).document).len(), 10); } + +/// A string a program scans again and again (`token`) makes a fresh copy each time, and the copies +/// count against the memory budget. +#[test] +fn rescanned_strings_are_charged() { + let body = "/s 1048576 string def s 0 40 put s 1048575 41 put\n1100 { s token pop pop pop } repeat\n"; + let e = import(&eps(body)).unwrap_err(); + assert!(e.contains("too much memory"), "{e}"); + // A few scans stay far within it. + let r = import(&eps("/s 1048576 string def s 0 40 put s 1048575 41 put\n20 { s token pop pop pop } repeat\n")).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + +/// An image names no more data sources than a color space has components: thousands of references +/// to one string are refused before any is copied. +#[test] +fn an_image_with_thousands_of_data_sources_is_refused() { + let sources = "<80> ".repeat(2048); + let e = import(&eps(&format!( + "/DeviceCMYK setcolorspace << /ImageType 1 /Width 1 /Height 1 /BitsPerComponent 8 \ + /MultipleDataSources true /ImageMatrix [1 0 0 1 0 0] /DataSource [{sources}] >> image" + ))) + .unwrap_err(); + assert!(e.contains("rangecheck") && e.contains("DataSource"), "{e}"); +} + +/// A filter over the program's own file that fails to decode is spent: a program that catches the +/// error and reads again gets the end of the file, not the same decode over again. +#[test] +fn a_failed_filter_decode_is_not_tried_again() { + let body = "/f currentfile /FlateDecode filter def\n/n 0 def 3 { { f read { pop } if } stopped { /n n 1 add def } if } repeat\n\ + n 1 eq { 0 0 10 10 rectfill } if\n"; + let r = import(&eps(body)).unwrap(); + let mut paths = 0; + r.document.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(paths, 1, "{:?}", r.warnings); +} diff --git a/crates/eps/src/import/tests_images.rs b/crates/eps/src/import/tests_images.rs index 8aad47fc7..193b81a23 100644 --- a/crates/eps/src/import/tests_images.rs +++ b/crates/eps/src/import/tests_images.rs @@ -548,3 +548,21 @@ fn the_data_of_an_image_too_large_to_read_is_skipped() { assert_eq!(super::tests::fill(&o[0]), Some(&vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))), "{program}"); } } + +/// A JPEG whose header claims far more pixels than an image may have is refused before the decoder +/// allocates for them. +#[test] +fn a_jpeg_with_a_huge_header_is_refused_before_it_is_decoded() { + let mut jpeg = vec![]; + image::codecs::jpeg::JpegEncoder::new(&mut jpeg).encode(&[128; 64], 8, 8, image::ExtendedColorType::L8).unwrap(); + // The frame header (marker, length, precision, height, width) now says 65535 × 65535. + let sof = jpeg.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap(); + jpeg[sof + 5..sof + 9].fill(0xFF); + let jpeg: String = jpeg.iter().map(|b| format!("{b:02x}")).collect(); + let e = import(&eps(&format!( + "/DeviceGray setcolorspace << /ImageType 1 /Width 1 /Height 1 /BitsPerComponent 8 /ImageMatrix [1 0 0 1 0 0] \ + /DataSource currentfile /ASCIIHexDecode filter /DCTDecode filter >> image\n{jpeg}>" + ))) + .unwrap_err(); + assert!(e.contains("too large"), "{e}"); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 13f979f9f..45a7a92c8 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -77,6 +77,34 @@ fn names(d: &Document) -> Vec { d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() } +/// The ruler guides of `d` by their line, artboard and layer. +fn guide_lines(d: &Document) -> Vec { + d.ruler_guides() + .into_iter() + .map(|g| vectorcraft_doc::Guide { artboard: g.artboard, layer: g.layer, ..vectorcraft_doc::Guide::new(g.vertical, g.pos) }) + .collect() +} + +#[test] +fn ruler_units_in_the_program_header_alone_are_kept() { + let art = layer("Art", true, &square(10, 10)); + let native = |units: &str| { + format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{units}%AI5_NumLayers: 1\n{art}%%Trailer\n" + ) + }; + let with_header = |data: Vec| { + let at = data.windows(13).position(|w| w == b"%%EndComments").unwrap(); + [&data[..at], &b"%AI5_RulerUnits: 0\n"[..], &data[at..]].concat() + }; + let r = import(&with_header(eps(&page_square(10, 10), &native("")))).unwrap(); + assert_eq!((r.document.units, r.document.units_from_file), (Unit::Inches, true), "the program's header gives them"); + let r = import(&with_header(eps(&page_square(10, 10), &native("%AI5_RulerUnits: 1\n")))).unwrap(); + assert_eq!(r.document.units, Unit::Millimeters, "the editing data's own come first"); + let r = import(&eps(&page_square(10, 10), &native(""))).unwrap(); + assert_eq!((r.document.units, r.document.units_from_file), (Unit::Points, false), "none recorded"); +} + #[test] fn native_import_keeps_ruler_units_from_editing_data() { let art = layer("Art", true, &square(10, 10)); @@ -2199,11 +2227,11 @@ fn an_ai_files_ruler_guides_come_in_on_their_layers() { assert!(read, "{w:?}"); let jig = d.layers[0].id; let want = [ - vectorcraft_doc::Guide { vertical: false, pos: 60.0 * k, artboard: None, layer: Some(jig) }, - vectorcraft_doc::Guide { vertical: true, pos: 20.0 * k, artboard: Some(d.artboards[0].id), layer: Some(jig) }, + vectorcraft_doc::Guide { layer: Some(jig), ..vectorcraft_doc::Guide::new(false, 60.0 * k) }, + vectorcraft_doc::Guide { artboard: Some(d.artboards[0].id), layer: Some(jig), ..vectorcraft_doc::Guide::new(true, 20.0 * k) }, ]; - assert_eq!(d.guides, want); - assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the square alone"); + assert_eq!(guide_lines(&d), want); + assert_eq!(d.layers[0].children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count()), 1, "the square alone"); } } @@ -2221,7 +2249,10 @@ fn an_eps_of_its_art_box_keeps_the_guides_of_its_first_artboard() { assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(r.document.artboards.len(), 1); let (board, layer) = (r.document.artboards[0].id, r.document.layers[0].id); - assert_eq!(r.document.guides, [vectorcraft_doc::Guide { vertical: true, pos: 25.0, artboard: Some(board), layer: Some(layer) }]); + assert_eq!( + guide_lines(&r.document), + [vectorcraft_doc::Guide { artboard: Some(board), layer: Some(layer), ..vectorcraft_doc::Guide::new(true, 25.0) }] + ); } /// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is @@ -3291,6 +3322,50 @@ fn a_standard_baseline_run_keeps_standard_vertical_roman_alignment() { } } +#[test] +fn stroked_type_keeps_its_cap_join_and_miter_limit() { + // GUARDS: type strokes came in with a miter join at a limit of 10 whatever the file said, so + // type the file strokes with round or bevel joins, or a lower limit, drew spikes at its corners. + // Four runs over "abcdefgh": "ab" stroked with round caps and joins at a miter limit of 4, "cd" + // with projecting caps and bevel joins at 2.5, "ef" stroked without the keys (the defaults), and + // "gh" with the keys but no stroke. + use vectorcraft_doc::{LineCap, LineJoin}; + let doc = text_document(&[("abcdefgh\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let run = |n: u32, own: &str| { + format!( + "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own}/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >>" + ) + }; + let one = run(9, ""); + assert!(doc.contains(&one)); + let stroke = "/54 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> /57 true /63 3.0 "; + let runs = [ + run(2, &format!("{stroke}/61 1 /62 1 /64 4.0 ")), + run(2, &format!("{stroke}/61 2 /62 2 /64 2.5 ")), + run(2, stroke), + run(3, "/61 1 /62 1 /64 4.0 "), + ]; + let doc = doc.replacen(&one, &runs.join(" "), 1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let strokes: Vec<(String, f64, LineCap, LineJoin, f64)> = texts[0] + .runs + .iter() + .map(|r| (r.text.clone(), r.style.stroke_width, r.style.stroke_cap, r.style.stroke_join, r.style.stroke_miter_limit)) + .collect(); + assert_eq!( + strokes, + [ + ("ab".to_string(), 3.0, LineCap::Round, LineJoin::Round, 4.0), + ("cd".to_string(), 3.0, LineCap::Square, LineJoin::Bevel, 2.5), + ("ef".to_string(), 3.0, LineCap::Butt, LineJoin::Miter, 10.0), + ("gh".to_string(), 0.0, LineCap::Butt, LineJoin::Miter, 10.0), + ] + ); +} + #[test] fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { @@ -5222,9 +5297,709 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance() } }); assert_eq!(texts, [("CHONKYCATS".to_string(), false)], "{:?}", r.warnings); + // The notes say the type isn't editable. + assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); +} + +/// A family that is installed, for type that must draw as itself; `None` (the test is skipped) +/// when no font is available. +fn installed_family() -> Option { + let db = vectorcraft_text::FontDb::global(); + let plain = |f: &String| !f.is_empty() && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' '); + ["DejaVu Sans", "Liberation Sans", "Noto Sans", "Source Sans 3", "Arial", "Helvetica"] + .iter() + .map(|f| f.to_string()) + .find(|f| db.has_family(f)) + .or_else(|| db.families().into_iter().find(|f| plain(f) && db.has_family(f))) +} + +/// The art space of the 100 × 100 page of [`native_with_text`] has y up. +fn to_art(p: kurbo::Point) -> (f64, f64) { + (p.x, 100.0 - p.y) +} + +/// What the live envelope `envelope` (on the page axes) draws, as VectorCraft draws it: the +/// format's paths (each subpath filled on its own, in the color its type is drawn in) and the +/// page's PostScript for them. +fn drawn_envelope(envelope: &Node) -> (String, String) { + fn walk(n: &Node, color: [f32; 3], art: &mut String, page: &mut String) { + let [r, g, b] = n.appearance.fill().and_then(|f| f.paint.color()).map_or(color, |c| c.to_rgb()); + let NodeKind::Path { path, .. } = &n.kind else { + n.children().into_iter().flatten().for_each(|c| walk(c, [r, g, b], art, page)); + return; + }; + let pt = |p: kurbo::Point| { + let (x, y) = to_art(p); + format!("{x} {y}") + }; + for el in path.to_bezpath().elements() { + match *el { + kurbo::PathEl::MoveTo(p) => { + *art += &format!("0 0 0 0 {r} {g} {b} Xa\n{} m\n", pt(p)); + *page += &format!("{r} {g} {b} setrgbcolor newpath {} moveto ", pt(p)); + } + kurbo::PathEl::LineTo(p) => { + *art += &format!("{} L\n", pt(p)); + *page += &format!("{} lineto ", pt(p)); + } + kurbo::PathEl::CurveTo(a, c, p) => { + *art += &format!("{} {} {} C\n", pt(a), pt(c), pt(p)); + *page += &format!("{} {} {} curveto ", pt(a), pt(c), pt(p)); + } + kurbo::PathEl::QuadTo(..) => {} + kurbo::PathEl::ClosePath => { + *art += "f\n"; + *page += "closepath fill\n"; + } + } + } + } + let (mut art, mut page) = (String::new(), String::new()); + walk(&vectorcraft_effects::expand_live_deep(None, envelope), [0.0; 3], &mut art, &mut page); + (art, page) +} + +/// An arc warp of `bend` (a fraction) in the editing data's keys, with the Envelope Options new +/// envelopes have and Fidelity 50. +fn arc_keys(bend: f64) -> String { + format!( + "131073 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n1 /Int (DeformStyle) ,\n0 /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n0 /Real (DeformVert) ,\n0 /Bool (Rotate) ,\n" + ) +} + +/// An envelope made with a warp (`keys`) around the type of story 0, as the editing data writes +/// it: on `%_` lines the group with its mesh, its dictionary with the text object in its contents, +/// then its drawn art `drawn` on plain lines and the plugin's `XP`. +fn type_envelope(keys: &str, drawn: &str) -> String { + type_envelope_with(keys, drawn, "") +} + +/// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in +/// its contents, such as the text object's XML id. +fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + let mesh = format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 40.0, 20.0, 50.0, 30.0, 40.0), + corner(20.0, 70.0, 30.0, 70.0, 20.0, 60.0), + corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0), + corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0), + ); + type_envelope_on(keys, &mesh, drawn, after) +} + +/// [`type_envelope_with`], with `shape` (on plain lines, written on `%_` ones) in the group before +/// its dictionary, where the editing data saves the envelope's mesh. +fn type_envelope_on(keys: &str, shape: &str, drawn: &str, after: &str) -> String { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + format!( + "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{shape}U\n")), + hidden(&format!("/ArtDictionary :\n{keys}")), + hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))), + ) +} + +/// The text document of the type in [`type_envelope`]: "FLUX" set in `family`, drawn in `paint` (a +/// text paint, the default's when `None`). +fn type_envelope_text(family: &str, paint: Option<&str>) -> String { + let doc = text_document(&[("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0)]).replace("(Helvetica)", &format!("({family})")); + let mut doc = match paint { + Some(p) => doc.replace("/0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >>", p), + None => doc, + }; + // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split. + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + doc +} + +/// The live envelope an arc warp of `bend` (in percent) makes of `text`, as the reader makes it. +fn arc_envelope(text: &Node, bend: f64) -> Node { + let mut text = text.clone(); + text.visible = true; + let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true }; + Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(text)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: vectorcraft_doc::live::EnvelopeOptions::NEW, + frame: Box::default(), + }, + ) +} + +/// The type of the file with an envelope of type whose drawn art is `drawn`, read with its text +/// document `doc`: the hidden text object it keeps, as the reader makes it. +fn made_envelope_type(doc: &str) -> Node { + let art = native_with_text(&layer("Art", true, &type_envelope(&arc_keys(0.3), &format!("{}\n", square(10, 10)))), doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let mut text = None; + r.document.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text = Some(n.clone()); + } + }); + text.unwrap_or_else(|| panic!("no type: {:?}", r.warnings)) +} + +/// The envelopes and the type, shown or not, in the layers of `d`: each envelope's contents, and +/// the plain text of the type outside envelopes with whether it shows. +fn envelopes_and_type(d: &Document) -> (Vec>, Vec<(String, bool)>) { + let (mut envelopes, mut loose) = (vec![], vec![]); + fn walk(n: &Node, envelopes: &mut Vec>, loose: &mut Vec<(String, bool)>) { + match &n.kind { + NodeKind::Envelope { content, .. } => envelopes.push( + content + .iter() + .map(|c| { + if let NodeKind::Text(t) = &c.kind { + format!("{}{}", t.plain_text(), if c.visible { "" } else { " (hidden)" }) + } else { + "art".into() + } + }) + .collect(), + ), + NodeKind::Text(t) => loose.push((t.plain_text(), n.visible)), + _ => n.children().into_iter().flatten().for_each(|c| walk(c, envelopes, loose)), + } + } + d.layers.iter().for_each(|l| walk(l, &mut envelopes, &mut loose)); + (envelopes, loose) +} + +/// An envelope of type whose live envelope, its type laid out from the text document, draws as its +/// drawn art opens live: the type is its contents, shown and editable, and the hidden copy of the +/// type goes (#3179). +#[test] +fn an_envelope_of_type_that_draws_as_its_drawn_art_opens_live() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); } +/// An envelope of type whose live envelope draws apart from its drawn art (the file's art bends the +/// other way) keeps its drawn art and its type, hidden, with a note. +#[test] +fn an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), -30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); +} + +/// White type in an envelope that draws apart from its drawn art keeps the drawn art too: the +/// envelope and its drawn art are each drawn on their own, where white shows only on black. +#[test] +fn an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, Some("/0 << /0 1 /1 [ 1.0 1.0 1.0 1.0 ] >>")); + let text = made_envelope_type(&doc); + assert_eq!(text_fill(&text), Some(vectorcraft_color::Color::rgb(1.0, 1.0, 1.0))); + let (drawn, page) = drawn_envelope(&arc_envelope(&text, -30.0)); + // A dark square under it, so the page shows the white type. + let ground = "0 0 0 1 k\n0 0 m\n100 0 L\n100 100 L\n0 100 L\nf\n"; + let art = native_with_text(&layer("Art", true, &format!("{ground}{}", type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("0 0 0 1 setcmykcolor 0 0 moveto 100 0 lineto 100 100 lineto 0 100 lineto closepath fill\n{page}"), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); +} + +/// A `.ai` whose layers would open alone, without its PDF part, waits on the PDF part when an +/// envelope of type is in them, as for the other live envelopes: one opens live only where the +/// layers with it draw as the page. Where they don't, they open alone as before, the envelope +/// keeping its drawn art and its type, hidden, with the note, even though it would draw as that +/// drawn art; where they do, it opens live (#3179). +#[test] +fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() { + let Some(family) = installed_family() else { return }; + let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)]; + let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})")); + // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split. + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn)); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + // A PDF part the layers don't draw as. + let (d, notes, read) = crate::layered_ai_from(layers, pdf_part(80.0, 80.0), vec![], false, false, 1.0, names); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("Note".to_string(), true), ("FLUX".to_string(), false)], "{notes:?}"); + assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); + // A PDF part that draws as those layers, with the drawn art: the envelope opens live. + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + let (d, notes, read) = crate::layered_ai_from(layers, d, vec![], false, false, 1.0, names); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}"); + assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}"); +} + +/// The hidden type outside envelopes in the layers of `d`: each text object's name. +fn loose_type_names(d: &Document) -> Vec> { + let mut out = vec![]; + fn walk(n: &Node, out: &mut Vec>) { + match &n.kind { + NodeKind::Envelope { .. } => {} + NodeKind::Text(_) => out.push(n.name.clone()), + _ => n.children().into_iter().flatten().for_each(|c| walk(c, out)), + } + } + d.layers.iter().for_each(|l| walk(l, &mut out)); + out +} + +/// An envelope of type that draws as its drawn art doesn't open live where the page draws other art +/// there (the warp bent the other way): the page check weighs the live envelope as the art it draws, +/// not as type laid out again, so it sees the two differ. The file comes in as its page, as it does +/// with the drawn art (#3179). +#[test] +fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let text = made_envelope_type(&doc); + let (drawn, _) = drawn_envelope(&arc_envelope(&text, 30.0)); + let (_, page) = drawn_envelope(&arc_envelope(&text, -30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + let (envelopes, _) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?} {:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.contains("layers weren't read") && n.contains("differs from the page's")), "{:?}", r.warnings); +} + +/// Layers used though they draw differently from the page (the editing data trusted) leave an +/// envelope of type its drawn art and its type, hidden, with the note, even where it draws as that +/// drawn art: one opens live only where the layers with it draw as the page. A `.ai` whose layers +/// would open alone then opens alone, as it would without the envelope (#3179). +#[test] +fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + // The page has the square only. + let r = crate::import_trusting(&eps(&page_square(10, 10), &art), true, true).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + // The type put back is named as the file names it. + assert_eq!(loose_type_names(&r.document), [None]); + assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings); + // A `.ai` whose layers would open alone, its PDF part drawn apart from them. + let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)]; + let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})")); + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn)); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + let (d, notes, read) = crate::layered_ai_from(layers, pdf_part(80.0, 80.0), vec![], false, true, 1.0, names); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("Note".to_string(), true), ("FLUX".to_string(), false)], "{notes:?}"); + assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); + assert!(!notes.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{notes:?}"); +} + +/// Layers whose objects that draw differently from the page come in as its art leave an envelope +/// of type its drawn art and its type, hidden, with the note, even where the envelope draws as the +/// page: the layers with it don't (#3179). +#[test] +fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + // A square black in the layers and cyan on the page, away from the envelope. + let body = format!("{}\n{}\n{}", square(10, 10), square_16(76, BLACK), type_envelope(&arc_keys(0.3), &drawn)); + let art = native_with_text(&layer("Art", true, &body), &doc); + let cyan = "1 0 0 0 setcmykcolor 76 10 moveto 92 10 lineto 92 26 lineto 76 26 lineto closepath fill"; + let r = import(&eps(&format!("{}\n{cyan}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert!( + r.warnings.iter().any(|n| n.starts_with("objects that drew differently from the file's own page came in as its art")), + "{:?}", + r.warnings + ); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + assert_eq!(loose_type_names(&r.document), [None]); + assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); +} + +/// An envelope of type whose type a visibility variable binds keeps its drawn art, as one whose type +/// a text variable binds does: the binding stays the hidden type's, which the live envelope would +/// take the place of (#3179). +#[test] +fn an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art() { + use vectorcraft_testkit::ai::{XmlValue, named, variable_library_data, xml_element, xml_node}; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let reference = "/XMLUIDREF : (type_x5F_1) ;"; + let attributes = [("trait", XmlValue::Str("visibility")), ("varName", XmlValue::Unicode("v_shown")), ("docRef", XmlValue::Raw(reference))]; + let declared = [xml_node(1, "variable", XmlValue::Str(""), &attributes, &[])]; + let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &declared)]); + let library = variable_library_data(&xml_element("variableSets", &[], &[set])); + // The text object's XML id, on plain lines as `type_envelope_with` takes it. + let id = named("type_x5F_1").replace("%_", ""); + let envelope = type_envelope_with(&arc_keys(0.3), &drawn, &id); + let art = native_with_text(&(library + &layer("Art", true, &format!("{}\n{envelope}", square(10, 10)))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + let mut text = vec![]; + r.document.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text.push(n.id); + } + }); + assert_eq!(r.document.variables.objects_of("v_shown"), text, "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|n| n.contains("variable bindings")), "{:?}", r.warnings); +} + +/// A one-patch warp grid as the editing data saves it, which no warp style draws: its top edge +/// rises to the right and bulges up. In art space, corners (20, 40) (20, 73) (80, 61) (80, 40), each +/// with its handles towards the next corner and the previous one. +fn rising_grid() -> String { + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 40.0, 20.0, 51.0, 40.0, 40.0), + corner(20.0, 73.0, 40.0, 80.0, 20.0, 62.0), + corner(80.0, 61.0, 80.0, 54.0, 60.0, 72.0), + corner(80.0, 40.0, 60.0, 40.0, 80.0, 47.0), + ) +} + +/// [`rising_grid`] on the page axes as a mesh envelope reads it: its points from the top left, row +/// by row, and their handles (towards the next column, the previous one, the next row and the +/// previous one; none out of the grid). +fn rising_kind() -> vectorcraft_doc::live::EnvelopeKind { + use kurbo::{Point, Vec2}; + let z = Vec2::ZERO; + vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(20.0, 27.0), Point::new(80.0, 39.0), Point::new(20.0, 60.0), Point::new(80.0, 60.0)], + handles: vec![ + [Vec2::new(20.0, -7.0), z, Vec2::new(0.0, 11.0), z], + [z, Vec2::new(-20.0, -11.0), Vec2::new(0.0, 7.0), z], + [Vec2::new(20.0, 0.0), z, z, Vec2::new(0.0, -11.0)], + [z, Vec2::new(-20.0, 0.0), z, Vec2::new(0.0, -7.0)], + ], + } +} + +/// The anchors of the paths `n` draws once its live art is expanded. +fn drawn_anchors(n: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Path { path, .. } => out.extend(path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p))), + _ => n.children().into_iter().flatten().for_each(|c| walk(c, out)), + } + } + let mut out = vec![]; + walk(&vectorcraft_effects::expand_live_deep(None, n), &mut out); + out +} + +/// A warp of type whose own warp draws apart from its drawn art, where the grid the file saves for +/// it draws as that art, opens as a mesh envelope on that grid, its points and handles as saved: +/// the type is editable, and new text fills the same shape (#3179). +#[test] +fn a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = |text: Node| { + let content = vec![std::sync::Arc::new(text)]; + let kind = Box::new(rising_kind()); + Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind, fidelity: 50.0, editing: false, options, frame: Box::default() }, + ) + }; + let (drawn, page) = drawn_envelope(&shaped(text)); + let art = + native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); + let mut live = None; + r.document.walk(|n| { + if matches!(n.kind, NodeKind::Envelope { .. }) { + live = Some(n.clone()); + } + }); + let mut live = live.unwrap(); + let NodeKind::Envelope { kind, content, .. } = &mut live.kind else { panic!("not an envelope: {live:?}") }; + let (EnvelopeKind::Mesh { rows, cols, points, handles }, EnvelopeKind::Mesh { points: want, handles: want_h, .. }) = (&**kind, rising_kind()) + else { + panic!("not a mesh envelope: {kind:?}") + }; + assert_eq!((*rows, *cols), (1, 1)); + assert!(points.iter().zip(&want).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{points:?}"); + assert!(handles.iter().flatten().zip(want_h.iter().flatten()).all(|(h, g)| (*h - *g).hypot() < 1e-6), "{handles:?}"); + // New text in the envelope fills the grid as the old did: "HIH", whose corners are its glyphs', + // reaches each corner of the grid. + let NodeKind::Text(t) = &mut std::sync::Arc::make_mut(&mut content[0]).kind else { panic!("type") }; + let style = t.runs[0].style.clone(); + t.runs = vec![vectorcraft_doc::TextRun::new("HIH", style)]; + (t.cached_bounds, t.cached_ink) = (None, None); + t.cached_baselines.clear(); + let anchors = drawn_anchors(&live); + for corner in want { + let near = anchors.iter().map(|a| (*a - corner).hypot()).fold(f64::INFINITY, f64::min); + assert!(near < 0.25, "{corner:?} is {near} from the new text"); + } +} + +/// A warp of stroked type with Distort Appearance whose drawn art maps the type's bounds with its +/// strokes onto the grid the file saves (not its glyphs' bounds alone) opens live as a mesh envelope +/// mapping from that box (#3179). +#[test] +fn a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does() { + use vectorcraft_doc::live::{envelope_content_boxes, envelope_stroke_margin}; + let Some(family) = installed_family() else { return }; + // "FLUX" stroked 4 points wide in black. + let stroked = "/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> /54 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> /57 true /63 4.0"; + let doc = type_envelope_text(&family, None).replace("/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >>", stroked); + let mut text = made_envelope_type(&doc); + text.visible = true; + assert!(matches!(&text.kind, NodeKind::Text(t) if t.runs[0].style.stroke_width == 4.0), "{text:?}"); + let content = vec![std::sync::Arc::new(text)]; + let (with, without) = envelope_content_boxes(&content, kurbo::Affine::IDENTITY).unwrap(); + let mut options = vectorcraft_doc::live::EnvelopeOptions::NEW; + options.bounds_margin = envelope_stroke_margin(with, without).unwrap(); + assert!(options.bounds_margin.iter().all(|m| *m > 0.0), "{options:?}"); + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind: Box::new(rising_kind()), fidelity: 50.0, editing: false, options, frame: Box::default() }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = + native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + let mut margin = None; + r.document.walk(|n| { + if let NodeKind::Envelope { options, .. } = &n.kind { + margin = Some(options.bounds_margin); + } + }); + assert_eq!(margin, Some(options.bounds_margin)); +} + +/// A warp of type whose editing data saves no grid it can be shaped by keeps its drawn art and its +/// type, hidden, with the note, when its own warp draws apart from that art (#3179). +#[test] +fn a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + let content = vec![std::sync::Arc::new(text)]; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind: Box::new(rising_kind()), fidelity: 50.0, editing: false, options, frame: Box::default() }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), "", &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); +} + +/// [`type_envelope_on`] while its contents are edited: the text object in the group before its +/// dictionary, and `shape` in its contents' block, where the editing data then saves the mesh. +fn type_envelope_editing(keys: &str, shape: &str, drawn: &str) -> String { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + format!( + "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{}U\n", text_object(0))), + hidden(&format!("/ArtDictionary :\n{}", keys.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"))), + hidden(&format!("0 g\n{shape}")), + ) +} + +/// While the contents of an envelope of type are edited, its grid is kept in its dictionary, and in +/// some files it lies away from the drawn art by a move, the type with the drawn art. A warp of type +/// whose saved grid draws the type as large as its drawn art but elsewhere opens live as a mesh +/// envelope on that grid moved onto the drawn art (#3179). +#[test] +fn a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + // The drawn art is the grid's moved 10 points left and 25 down. + let by = kurbo::Vec2::new(-10.0, 25.0); + let EnvelopeKind::Mesh { rows, cols, points, handles } = rising_kind() else { panic!("a mesh") }; + let want: Vec = points.iter().map(|p| *p + by).collect(); + let kind = EnvelopeKind::Mesh { rows, cols, points: want.clone(), handles }; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(text)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options, + frame: Box::default(), + }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = native_with_text( + &layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_editing(&arc_keys(0.3), &rising_grid(), &drawn))), + &doc, + ); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); + let mut live = None; + r.document.walk(|n| { + if let NodeKind::Envelope { kind, editing, .. } = &n.kind { + live = Some(((**kind).clone(), *editing)); + } + }); + let Some((EnvelopeKind::Mesh { points, .. }, true)) = live else { panic!("not a mesh envelope in Edit Contents: {live:?}") }; + assert!(points.iter().zip(&want).all(|(p, q)| (*p - *q).hypot() < 0.05), "{points:?}"); +} + +/// A warp of type tried again on its saved grid leaves a check for each envelope of type still to +/// come: with one check too few for both, the next envelope, whose first try opens it, gets it. +#[test] +fn a_warp_of_type_tried_again_leaves_the_next_envelope_its_check() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let story = ("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0); + let mut doc = text_document(&[story, story]).replace("(Helvetica)", &format!("({family})")); + // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split. + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let text = made_envelope_type(&doc); + // The first envelope, of story 0, draws as its saved grid and not as its warp: two checks. + let mut shown = text.clone(); + shown.visible = true; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(shown)], + kind: Box::new(rising_kind()), + fidelity: 50.0, + editing: false, + options, + frame: Box::default(), + }, + ); + let (drawn_grid, page_grid) = drawn_envelope(&shaped); + let grid = type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn_grid, ""); + // The second, of story 1, draws as its warp: one check. + let (drawn_arc, page_arc) = drawn_envelope(&arc_envelope(&text, 30.0)); + let arc = type_envelope(&arc_keys(0.3), &drawn_arc).replace("%_0 /StoryIndex", "%_1 /StoryIndex"); + // Envelopes of art before them use all but `left` of the import's checks. + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + let rect = "20 5 m\n20 95 L\n80 95 L\n80 5 L\n20 5 L\n"; + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + let mesh = format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 5.0, 20.0, 35.0, 40.0, 5.0), + corner(20.0, 95.0, 40.0, 95.0, 20.0, 65.0), + corner(80.0, 95.0, 80.0, 65.0, 60.0, 95.0), + corner(80.0, 5.0, 60.0, 5.0, 80.0, 35.0), + ); + let red = "0 1 1 0 1 0 0 Xa\n"; + let filler = format!( + "{}%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n%_0 /Bool (AIDeformEditModeKey) ,\n%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\nu\n{red}{rect}f\nU\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("u\n{red}{rect}f\nU\n")), + ); + let page = + format!("{}\n1 0 0 setrgbcolor 20 5 moveto 20 95 lineto 80 95 lineto 80 5 lineto closepath fill\n{page_grid}{page_arc}", page_square(10, 10)); + let file = |left: usize| { + let body = format!("{}\n{}{grid}{arc}", square(10, 10), filler.repeat(crate::import::ai::MAX_CHECKED - left)); + eps(&page, &native_with_text(&layer("Art", true, &body), &doc)) + }; + // The live envelopes of type, by kind. + let typed = |d: &Document| { + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Envelope { content, kind, .. } = &n.kind + && content.iter().any(|c| matches!(c.kind, NodeKind::Text(_))) + { + out.push(if matches!(**kind, EnvelopeKind::Mesh { .. }) { "mesh" } else { "warp" }); + } + }); + out + }; + let r = import(&file(3)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert_eq!(typed(&r.document), ["mesh", "warp"], "{:?}", r.warnings); + let r = import(&file(2)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert_eq!(typed(&r.document), ["warp"], "{:?}", r.warnings); +} + +/// The fill of the first run of type `n`. +fn text_fill(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + t.runs.first().and_then(|r| r.style.fill.color()) +} + /// A live envelope that draws a little apart from its drawn art, in layers that already differ /// from the page almost as much as the page check takes, would make them differ too much: its /// drawn art is put back, so the file still opens from its layers. diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index c5c1c4c26..6b553b8d1 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,8 +65,8 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, - NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, Node, NodeKind, ParaDirection, + ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index c3ca85c08..979cd2a43 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -940,6 +940,35 @@ fn names_outside_windows_1252_are_replaced_and_kept_apart() { assert_eq!(inks(&read), want); } +/// #6502: the legacy writer gives Yellow, Magenta and Cyan (presets 4, 5 and 6) the file's +/// indexes 6, 4 and 5, and they open as the same presets. +#[test] +fn layer_presets_are_written_with_the_files_colour_index() { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + for i in 3..8u8 { + let id = doc.alloc_id(); + doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), vectorcraft_doc::LayerColor::Preset(i)))); + } + let out = write(&doc); + let text = String::from_utf8_lossy(&out.bytes).replace("\r\n", "\n"); + for (i, index) in [(3, 3), (4, 6), (5, 4), (6, 5), (7, 7)] { + let [r, g, b] = vectorcraft_doc::LAYER_COLORS[i].1; + let want = format!(" 0 {index} {r} {g} {b} Lb\n(P{i}) Ln\n"); + assert!(text.contains(&want), "missing {want:?} in\n{text}"); + } + let read = crate::import(&out.bytes).unwrap().document; + let colors: Vec<_> = read + .layers + .iter() + .filter_map(|l| match l.kind { + NodeKind::Layer { color, .. } => Some(color), + _ => None, + }) + .collect(); + assert_eq!(colors, (3..8u8).map(vectorcraft_doc::LayerColor::Preset).collect::>()); +} + #[test] fn losses_are_warned() { let mut doc = sample(); diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index cf601e939..dc5128565 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -5,9 +5,9 @@ use super::encoding::{ use super::{ Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, - LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, - ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, - TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, + LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData, + ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, + TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; pub(super) struct Writer<'a> { @@ -135,14 +135,7 @@ impl<'a> Writer<'a> { /// A top-level layer. False when it isn't a layer. pub(super) fn layer(&mut self, n: &Node) -> bool { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); let flag = |b: bool| u8::from(b); let name = self.layer_name(n.name.as_deref().unwrap_or("Layer")); // A template layer doesn't print and shows dimmed. @@ -159,7 +152,9 @@ impl<'a> Writer<'a> { self.locked = false; self.line("0 A"); self.in_hidden = !n.visible; - self.contents(children, *clip, false); + // Ruler guides are kept in the document's native copy only. + let art: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + self.contents(&art, *clip, false); self.in_hidden = false; self.line("LB\n%AI5_EndLayer--"); true @@ -173,14 +168,7 @@ impl<'a> Writer<'a> { } let art = std::mem::take(&mut self.hidden_art); let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer")); let name = self.layer_name(&name); let _ = writeln!( @@ -223,6 +211,10 @@ impl<'a> Writer<'a> { /// One object (inside a container locked when `locked`). fn node(&mut self, n: &Node, locked: bool) { + // Ruler guides are kept in the document's native copy only. + if n.is_ruler_guide() { + return; + } if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { @@ -306,6 +298,8 @@ impl<'a> Writer<'a> { self.nest -= 1; } } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } } @@ -694,12 +688,9 @@ impl<'a> Writer<'a> { if lost_attributes(t) { self.warn(TYPE_ATTRIBUTES); } - // Area type that Shrink Text to Fit scales down is written in the styles it draws in. - let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. })) - .then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale) - .filter(|f| *f != 1.0) - .map(|f| vectorcraft_text::scaled(t, f)); - let t = fitted.as_ref().unwrap_or(t); + // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in. + let fitted = vectorcraft_text::drawn(t); + let t = &*fitted; let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let (kind, path, start) = match &t.kind { TextKind::Point => (0, None, 0.0), diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 974831b70..8c9b04ff8 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -389,6 +389,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > MAX_BODY { self.body.truncate(was); diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index c208f1b52..863c6fdd3 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -10,7 +10,8 @@ //! is left as it was and the save fails. //! //! On macOS `File::sync_all` is a full flush (`F_FULLFSYNC`), which SMB shares refuse with -//! `ENOTSUP` while a plain `fsync` works: [`sync_file`] falls back to the plain one there (#1620). +//! `ENOTSUP` and virtiofs shares (folders shared into a macOS VM) with `ENOTTY`, while a plain +//! `fsync` works: [`sync_file`] falls back to the plain one there (#1620). use std::fs::{File, OpenOptions}; use std::io::{self, Write as _}; @@ -180,8 +181,9 @@ fn copy_new(from: &Path, path: &Path) -> io::Result<()> { } /// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush -/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares, #1620) this falls back -/// to a plain `fsync`. Any other error, and a failed `fsync`, is returned as it is. +/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares) or `ENOTTY` (virtiofs +/// shares, #1620) this falls back to a plain `fsync`. Any other error, and a failed `fsync`, is +/// returned as it is. pub fn sync_file(file: &File) -> io::Result<()> { match full_sync(file) { Err(e) if full_flush_unsupported(&e) => plain_fsync(file), @@ -190,9 +192,10 @@ pub fn sync_file(file: &File) -> io::Result<()> { } /// Whether `e` is a volume refusing a full flush. Never off Apple platforms: there `sync_all` is -/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` to `Uncategorized`. +/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` and `ENOTTY` to +/// `Uncategorized`. fn full_flush_unsupported(e: &io::Error) -> bool { - cfg!(target_vendor = "apple") && e.raw_os_error() == Some(ENOTSUP) + cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY)) } #[cfg(target_vendor = "apple")] @@ -232,6 +235,9 @@ fn full_sync(file: &File) -> io::Result<()> { const ENOTSUP: i32 = 45; #[cfg(not(target_vendor = "apple"))] const ENOTSUP: i32 = 95; +/// What macOS answers when a virtiofs share (a folder shared into a VM) is asked for a full flush. +/// The same number on every Unix. +const ENOTTY: i32 = 25; #[cfg(test)] thread_local! { @@ -414,6 +420,17 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// A save to a virtiofs share (#1620: a folder shared into a macOS VM): macOS refuses the full + /// flush there with `ENOTTY` (os error 25), which falls back to a plain `fsync` as `ENOTSUP` does. + /// Elsewhere neither is a refused full flush, and other errors never are. + #[test] + fn a_full_flush_refused_with_enotty_falls_back_like_enotsup() { + let apple = cfg!(target_vendor = "apple"); + assert_eq!(full_flush_unsupported(&io::Error::from_raw_os_error(25)), apple, "ENOTTY"); + assert_eq!(full_flush_unsupported(&io::Error::from_raw_os_error(ENOTSUP)), apple, "ENOTSUP"); + assert!(!full_flush_unsupported(&io::Error::other("the connection was lost"))); + } + fn denied() -> io::Result { Err(io::Error::new(io::ErrorKind::PermissionDenied, "refused by the share")) } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5d7aabd07..38545b829 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -239,6 +239,9 @@ struct Body { fn body(doc: &Document, o: &SaveOptions) -> Result { let version = o.version; let mut d = doc.without_edit_modes().into_owned(); + // Ruler guides are saved in the document's `guides` list, as before they were nodes, so every + // version reads them. + d.guides_to_saved(); // Older apps don't know placed documents: they get the art the objects show, with its // resources. Otherwise resources only exporting adds are never saved. if version < VERSION && d.has_placed() { @@ -510,20 +513,35 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result // at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the // PDF are skipped without being kept. - let read = || { - vectorcraft_doc::node::with_node_budget(max_nodes, || { - vectorcraft_doc::node::with_buffered_value_budget(max_values, || Contents::::read(file.json())) + fn within_budgets(max_nodes: usize, max_values: usize, read: impl FnOnce() -> T) -> T { + #[cfg(test)] + DOCUMENT_READS.with(|n| n.set(n.get() + 1)); + vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, || { + vectorcraft_doc::node::with_node_budget(max_nodes, || vectorcraft_doc::node::with_buffered_value_budget(max_values, read)) }) - }; - let mut f = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| match e { + } + let read_error = |e| match e { ReadError::Json(e) => { let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() }; FormatError::NotVectorcraft(why) } ReadError::Envelope(e) => e.into(), e => FormatError::NotVectorcraft(e.to_string()), - })?; - let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + }; + // The document and the saved PDF preference come from one pass over the JSON. + let (mut f, pdf_compatible) = match within_budgets(max_nodes, max_values, || Contents::::read_with_pdf_preference(file.json())) { + Ok(read) => read, + // That pass refuses a `pdfCompatible` that is not a boolean, and any other data error. + // When it stopped at the preference, the file is read again without it, and only then is + // its JSON scanned for the preference a second time, so such a file opens as before. Any + // other error, such as a document past a budget, is returned without a second read. + Err(ReadError::Json(e)) if e.is_data() && refused_preference_at(file.json(), &e) => { + let f = within_budgets(max_nodes, max_values, || Contents::::read(file.json())).map_err(read_error)?; + let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + (f, pdf_compatible) + } + Err(e) => return Err(read_error(e)), + }; let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION, pdf_compatible }; // A version 1 document saved before VectorCraft 0.21.0, or for an app before it (an older file // version): its Twist angles turned the other way. The app that wrote it is read from the JSON @@ -587,6 +605,12 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ))); } depth_notes.extend(beyond.note()); + // Untrusted: ruler guide nodes only directly in layers, above their art (after the depth limit: + // the walk recurses). + doc.tidy_ruler_guides(); + // The saved ruler guides become nodes on their layers (after the guides beyond the canvas are + // dropped, and after `fix_next_id`: new ids start above the file's). + doc.guides_into_nodes(); // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). doc.keep_type_values_in_range(); @@ -615,9 +639,62 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result Ok(NativeFile { doc, depth_notes, info, profiles }) } +#[cfg(test)] +thread_local! { + /// Tests: how many times a file's JSON was scanned again for its PDF preference on this thread. + static PDF_MEMBER_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Tests: how many times a file's document was read on this thread. + static DOCUMENT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Whether reading `json`'s `pdfCompatible` as a boolean or null fails with a data error at the +/// line and column of `e`, that is, whether the read that gave `e` stopped at the preference. Only +/// the bytes up to that position, and the one there, are read, and they are skipped without being +/// kept. +fn refused_preference_at(json: &[u8], e: &serde_json::Error) -> bool { + // serde_json gives a 1-based line and the bytes before the position on that line. + let Some(line_start) = (match e.line() { + 0 => None, + 1 => Some(0), + n => json.iter().enumerate().filter(|(_, b)| **b == b'\n').nth(n - 2).map(|(i, _)| i + 1), + }) else { + return false; + }; + let end = line_start.saturating_add(e.column()).saturating_add(1).min(json.len()); + struct StrictPreference; + impl<'de> Visitor<'de> for StrictPreference { + type Value = (); + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a native file envelope") + } + + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + while let Some(key) = map.next_key::()? { + if key == "pdfCompatible" { + map.next_value::>()?; + } else { + map.next_value::()?; + } + } + Ok(()) + } + + fn visit_seq>(self, mut seq: A) -> Result<(), A::Error> { + while seq.next_element::()?.is_some() {} + Ok(()) + } + } + serde_json::Deserializer::from_slice(&json[..end]) + .deserialize_any(StrictPreference) + .is_err_and(|s| s.is_data() && s.line() == e.line() && s.column() == e.column()) +} + /// The saved PDF preference, falling back to the presence of its payload without retaining or /// decoding it. The array form has neither member. Repeated members use their last value. fn pdf_member(json: &[u8]) -> Result { + #[cfg(test)] + PDF_MEMBER_SCANS.with(|n| n.set(n.get() + 1)); struct PdfMember; impl<'de> Visitor<'de> for PdfMember { type Value = bool; @@ -801,6 +878,67 @@ mod tests { } } + #[test] + fn only_a_preference_that_is_not_a_boolean_scans_the_json_again() { + let scans_for = |bytes: &[u8]| { + PDF_MEMBER_SCANS.with(|n| n.set(0)); + let compatible = load_info(bytes).unwrap().1.pdf_compatible; + (compatible, PDF_MEMBER_SCANS.with(|n| n.get())) + }; + let d = Document::new(10.0, 10.0); + assert_eq!(scans_for(&save(&d, false)), (false, 0)); + assert_eq!(scans_for(&save(&d, true)), (false, 0)); + for compatible in [false, true] { + let bytes = save_with(&d, &SaveOptions { pdf_compatible: Some(compatible), ..SaveOptions::default() }).unwrap(); + assert_eq!(scans_for(&bytes), (compatible, 0)); + } + let with_pdf = save_with(&d, &SaveOptions { pdf: Some(vec![7; 1024]), ..SaveOptions::default() }).unwrap(); + assert_eq!(scans_for(&with_pdf), (true, 0)); + let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + for odd in [serde_json::json!("yes"), serde_json::json!(1), serde_json::json!([true])] { + value["pdfCompatible"] = odd.clone(); + assert_eq!(scans_for(&serde_json::to_vec(&value).unwrap()), (false, 1), "{odd}"); + } + // A document that can't be read still fails. Read before the preference, it is not read + // a second time. + value["document"] = serde_json::json!("not a document"); + DOCUMENT_READS.with(|n| n.set(0)); + assert!(load_info(&serde_json::to_vec(&value).unwrap()).is_err()); + assert_eq!(DOCUMENT_READS.with(|n| n.get()), 1); + // Read after a preference that is not a boolean, it fails on the second read, which is + // the first to reach it. + let mut first = serde_json::to_vec(&value).unwrap(); + assert!(first.starts_with(br#"{"document""#)); + first.splice(1..1, br#""pdfCompatible":"yes","#.iter().copied()); + DOCUMENT_READS.with(|n| n.set(0)); + assert!(load_info(&first).is_err()); + assert_eq!(DOCUMENT_READS.with(|n| n.get()), 2); + } + + /// A document past a read budget is refused after one read, also in a file whose preference + /// is not a boolean. + #[test] + fn a_document_past_a_budget_is_read_once() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + for i in 0..20 { + let id = d.alloc_id(); + d.insert(Some(l), i, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + } + let n = d.node_count(); + let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + for preference in [serde_json::Value::Null, serde_json::json!(true), serde_json::json!("yes")] { + value["pdfCompatible"] = preference.clone(); + let bytes = serde_json::to_vec(&value).unwrap(); + DOCUMENT_READS.with(|r| r.set(0)); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one object past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + assert_eq!(DOCUMENT_READS.with(|r| r.get()), 1, "{preference}"); + } + } + #[test] fn file_info_keeps_the_pdf_preference_without_artboards() { let mut d = Document::new(10.0, 10.0); @@ -1074,6 +1212,66 @@ mod tests { assert_eq!(load(&bytes).unwrap().node_count(), n, "the real budget opens ordinary documents"); } + /// The saved ruler guides become objects when read, so each counts against the same budget. + #[test] + fn saved_ruler_guides_count_against_the_object_budget() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + for i in 0..50 { + d.add_ruler_guide(&vectorcraft_doc::Guide::new(i % 2 == 0, f64::from(i))); + } + let n = d.node_count() + d.ruler_guides().len(); + let bytes = save_with(&d, &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap(); + let opened = load_file_within(&bytes, n, vectorcraft_doc::node::MAX_BUFFERED_VALUES).unwrap().doc; + assert_eq!(opened.ruler_guides().len(), 50, "a document of exactly the budget opens with its guides"); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one guide past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + } + + /// Ruler guide nodes a file holds out of place are put right as it is read: one below a layer's + /// art moves up to the layer's guides, and one in a group, beside the layers or in a symbol is + /// removed. Saving again writes no ruler guide node, which builds from before ruler guides + /// were nodes could not read. + #[test] + fn ruler_guide_nodes_out_of_place_in_a_file_are_put_right() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let (a, b, group) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id| Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + d.insert(Some(l), 0, path(a)).unwrap(); + d.insert(Some(l), usize::MAX, Node::group(group, vec![Arc::new(path(b))])).unwrap(); + let li = d.layers.iter().position(|x| x.id == l).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let guide = |id: u64| serde_json::json!({"id": id, "kind": {"type": "rulerguide", "vertical": true, "pos": 5.0}}); + let kids = &mut v["document"]["layers"][li]["kind"]["children"]; + kids.as_array_mut().unwrap().insert(0, guide(50)); + kids[2]["kind"]["children"].as_array_mut().unwrap().push(guide(51)); + v["document"]["layers"].as_array_mut().unwrap().push(guide(52)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let listed: Vec = opened.ruler_guides().iter().filter_map(|g| g.id).map(|id| id.0).collect(); + assert_eq!(listed, [50]); + let layer = opened.layers.iter().find(|x| x.id == l).unwrap(); + assert_eq!(layer.children().unwrap().iter().map(|c| c.id.0).collect::>(), [a.0, group.0, 50], "art first, then the guide"); + assert_eq!(opened.node(group).unwrap().children().unwrap().len(), 1, "the group keeps only its path"); + assert!(opened.layers.iter().all(|x| !x.is_ruler_guide())); + assert!(!String::from_utf8(save(&opened, false)).unwrap().contains("rulerguide")); + + // A ruler guide in a symbol's art is removed too. + let mut d = Document::new(100.0, 100.0); + let sym = d.alloc_id(); + let art = Node::group(sym, vec![Arc::new(path(d.alloc_id()))]); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art), dynamic: false }); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + v["document"]["symbols"][0]["art"]["kind"]["children"].as_array_mut().unwrap().push(guide(60)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(opened.symbols[0].art.children().unwrap().len(), 1); + assert!(!opened.symbols[0].art.children().unwrap().iter().any(|c| c.is_ruler_guide())); + } + /// #3587: a reordered file (its objects' fields before their `type`, as a JSON tool that /// sorts keys writes them) still opens; one whose fields before a `type` hold more values than /// the budget, such as a long array of numbers, is refused as they are read. diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs index a5f028c38..2272f42f7 100644 --- a/crates/format/tests/it/main.rs +++ b/crates/format/tests/it/main.rs @@ -3,6 +3,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod links; +mod native_guides; mod native_v3; mod native_v4; mod save_options; diff --git a/crates/format/tests/it/native_guides.rs b/crates/format/tests/it/native_guides.rs new file mode 100644 index 000000000..614ceded6 --- /dev/null +++ b/crates/format/tests/it/native_guides.rs @@ -0,0 +1,146 @@ +//! Ruler guides in `.vectorcraft` files of every version: the saved `guides` list becomes ruler +//! guide nodes on their layers when a file opens, and is written back as the same list. + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, LayerColor, Node, NodeId}; +use vectorcraft_format::{MIN_VERSION, SaveOptions, VERSION, load, save_with}; +use vectorcraft_testkit::fixtures; + +fn json_of(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() +} + +fn save_v(d: &Document, version: u32) -> Vec { + save_with(d, &SaveOptions { version, ..SaveOptions::default() }).unwrap() +} + +/// The document of a saved file without its guide list and its next id (giving guides ids +/// raises the next id). +fn without_guides(v: &Value) -> Value { + let mut d = v["document"].clone(); + let o = d.as_object_mut().unwrap(); + o.remove("guides"); + o.remove("next_id"); + d +} + +/// A document with art on two layers, a sublayer in the bottom one, and two artboards: +/// (document, bottom layer, sublayer, top layer, second artboard's id). +fn doc() -> (Document, NodeId, NodeId, NodeId, u32) { + let mut d = (*fixtures::rich_session().doc().unwrap().doc).clone(); + let bottom = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(bottom), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let top = d.add_layer(Some("Top")); + let mut ab = d.artboards[0].clone(); + ab.id = d.next_artboard_id(); + d.artboards.push(ab); + let board = d.artboards[1].id; + (d, bottom, sub, top, board) +} + +/// Each version's file with a guide on no layer, one on a sublayer, one on a layer that is gone, +/// and an artboard guide, as files written before ruler guides were nodes hold them: it opens +/// with the guides on their layers, saves the same lines with their layers and ids, and saving +/// what that file opens to gives the same `guides` list again. The rest of the document is saved +/// as it was. +#[test] +fn every_version_reads_and_writes_back_the_same_guides() { + let (d, bottom, sub, top, board) = doc(); + for version in MIN_VERSION..=VERSION { + let mut v = json_of(&save_v(&d, version)); + assert_eq!(v["version"], version); + let plain = without_guides(&v); + v["document"]["guides"] = json!([ + {"vertical": true, "pos": 12.5}, + {"vertical": false, "pos": 40.0, "layer": sub}, + {"vertical": true, "pos": 70.0, "layer": 99_999}, + {"vertical": false, "pos": 90.0, "layer": bottom, "artboard": board}, + ]); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let guides = opened.ruler_guides(); + let lines: Vec<_> = guides.iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer)).collect(); + // On no layer and on a deleted layer: the top visible, unlocked layer. + assert_eq!( + lines, + [(true, 12.5, None, Some(top)), (false, 40.0, None, Some(sub)), (true, 70.0, None, Some(top)), (false, 90.0, Some(board), Some(bottom))], + "version {version}" + ); + for g in &guides { + let n = opened.node(g.id.unwrap()).unwrap(); + assert!(n.is_ruler_guide() && n.visible && !n.locked && n.name.is_none(), "version {version}"); + // Each sits at the top of its layer, above the art. + let kids = opened.node(g.layer.unwrap()).unwrap().children().unwrap(); + let first_guide = kids.iter().position(|c| c.is_ruler_guide()).unwrap(); + assert!(kids[first_guide..].iter().all(|c| c.is_ruler_guide()), "version {version}"); + } + + let once = json_of(&save_v(&opened, version)); + assert_eq!(once["version"], version); + let saved: Vec<_> = once["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| (g["vertical"].clone(), g["pos"].clone(), g.get("artboard").cloned(), g["layer"].clone(), g["id"].is_u64())) + .collect(); + assert_eq!( + saved, + [ + (json!(true), json!(12.5), None, json!(top), true), + (json!(false), json!(40.0), None, json!(sub), true), + (json!(true), json!(70.0), None, json!(top), true), + (json!(false), json!(90.0), Some(json!(board)), json!(bottom), true), + ], + "version {version}" + ); + assert_eq!(without_guides(&once), plain, "version {version}: the rest of the document"); + + let reopened = load(&serde_json::to_vec(&once).unwrap()).unwrap(); + assert_eq!(reopened.ruler_guides(), guides, "version {version}: same ids, layers and lines"); + let twice = json_of(&save_v(&reopened, version)); + assert_eq!(twice["document"]["guides"], once["document"]["guides"], "version {version}"); + } +} + +/// The guide entry of a build from before ruler guides were nodes, field for field. +#[derive(Debug, PartialEq, Deserialize)] +struct OldGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, +} + +/// An older build reads the guides a file now saves, the new fields (id, name, lock, visibility) +/// included, as the same lines on the same layers. +#[test] +fn an_older_guide_entry_reads_the_saved_guides() { + let (mut d, bottom, sub, _, board) = doc(); + let mut g = vectorcraft_doc::Guide::new(true, 15.0); + g.layer = Some(sub); + g.name = Some("Fold".into()); + g.locked = true; + g.hidden = true; + d.add_ruler_guide(&g); + d.add_ruler_guide(&vectorcraft_doc::Guide { layer: Some(bottom), artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 25.0) }); + for version in MIN_VERSION..=VERSION { + let v = json_of(&save_v(&d, version)); + let entries = v["document"]["guides"].as_array().unwrap(); + assert_eq!(entries[0]["name"], "Fold", "the new fields are written"); + let old: Vec = serde_json::from_value(v["document"]["guides"].clone()).unwrap(); + assert_eq!( + old, + [ + OldGuide { vertical: true, pos: 15.0, artboard: None, layer: Some(sub) }, + OldGuide { vertical: false, pos: 25.0, artboard: Some(board), layer: Some(bottom) }, + ], + "version {version}" + ); + // And this build reads its own lock, visibility and name back. + let back = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(back.ruler_guides(), d.ruler_guides(), "version {version}"); + } +} diff --git a/crates/format/tests/it/native_v3.rs b/crates/format/tests/it/native_v3.rs index 6a96fae79..512025375 100644 --- a/crates/format/tests/it/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -293,13 +293,21 @@ fn artboard_guides_round_trip_and_canvas_guides_save_as_before() { use vectorcraft_doc::Guide; let mut d = Document::new(100.0, 100.0); let id = d.artboards[0].id; - d.guides = vec![Guide::new(true, 10.0), Guide { artboard: Some(id), ..Guide::new(false, 20.0) }]; + d.add_ruler_guide(&Guide::new(true, 10.0)); + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 20.0) }); let mut v = json_of(&save(&d, false)); - assert_eq!(v["document"]["guides"], json!([{"vertical": true, "pos": 10.0}, {"vertical": false, "pos": 20.0, "artboard": id}])); - assert_eq!(load(&save(&d, false)).unwrap().guides, d.guides); + let lines: Vec<_> = v["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| json!({"vertical": g["vertical"], "pos": g["pos"], "artboard": g.get("artboard")})) + .collect(); + assert_eq!(lines, [json!({"vertical": true, "pos": 10.0, "artboard": null}), json!({"vertical": false, "pos": 20.0, "artboard": id})]); + assert_eq!(load(&save(&d, false)).unwrap().ruler_guides(), d.ruler_guides()); v["document"]["guides"] = json!([{"vertical": false, "pos": 20.0}]); let old = load(&serde_json::to_vec(&v).unwrap()).unwrap(); - assert_eq!(old.guides, [Guide::new(false, 20.0)]); + let old_lines: Vec<_> = old.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(old_lines, [(false, 20.0, None)]); } #[test] diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 5848c1a91..610c8f9bd 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -12,6 +12,12 @@ workspace = true [dependencies] chrono = { workspace = true } unicode-general-category = "1" +# The seed of Math.random (interp.rs). +getrandom = "0.4" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# The browser's crypto.getRandomValues. +getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index f0dd6b44b..5a4363108 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -175,6 +175,63 @@ pub enum Expr { /// A parenthesized expression, kept so a rule about the bare form (a label, a directive) can /// tell them apart. Paren(ExprId), + /// An XML literal (`d`), or with `list` an XMLList literal (`<>…`). + XmlLiteral { + parts: Vec, + list: bool, + }, + /// An E4X name (`@a`, `*`, `ns::a`), the key of an [`Expr::Index`] or the operand of + /// [`Expr::XmlScopeName`] and [`Expr::XmlDescendants`]. + XmlName { + attribute: bool, + qualifier: Option, + name: XmlNamePart, + }, + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + XmlScopeName(ExprId), + /// `object..name`. + XmlDescendants { + object: ExprId, + name: ExprId, + }, + /// `object.(predicate)`. + XmlFilter { + object: ExprId, + predicate: ExprId, + }, +} + +/// A piece of an XML literal, in source order. +#[derive(Clone, Debug, PartialEq)] +pub enum XmlPart { + /// Markup kept as written. + Text(Vec), + /// `{expression}` in element content: XML values keep their markup, other values are escaped. + Content(ExprId), + /// `{expression}` as an attribute's value. + Attribute(ExprId), + /// `{expression}` as an element or attribute name, its string checked as an XML name. + Name(ExprId), + /// `{expression}` as a run of attributes, its string checked as `name="value"` attributes. + Attributes(ExprId), +} + +/// What stands before `::` in a qualified name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XmlQualifier { + /// `*::name`: any namespace. + Any, + Expr(ExprId), +} + +/// The name in an E4X name. +#[derive(Clone, Debug, PartialEq)] +pub enum XmlNamePart { + Ident(String), + /// `*`. + Star, + /// `[expression]`. + Computed(ExprId), } #[derive(Clone, Debug, PartialEq)] @@ -218,19 +275,55 @@ pub enum Stmt { Expr(ExprId), Block(Vec), Empty, - If { test: ExprId, consequent: StmtId, alternate: Option }, - DoWhile { body: StmtId, test: ExprId }, - While { test: ExprId, body: StmtId }, - For { init: Option, test: Option, update: Option, body: StmtId }, - ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + If { + test: ExprId, + consequent: StmtId, + alternate: Option, + }, + DoWhile { + body: StmtId, + test: ExprId, + }, + While { + test: ExprId, + body: StmtId, + }, + For { + init: Option, + test: Option, + update: Option, + body: StmtId, + }, + /// `for (… in …)`, or with `each` the E4X `for each (… in …)`, which assigns the values. + ForIn { + target: ForInTarget, + object: ExprId, + body: StmtId, + each: bool, + }, + /// `default xml namespace = expression;`. + DefaultXmlNamespace(ExprId), Continue(Option), Break(Option), Return(Option), - With { object: ExprId, body: StmtId }, - Switch { discriminant: ExprId, cases: Vec }, - Labeled { label: Name, body: StmtId }, + With { + object: ExprId, + body: StmtId, + }, + Switch { + discriminant: ExprId, + cases: Vec, + }, + Labeled { + label: Name, + body: StmtId, + }, Throw(ExprId), - Try { block: Vec, handler: Option, finalizer: Option> }, + Try { + block: Vec, + handler: Option, + finalizer: Option>, + }, Debugger, } diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index d420b94b3..f7c21a8d8 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -10,6 +10,7 @@ //! run past [`crate::Limits`]. mod array; +pub(crate) mod extensions; mod global; mod json; mod math; @@ -131,7 +132,11 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { crate::string_builtins::install(interp)?; crate::number_builtins::install(interp)?; crate::regexp_builtins::install(interp)?; - crate::date_builtins::install(interp) + crate::date_builtins::install(interp)?; + if interp.options.e4x { + crate::xml_builtins::install(interp)?; + } + Ok(()) } // ---- helpers for the built-ins ---- @@ -218,7 +223,7 @@ const UNITS_PER_FUEL: usize = 16; /// against the longest string the heap limit allows, and has its bytes reserved before it is /// added. The buffer's bytes count toward the heap until `finish` or until the native call that /// made the builder returns. Everything the caller still needs must be reachable from the roots. -struct Builder { +pub(crate) struct Builder { units: Vec, /// Code units already reserved and charged to the heap. charged: usize, @@ -227,7 +232,7 @@ struct Builder { } impl Builder { - fn new() -> Self { + pub(crate) fn new() -> Self { Builder { units: Vec::new(), charged: 0, unpaid: 0 } } @@ -259,7 +264,7 @@ impl Builder { Ok(()) } - fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { + pub(crate) fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { self.make_room(interp, units.len())?; self.units.extend_from_slice(units); Ok(()) @@ -267,7 +272,7 @@ impl Builder { /// Appends `units` `count` times. Nothing is written for an empty `units`, however large /// `count` is. - fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { + pub(crate) fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { if units.is_empty() || count == 0 { return Ok(()); } @@ -279,14 +284,14 @@ impl Builder { Ok(()) } - fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + pub(crate) fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { let units: Vec = s.encode_utf16().collect(); self.push_units(interp, &units) } /// The string built, whose bytes were charged as it grew. From here on the heap counts them /// as the string's. - fn finish(self, interp: &mut Interp) -> Value { + pub(crate) fn finish(self, interp: &mut Interp) -> Value { interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged); Value::String(Str::from_units(self.units)) } @@ -358,6 +363,13 @@ fn object_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResu /// `Object.prototype.toString` (section 15.2.4.2). fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + // With `$.localize` on, a localization object gives its text for the current locale. + if interp.localization.enabled() + && let Value::Object(id) = this + && let Some(text) = extensions::localized_text(interp, id)? + { + return Ok(text); + } let class = match &this { Value::Undefined => "Undefined", Value::Null => "Null", @@ -472,7 +484,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe Ok(p) => p, Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)), }; - let source = Rc::new(Source { lines: program.lines.clone(), text: units.into() }); + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag, strict_writes: interp.writes_mode() }); let code = match compile_constructed_function(&program, &source) { Ok(Some(code)) => code, Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")), @@ -501,7 +513,7 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> return Ok(Value::String(Str::from_units(units.to_vec()))); } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), - Some(Func::Host { name, .. } | Func::Data { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Host { name, .. } | Func::Data { name, .. } | Func::Eval { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Bound { .. }) => "function () { [native code] }".to_string(), None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), }; diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs new file mode 100644 index 000000000..15ebbe734 --- /dev/null +++ b/crates/js/src/builtins/extensions.rs @@ -0,0 +1,400 @@ +//! The script extensions a host adds with [`crate::Realm::add_script_extensions`]: the global +//! `localize`, the global `toSource` and `Object.prototype.toSource`, and the getters behind +//! [`crate::ScriptItem`]. `localize` and `Object.prototype.toString` read the locale of +//! [`Localization`]. +//! +//! `toSource` writes primitives, arrays and plain objects as the text of a JavaScript expression +//! that makes the same value: strings in double quotes, property names quoted, properties in the +//! order the object lists them, a hole in an array as an empty element. It never runs a getter or +//! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed +//! primitive, an array with properties besides its elements, an object whose prototype is not +//! `Object.prototype`, a property that is not enumerable or any other object that is not plain +//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the +//! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use super::{Builder, arg, method}; +use crate::heap::{Class, NativeFn, ObjId, Prop, key}; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::value::{Value, array_index, index_key, number_to_str}; +use crate::{ScriptItem, bytecode::Code}; + +/// The locale `localize` looks a localization object up in, and whether `toString` gives an +/// object's localized text (`$.localize`). A host changes it through [`crate::Realm::localization`]. +#[derive(Debug)] +pub struct Localization { + locale: RefCell, + platform: Cell>, + enabled: Cell, +} + +impl Default for Localization { + fn default() -> Self { + Self { locale: RefCell::new("en".into()), platform: Cell::new(None), enabled: Cell::new(false) } + } +} + +impl Localization { + /// The locale, a language and an optional region joined by `_`, such as `de_DE`. + pub fn locale(&self) -> String { + self.locale.borrow().clone() + } + + pub fn set_locale(&self, locale: &str) { + *self.locale.borrow_mut() = locale.to_string(); + } + + /// The platform suffix the lookup tries first (`Win`, `Mac` or `Unix`), or none. + pub fn platform(&self) -> Option<&'static str> { + self.platform.get() + } + + pub fn set_platform(&self, platform: Option<&'static str>) { + self.platform.set(platform); + } + + /// Does `Object.prototype.toString` give an object's localized text? + pub fn enabled(&self) -> bool { + self.enabled.get() + } + + pub fn set_enabled(&self, on: bool) { + self.enabled.set(on); + } + + /// The property names a localization object is looked up by, in order: the locale with the + /// platform suffix, the locale, its language with the suffix, its language, and `en`. + pub fn keys(&self) -> Vec { + let locale = self.locale(); + let language = locale.split('_').next().unwrap_or_default().to_string(); + let mut keys = Vec::with_capacity(5); + for name in [&locale, &language] { + if let Some(p) = self.platform() { + keys.push(format!("{name}_{p}")); + } + keys.push(name.clone()); + } + keys.push("en".into()); + let mut unique = Vec::with_capacity(keys.len()); + for k in keys { + if !k.is_empty() && !unique.contains(&k) { + unique.push(k); + } + } + unique + } +} + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let global = interp.global; + method(interp, global, "localize", localize, 1)?; + method(interp, global, "toSource", to_source, 1)?; + let object_proto = interp.intrinsics.object_proto; + method(interp, object_proto, "toSource", to_source_method, 0) +} + +/// The text of the localization object `id` for the current locale: the string value of the first +/// of [`Localization::keys`] that the object has as its own data property. No getter runs. +pub(crate) fn localized_text(interp: &mut Interp, id: ObjId) -> JsResult> { + if !matches!(interp.object(id)?.class, Class::Ordinary) { + return Ok(None); + } + for k in interp.localization.keys() { + interp.use_fuel()?; + if let Some(Prop::Data { value: v @ Value::String(_), .. }) = interp.get_own_property(id, &key(&k))? { + return Ok(Some(v)); + } + } + Ok(None) +} + +/// `localize(object, ...values)`: the text of a localization object for the current locale, or of +/// a string, with `%1` replaced by the first value, `%2` by the second and so on. A number with no +/// value stays as it is. An object with none of the locale's names is returned as it is. +fn localize(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let mark = interp.temp_roots.len(); + interp.temp_roots.extend(args.iter().cloned()); + let r = localize_rooted(interp, args); + interp.temp_roots.truncate(mark); + r +} + +fn localize_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { + let text = match args.first() { + Some(Value::String(s)) => s.flat(), + Some(first @ Value::Object(id)) => match localized_text(interp, *id)? { + Some(Value::String(s)) => s.flat(), + _ => return Ok(first.clone()), + }, + _ => return Err(interp.type_error("localize: the first argument must be a localization object or a string")), + }; + let values = args.get(1..).unwrap_or_default(); + let mut converted: Vec>> = vec![None; values.len()]; + let mut out = Builder::new(); + let (mut start, mut i) = (0, 0); + while let Some(&u) = text.get(i) { + if u != u16::from(b'%') { + i += 1; + continue; + } + let digits = text.get(i + 1..).unwrap_or_default().iter().take(4).take_while(|d| (0x30..=0x39).contains(*d)).count(); + let n = text.get(i + 1..i + 1 + digits).unwrap_or_default().iter().fold(0usize, |n, d| n * 10 + usize::from(*d - 0x30)); + let Some(slot) = n.checked_sub(1).filter(|&k| digits > 0 && k < values.len()) else { + i += 1 + digits; + continue; + }; + out.push_units(interp, text.get(start..i).unwrap_or_default())?; + let units = match converted.get(slot).cloned().flatten() { + Some(u) => u, + None => { + let s = interp.to_str(&arg(values, slot))?.flat(); + if let Some(c) = converted.get_mut(slot) { + *c = Some(s.clone()); + } + s + } + }; + out.push_units(interp, &units)?; + i += 1 + digits; + start = i; + } + out.push_units(interp, text.get(start..).unwrap_or_default())?; + Ok(out.finish(interp)) +} + +/// The global `toSource(value)`. +fn to_source(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + write_source(interp, arg(args, 0)) +} + +/// `Object.prototype.toSource()`: the source of the value it is called on. +fn to_source_method(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + write_source(interp, this) +} + +fn write_source(interp: &mut Interp, v: Value) -> JsResult { + let mark = interp.temp_roots.len(); + interp.temp_roots.push(v.clone()); + let mut w = Source { out: Builder::new(), ancestors: Vec::new(), entries: 0 }; + let r = w.value(interp, &v); + interp.temp_roots.truncate(mark); + r?; + Ok(w.out.finish(interp)) +} + +/// A value being written as source: the text so far, the objects it is inside and the entries it +/// has written. +struct Source { + out: Builder, + ancestors: Vec, + entries: usize, +} + +impl Source { + fn push(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + self.out.push_str(interp, s)?; + self.check_length(interp) + } + + fn check_length(&mut self, interp: &mut Interp) -> JsResult<()> { + self.fit(interp, 0) + } + + /// Checks that `more` code units can be added without passing the length limit. + fn fit(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> { + if self.out.units.len().saturating_add(more) > interp.data_limits.bytes { + return Err(interp.throw(ErrorType::Range, "toSource: the source is too long")); + } + Ok(()) + } + + fn count(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> { + self.entries = self.entries.saturating_add(n); + if self.entries > interp.data_limits.entries { + return Err(interp.throw(ErrorType::Range, "toSource: the value has too many entries")); + } + Ok(()) + } + + fn value(&mut self, interp: &mut Interp, v: &Value) -> JsResult<()> { + interp.use_fuel()?; + match v { + Value::Undefined => self.push(interp, "(void 0)"), + Value::Null => self.push(interp, "null"), + Value::Bool(b) => self.push(interp, if *b { "true" } else { "false" }), + Value::Number(n) if *n == 0.0 && n.is_sign_negative() => self.push(interp, "-0"), + Value::Number(n) => { + self.out.push_units(interp, &number_to_str(*n).flat())?; + self.check_length(interp) + } + Value::String(s) => self.quoted(interp, &s.flat()), + Value::Object(id) => self.object(interp, *id), + } + } + + /// `s` in double quotes, with the characters a string literal cannot hold escaped. The length + /// limit is checked before each addition, so a string that grows as it is escaped throws a + /// `RangeError` before the text takes more heap than the limit allows. + fn quoted(&mut self, interp: &mut Interp, s: &[u16]) -> JsResult<()> { + self.fit(interp, 1)?; + self.out.push_str(interp, "\"")?; + let mut start = 0; + for (i, &c) in s.iter().enumerate() { + let escape = match c { + 0x22 => "\\\"".to_string(), + 0x5C => "\\\\".to_string(), + 0x0A => "\\n".to_string(), + 0x0D => "\\r".to_string(), + 0x09 => "\\t".to_string(), + 0x08 => "\\b".to_string(), + 0x0C => "\\f".to_string(), + 0..=0x1F | 0x7F | 0x2028 | 0x2029 | 0xD800..=0xDFFF => format!("\\u{c:04X}"), + _ => continue, + }; + self.fit(interp, (i - start).saturating_add(escape.len()))?; + self.out.push_units(interp, s.get(start..i).unwrap_or_default())?; + self.out.push_str(interp, &escape)?; + start = i + 1; + } + self.fit(interp, s.len().saturating_sub(start).saturating_add(1))?; + self.out.push_units(interp, s.get(start..).unwrap_or_default())?; + self.push(interp, "\"") + } + + fn object(&mut self, interp: &mut Interp, id: ObjId) -> JsResult<()> { + if self.ancestors.contains(&id) { + return Err(interp.type_error("toSource: the value contains itself")); + } + if self.ancestors.len() >= interp.data_limits.depth { + return Err(interp.throw(ErrorType::Range, "toSource: the value is nested too deep")); + } + let (object_proto, array_proto) = (interp.intrinsics.object_proto, interp.intrinsics.array_proto); + let o = interp.object(id)?; + // The text makes an array or an object from `Object.prototype` with enumerable properties + // only, so an array with other properties, an object with another prototype and a property + // that is not enumerable are refused. + let enumerable = o.props.iter().all(|(_, p)| p.enumerable()); + let shape = match &o.class { + Class::Array { length, .. } + if o.proto == Some(array_proto) && enumerable && o.props.iter().all(|(k, _)| array_index(k).is_some_and(|i| i < *length)) => + { + Some(Err(*length)) + } + Class::Array { .. } => return Err(interp.type_error(NOT_PLAIN_ARRAY)), + Class::Ordinary if o.proto == Some(object_proto) && enumerable => { + Some(Ok(o.props.iter().map(|(k, p)| (k.clone(), p.clone())).collect::>())) + } + Class::Ordinary => return Err(interp.type_error(NOT_PLAIN_OBJECT)), + _ => None, + }; + let Some(shape) = shape else { + let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name()); + return Err(interp.type_error(&what)); + }; + self.ancestors.push(id); + let r = match shape { + Err(length) => self.array(interp, id, length), + Ok(props) => self.plain(interp, props), + }; + self.ancestors.pop(); + r + } + + fn array(&mut self, interp: &mut Interp, id: ObjId, length: u32) -> JsResult<()> { + self.count(interp, length as usize)?; + self.push(interp, "[")?; + let mut hole = false; + for i in 0..length { + interp.use_fuel()?; + if i > 0 { + self.push(interp, ", ")?; + } + let prop = interp.object(id)?.props.get(&index_key(i)).cloned(); + hole = prop.is_none(); + match prop { + None => {} + Some(Prop::Data { value, .. }) => self.value(interp, &value)?, + Some(Prop::Accessor { .. }) => return Err(interp.type_error("toSource: a getter cannot be written as source")), + } + } + // A hole at the end needs a comma of its own, or the array would be one shorter. + if hole { + self.push(interp, ",")?; + } + self.push(interp, "]") + } + + fn plain(&mut self, interp: &mut Interp, props: Vec<(crate::heap::Key, Prop)>) -> JsResult<()> { + self.count(interp, props.len())?; + self.push(interp, "({")?; + for (n, (k, prop)) in props.into_iter().enumerate() { + interp.use_fuel()?; + if n > 0 { + self.push(interp, ", ")?; + } + let Prop::Data { value, .. } = prop else { return Err(interp.type_error("toSource: a getter cannot be written as source")) }; + self.quoted(interp, &k)?; + self.push(interp, ":")?; + self.value(interp, &value)?; + } + self.push(interp, "})") + } +} + +const NOT_PLAIN_ARRAY: &str = + "toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source"; +const NOT_PLAIN_OBJECT: &str = + "toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source"; + +/// The getter behind `item`; [`ScriptItem::Global`] is a value and has none. +pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> { + match item { + ScriptItem::Global => None, + ScriptItem::LastError => Some(("error", last_error)), + ScriptItem::Line => Some(("line", line)), + ScriptItem::Stack => Some(("stack", stack)), + } +} + +fn last_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(interp.last_error.clone()) +} + +fn line(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let line = interp.script_frames().last().map_or(0, |(code, pc, _)| code.line_column(pc.saturating_sub(1)).0); + Ok(Value::Number(f64::from(line))) +} + +/// The most frames `$.stack` lists, and the most characters of a function's name it shows. +const STACK_FRAMES: usize = 64; +const STACK_NAME_UNITS: usize = 64; + +/// The script's frames, outermost first, one a line: the function's name and the line it is at. +fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let frames: Vec<(Rc, usize, bool)> = interp.script_frames().map(|(c, pc, f)| (c.clone(), pc, f)).collect(); + let skip = frames.len().saturating_sub(STACK_FRAMES); + let mut text = String::new(); + if skip > 0 { + text.push_str("…\n"); + } + for (code, pc, function) in frames.iter().skip(skip) { + interp.use_fuel()?; + let name = if *function { function_name(code) } else { "[global]".to_string() }; + let line = code.line_column(pc.saturating_sub(1)).0; + text.push_str(&format!("{name}:{line}\n")); + } + let mut out = Builder::new(); + out.push_str(interp, &text)?; + Ok(out.finish(interp)) +} + +/// The name `code` has: its own as a function expression's, or its declaration's, cut to +/// [`STACK_NAME_UNITS`]; `[anonymous]` without one. +fn function_name(code: &Code) -> String { + match code.self_name.as_ref().or(code.decl_name.as_ref()) { + Some(name) => String::from_utf16_lossy(name.get(..STACK_NAME_UNITS).unwrap_or(&**name)), + None => "[anonymous]".to_string(), + } +} diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index e0593d1dd..0a70e8043 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -605,6 +605,21 @@ fn math_functions_follow_the_special_cases() { truth("var n = 0; Math.max({valueOf: function () { n++; return NaN; }}, {valueOf: function () { n++; return 1; }}); n === 2"); } +#[test] +fn math_random_is_seeded_from_the_os_random_source() { + // On wasm32-unknown-unknown `RandomState`'s keys come from memory addresses, which are the + // same on every page load: the seed must come from the OS (the browser's crypto) instead. + let src = include_str!("interp.rs"); + let Some(start) = src.find("fn random_seed()") else { panic!("interp.rs has no random_seed") }; + let body = &src[start..]; + let body = &body[..body.find("\n}").unwrap_or(body.len())]; + assert!(body.contains("random_seed_from(getrandom::u64())"), "Math.random isn't seeded from the OS random source"); + // The OS value is the seed, made odd so it's never zero; a failed read still gives a seed. + assert_eq!(crate::interp::random_seed_from(Ok(0x40)), 0x41); + assert_eq!(crate::interp::random_seed_from(Ok(0)), 1); + assert_eq!(crate::interp::random_seed_from(Err(getrandom::Error::UNSUPPORTED)) & 1, 1); +} + // ---- JSON ---- #[test] diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index b23d9a525..3092530d4 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -145,6 +145,35 @@ pub(crate) enum Op { /// Pushes the completion value, which a `finally` block in script and eval code keeps and /// sets back with `SetCompletion` when it ends normally. GetCompletion, + /// `[qualifier] name` → an E4X name object. + XmlMakeName { + attribute: bool, + qualified: bool, + }, + /// `object name` → the descendants that match. + XmlDescendants, + /// `value` → the string an embedded expression puts in a literal (0 content, 1 attribute + /// value, 2 name, 3 run of attributes). + XmlEscape(u8), + /// The `parts` strings → the XML or XMLList they spell. + XmlLiteral { + parts: u32, + list: bool, + }, + /// `name` → the value of the name on the nearest XML value in scope. + XmlScopeGet, + /// `object` → the filter's state. + XmlFilterStart, + /// `state` → `state item`, or jumps with `state` when the items are used up. + XmlFilterNext(u32), + /// `state value` → `state`, the current item kept when the value is truthy. + XmlFilterTest, + /// `state` → the XMLList of the items kept. + XmlFilterEnd, + /// `iterator name` → `iterator value`: the value under the name a `for each` got. + ForEachValue, + /// `value` → nothing: sets the default XML namespace of the running code. + SetDefaultNamespace, } /// What code is: a function body, a script, or eval code. @@ -171,6 +200,11 @@ pub(crate) struct Handler { pub(crate) struct Source { pub(crate) lines: LineTable, pub(crate) text: Rc<[u16]>, + /// Which source this is, as [`crate::Realm::error_source`] gives it. + pub(crate) tag: u32, + /// Writes to read-only and getter-only properties, and new properties on objects that are + /// not extensible, throw in this source's code even where it is not strict mode code. + pub(crate) strict_writes: bool, } #[derive(Debug)] @@ -190,6 +224,8 @@ pub(crate) struct Code { pub(crate) strict: bool, /// A function expression's own name, bound inside it. pub(crate) self_name: Option, + /// A function declaration's name, which `$.stack` shows. + pub(crate) decl_name: Option, pub(crate) params: Vec, pub(crate) vars: Vec, /// Function declarations to instantiate on entry: their names and indices in `functions`. @@ -267,7 +303,13 @@ fn effect(op: Op, finally_tables: &[Vec]) -> Result { let table = finally_tables.get(t as usize).ok_or_else(|| format!("finally table {t} out of range"))?; Effect { pops: 2, next: Some(0), jumps: table.iter().map(|&target| (target, 1)).collect() } } - Op::ForInNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + Op::ForInNext(t) | Op::XmlFilterNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + Op::XmlMakeName { qualified, .. } => e(1 + u32::from(qualified), 1), + Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), + Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), + Op::XmlLiteral { parts, .. } => e(parts, 1), + Op::ForEachValue => e(2, 2), + Op::SetDefaultNamespace => e(1, 0), }) } diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index eb61fe763..a7b2c1374 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -1,5 +1,8 @@ //! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters -//! identifiers are made of. +//! identifiers are made of. Identifiers follow the Unicode `ID_Start` and `ID_Continue` rule of +//! later editions. + +use std::cmp::Ordering; use unicode_general_category::{GeneralCategory as G, get_general_category}; @@ -14,35 +17,97 @@ pub fn is_whitespace(c: u32) -> bool { matches!(c, 0x09 | 0x0B | 0x0C | 0x20 | 0xA0 | 0xFEFF | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000) } -/// A character an identifier may start with: a letter (categories Lu, Ll, Lt, Lm, Lo and Nl), -/// `$` or `_` (section 7.6). `\u` escapes are decoded before this check. +/// A character an identifier may start with: a Unicode `ID_Start` character, `$` or `_` +/// (the Names and Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, Lm, Lo and Nl) +/// and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode Standard Annex +/// #31). The categories come from Unicode 16.0. `\u` escapes are decoded before this check. pub fn is_id_start(c: u32) -> bool { match c { 0x24 | 0x5F => true, 0x41..=0x5A | 0x61..=0x7A => true, 0..=0x7F => false, - // U+2E2F VERTICAL TILDE is a letter (Lm) that Unicode leaves out of identifiers. - 0x2E2F => false, - _ => category(c).is_some_and(|g| { - matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber) - }), + _ if is_other_id_start(c) => true, + _ => { + category(c).is_some_and(|g| { + matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber) + }) && !is_pattern_syntax(c) + } } } -/// A character an identifier may hold after its first: those it may start with, decimal digits -/// (Nd), combining marks (Mn, Mc), connector punctuation (Pc), and the zero width non-joiner and -/// joiner. +/// A character an identifier may hold after its first: a Unicode `ID_Continue` character or `$`. +/// `ID_Continue` adds to `ID_Start` the decimal digits (Nd), combining marks (Mn, Mc), connector +/// punctuation (Pc, which holds `_`) and `Other_ID_Continue` (which holds the zero width +/// non-joiner and joiner), less `Pattern_Syntax` and `Pattern_White_Space`. pub fn is_id_part(c: u32) -> bool { match c { 0x30..=0x39 => true, - 0x200C | 0x200D => true, - 0x2E2F => false, _ if is_id_start(c) => true, 0..=0x7F => false, - _ => category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)), + _ if is_other_id_continue(c) => true, + _ => { + category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)) + && !is_pattern_syntax(c) + } } } +/// `Other_ID_Start` (Unicode PropList): characters kept in `ID_Start` though their category is +/// not a letter. +fn is_other_id_start(c: u32) -> bool { + matches!(c, 0x1885 | 0x1886 | 0x2118 | 0x212E | 0x309B | 0x309C) +} + +/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// category is not a letter, mark, digit or connector. +fn is_other_id_continue(c: u32) -> bool { + matches!(c, 0xB7 | 0x387 | 0x1369..=0x1371 | 0x19DA | 0x200C | 0x200D | 0x30FB | 0xFF65) +} + +/// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no +/// identifier character belongs to it. `Pattern_White_Space` outside ASCII (U+0085, U+200E, U+200F, +/// U+2028 and U+2029) holds no letter, mark, digit or connector, so the category checks leave it out. +const PATTERN_SYNTAX: [(u32, u32); 23] = [ + (0x00A1, 0x00A7), + (0x00A9, 0x00A9), + (0x00AB, 0x00AC), + (0x00AE, 0x00AE), + (0x00B0, 0x00B1), + (0x00B6, 0x00B6), + (0x00BB, 0x00BB), + (0x00BF, 0x00BF), + (0x00D7, 0x00D7), + (0x00F7, 0x00F7), + (0x2010, 0x2027), + (0x2030, 0x203E), + (0x2041, 0x2053), + (0x2055, 0x205E), + (0x2190, 0x245F), + (0x2500, 0x2775), + (0x2794, 0x2BFF), + (0x2E00, 0x2E7F), + (0x3001, 0x3003), + (0x3008, 0x3020), + (0x3030, 0x3030), + (0xFD3E, 0xFD3F), + (0xFE45, 0xFE46), +]; + +/// Whether a non-ASCII code point is `Pattern_Syntax`. +fn is_pattern_syntax(c: u32) -> bool { + PATTERN_SYNTAX + .binary_search_by(|&(lo, hi)| { + if hi < c { + Ordering::Less + } else if lo > c { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .is_ok() +} + /// The Unicode general category of a code point; `None` for a lone surrogate. fn category(c: u32) -> Option { char::from_u32(c).map(get_general_category) diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index ae470be44..071e99a60 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -18,7 +18,7 @@ use std::rc::Rc; use crate::ast::{ AssignOp, Ast, BinaryOp, Expr, ExprId, ForInTarget, ForInit, FunctionId, FunctionKind, LogicalOp, PropertyValue, Stmt, StmtId, UnaryOp, UpdateOp, - VarDecl, + VarDecl, XmlNamePart, XmlPart, XmlQualifier, }; use crate::bytecode::{Code, CodeKind, Handler, Op, Source, fallthrough_effect, verify}; use crate::error::{SyntaxError, TOO_DEEP}; @@ -276,7 +276,14 @@ impl<'a> FnCompiler<'a> { } work.extend(block.iter().rev()); } - Stmt::Expr(_) | Stmt::Empty | Stmt::Continue(_) | Stmt::Break(_) | Stmt::Return(_) | Stmt::Throw(_) | Stmt::Debugger => {} + Stmt::Expr(_) + | Stmt::Empty + | Stmt::Continue(_) + | Stmt::Break(_) + | Stmt::Return(_) + | Stmt::Throw(_) + | Stmt::Debugger + | Stmt::DefaultXmlNamespace(_) => {} } } Ok(out) @@ -348,6 +355,11 @@ impl<'a> FnCompiler<'a> { Ok(()) } Stmt::Try { block, handler, finalizer } => self.try_stmt(block, handler.as_ref(), finalizer.as_deref()), + Stmt::DefaultXmlNamespace(e) => { + self.expr(*e)?; + self.emit(Op::SetDefaultNamespace); + Ok(()) + } } } @@ -467,14 +479,14 @@ impl<'a> FnCompiler<'a> { } self.jump(Op::Jump, head); } - Some(Stmt::ForIn { target, object, body }) => return self.for_in(target, *object, *body, labels), + Some(Stmt::ForIn { target, object, body, each }) => return self.for_in(target, *object, *body, labels, *each), _ => return internal("not a loop"), } self.bind(brk); Ok(()) } - fn for_in(&mut self, target: &ForInTarget, object: ExprId, body: StmtId, labels: Vec) -> CResult<()> { + fn for_in(&mut self, target: &ForInTarget, object: ExprId, body: StmtId, labels: Vec, each: bool) -> CResult<()> { if let ForInTarget::Var(d) = target { self.var_decls(std::slice::from_ref(d))?; } @@ -485,6 +497,9 @@ impl<'a> FnCompiler<'a> { let brk = self.new_label(); self.bind(head); self.jump(Op::ForInNext, brk); + if each { + self.emit(Op::ForEachValue); + } match target { ForInTarget::Var(d) => { let n = self.var_name(&d.name.name); @@ -827,10 +842,83 @@ impl<'a> FnCompiler<'a> { } } Expr::Paren(inner) => self.expr(*inner)?, + Expr::XmlLiteral { parts, list } => self.xml_literal(parts, *list)?, + Expr::XmlName { attribute, qualifier, name } => self.xml_name(*attribute, *qualifier, name)?, + Expr::XmlScopeName(name) => { + self.expr(*name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeGet); + } + Expr::XmlDescendants { .. } | Expr::XmlFilter { .. } => self.chain(id)?, } Ok(()) } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { + for part in parts { + match part { + XmlPart::Text(units) => { + let i = self.string(units); + self.emit(Op::String(i)); + } + XmlPart::Content(e) | XmlPart::Attribute(e) | XmlPart::Name(e) | XmlPart::Attributes(e) => { + self.expr(*e)?; + self.emit(Op::XmlEscape(match part { + XmlPart::Content(_) => 0, + XmlPart::Attribute(_) => 1, + XmlPart::Name(_) => 2, + _ => 3, + })); + } + } + } + self.emit(Op::XmlLiteral { parts: len_u32(parts.len()), list }); + Ok(()) + } + + fn xml_name(&mut self, attribute: bool, qualifier: Option, name: &XmlNamePart) -> CResult<()> { + match qualifier { + Some(XmlQualifier::Any) => self.emit(Op::Null), + Some(XmlQualifier::Expr(e)) => self.expr(e)?, + None => {} + } + match name { + XmlNamePart::Ident(n) => { + let i = self.string(&n.encode_utf16().collect::>()); + self.emit(Op::String(i)); + } + XmlNamePart::Star => { + let i = self.string(&[u16::from(b'*')]); + self.emit(Op::String(i)); + } + XmlNamePart::Computed(e) => self.expr(*e)?, + } + self.emit(Op::XmlMakeName { attribute, qualified: qualifier.is_some() }); + Ok(()) + } + + /// `object.(predicate)` with the object already on the stack: each item is tested with itself + /// as the innermost scope, in list order. + fn xml_filter(&mut self, predicate: ExprId) -> CResult<()> { + self.emit(Op::XmlFilterStart); + let head = self.new_label(); + let done = self.new_label(); + self.bind(head); + let depth = self.depth; + self.jump(Op::XmlFilterNext, done); + self.emit(Op::PushWith); + self.env_depth += 1; + self.expr(predicate)?; + self.emit(Op::PopEnv); + self.env_depth -= 1; + self.emit(Op::XmlFilterTest); + self.jump(Op::Jump, head); + self.bind(done); + self.depth = depth; + self.emit(Op::XmlFilterEnd); + Ok(()) + } + fn args(&mut self, args: &[ExprId]) -> CResult<()> { for a in args { self.expr(*a)?; @@ -869,7 +957,9 @@ impl<'a> FnCompiler<'a> { let mut cur = id; loop { match self.ast.expr(cur) { - Some(Expr::Member { object, .. } | Expr::Index { object, .. }) => { + Some( + Expr::Member { object, .. } | Expr::Index { object, .. } | Expr::XmlDescendants { object, .. } | Expr::XmlFilter { object, .. }, + ) => { suffixes.push(cur); cur = self.ast.unparenthesized(*object); } @@ -910,6 +1000,15 @@ impl<'a> FnCompiler<'a> { self.at(span.start); self.emit(if next_is_call { Op::GetIndexThis } else { Op::GetIndex }); } + Some(Expr::XmlDescendants { name, .. }) => { + self.expr(*name)?; + self.at(span.start); + self.emit(Op::XmlDescendants); + } + Some(Expr::XmlFilter { predicate, .. }) => { + self.at(span.start); + self.xml_filter(*predicate)?; + } Some(Expr::Call { args, .. }) => { let innermost = j + 1 == count; let has_this = if innermost { @@ -1163,9 +1262,15 @@ impl<'a> FnCompiler<'a> { for &(i, label) in &self.patches { let Some(target) = resolve(&self.labels, label) else { return internal("jump to an unplaced label") }; match self.ops.get_mut(i) { - Some(Op::Jump(t) | Op::JumpIfFalse(t) | Op::JumpIfTrue(t) | Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) | Op::ForInNext(t)) => { - *t = target - } + Some( + Op::Jump(t) + | Op::JumpIfFalse(t) + | Op::JumpIfTrue(t) + | Op::JumpIfFalseKeep(t) + | Op::JumpIfTrueKeep(t) + | Op::ForInNext(t) + | Op::XmlFilterNext(t), + ) => *t = target, _ => return internal("patching an instruction that is not a jump"), } } @@ -1193,6 +1298,7 @@ impl<'a> FnCompiler<'a> { max_stack: 0, strict: self.strict, self_name: meta.self_name, + decl_name: meta.decl_name, params: meta.params, vars: hoisted.vars, fun_decls: hoisted.fun_decls, @@ -1209,6 +1315,7 @@ impl<'a> FnCompiler<'a> { #[derive(Default)] struct Meta { self_name: Option, + decl_name: Option, params: Vec, span: (u32, u32), } @@ -1231,6 +1338,7 @@ pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc, c.emit(Op::Return); let name = f.name.as_ref().map(|n| key_of(&n.name)); let meta = Meta { + decl_name: if f.kind == FunctionKind::Declaration { name.clone() } else { None }, self_name: if bind_name && f.kind == FunctionKind::Expression { name } else { None }, params: f.params.iter().map(|p| key_of(&p.name)).collect(), span: (f.span.start, f.span.end), diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs new file mode 100644 index 000000000..19a20ab5b --- /dev/null +++ b/crates/js/src/extension_tests.rs @@ -0,0 +1,202 @@ +//! The script extensions: `localize`, `toSource`, host properties with setters and the items that +//! read the interpreter's own state. + +use std::cell::RefCell; +use std::rc::Rc; + +use super::*; + +fn realm() -> Realm { + let mut r = Realm::new(); + r.add_script_extensions().unwrap(); + r +} + +fn text(r: &mut Realm, source: &str) -> String { + match r.run(source) { + Ok(JsValue::String(s)) => s, + other => panic!("{source}: {other:?}"), + } +} + +fn thrown(r: &mut Realm, source: &str) -> String { + match r.run(source) { + Err(e) if e.kind == RunErrorKind::Thrown => e.message, + other => panic!("{source}: {other:?}"), + } +} + +#[test] +fn localize_looks_up_the_locale_with_its_platform_then_its_language_then_english() { + let mut r = realm(); + let l = r.localization(); + l.set_locale("de_DE"); + l.set_platform(Some("Win")); + assert_eq!(l.keys(), ["de_DE_Win", "de_DE", "de_Win", "de", "en"]); + let all = "({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd', de_DE_Win: 'ddw'})"; + assert_eq!(text(&mut r, &format!("localize({all})")), "ddw"); + assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd'})"), "dd"); + assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw'})"), "dw"); + assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', DE: 'x'})"), "d", "names are case sensitive"); + assert_eq!(text(&mut r, "localize({en: 'e', fr: 'f'})"), "e"); + l.set_platform(None); + assert_eq!(text(&mut r, &format!("localize({all})")), "dd"); + l.set_locale("en"); + assert_eq!(l.keys(), ["en"]); +} + +#[test] +fn localize_replaces_numbered_values_once_and_leaves_the_rest() { + let mut r = realm(); + assert_eq!(text(&mut r, "localize({en: '%2 before %1, %1 again'}, 'a', 'b')"), "b before a, a again"); + assert_eq!(text(&mut r, "localize('%1%% %3 %0 %', 'x', '%1')"), "x%% %3 %0 %"); + assert_eq!(text(&mut r, "localize('%2', 'x', '%1')"), "%1", "a value is not replaced again"); + assert_eq!(text(&mut r, "localize('%1', {toString: function () { return 'o'; }})"), "o"); + assert_eq!(text(&mut r, "var n = 0; var v = {toString: function () { n++; return 'v'; }}; localize('%1%1', v) + n"), "vv1"); +} + +#[test] +fn localize_gives_back_an_object_without_the_locale_and_runs_no_getter() { + let mut r = realm(); + r.localization().set_locale("fr_FR"); + assert_eq!(r.run("var o = {de: 'd'}; localize(o) === o").unwrap(), JsValue::Bool(true)); + let source = "var hit = false; var o = {get fr() { hit = true; return 'g'; }, en: 'e'}; localize(o) + ',' + hit"; + assert_eq!(text(&mut r, source), "e,false"); + assert_eq!(text(&mut r, "localize({fr: 1, en: 'e'})"), "e", "a value that is not a string is passed over"); + for bad in ["localize()", "localize(1)", "localize(null)"] { + assert!(thrown(&mut r, bad).starts_with("TypeError"), "{bad}"); + } +} + +#[test] +fn to_string_localizes_an_object_only_while_localization_is_on() { + let mut r = realm(); + r.localization().set_locale("ja"); + let source = "String({en: 'Hello', ja: 'こんにちは'}) + '|' + String({x: 1})"; + assert_eq!(text(&mut r, source), "[object Object]|[object Object]"); + r.localization().set_enabled(true); + assert_eq!(text(&mut r, source), "こんにちは|[object Object]"); +} + +#[test] +fn to_source_writes_plain_data_as_an_expression_that_makes_it_again() { + let mut r = realm(); + let source = r#"toSource({a: [1, "x\n\"", null, true, -0, 1.5e300], "b c": {d: undefined}, e: {}, f: []})"#; + let want = r#"({"a":[1, "x\n\"", null, true, -0, 1.5e+300], "b c":({"d":(void 0)}), "e":({}), "f":[]})"#; + assert_eq!(text(&mut r, source), want); + assert_eq!(text(&mut r, "[1, , 3].toSource() + '|' + [1, ,].toSource() + '|' + [,].toSource()"), "[1, , 3]|[1, ,]|[,]"); + assert_eq!( + text(&mut r, "toSource(NaN) + toSource(-Infinity) + toSource() + toSource('\\u2028\\ud800\\x7f')"), + "NaN-Infinity(void 0)\"\\u2028\\uD800\\u007F\"" + ); + assert_eq!(text(&mut r, "(5).toSource() + 'a'.toSource() + true.toSource()"), "5\"a\"true"); + // Parsed back, the text makes the same value: holes, -0 and undefined included. + let round = r#"var v = {a: [1, , "q\u0001'\\"], b: {"0": -0, "": undefined, "x y": [[]]}}; + var w = eval(toSource(v)); + [JSON.stringify(w) === JSON.stringify(v), w.a.length, 1 in w.a, 1 / w.b[0], "" in w.b].join()"#; + assert_eq!(text(&mut r, round), "true,3,false,-Infinity,true"); + let shared = "var s = {k: 1}; toSource([s, s])"; + assert_eq!(text(&mut r, shared), r#"[({"k":1}), ({"k":1})]"#, "a value seen twice that is not its own ancestor is written twice"); +} + +#[test] +fn to_source_refuses_what_is_not_plain_data() { + let mut r = realm(); + for (source, kind) in [ + ("var o = {}; o.o = o; toSource(o)", "TypeError"), + ("var a = [1]; a.push([a]); toSource(a)", "TypeError"), + ("toSource({f: function () {}})", "TypeError"), + ("toSource({get g() { return 1; }})", "TypeError"), + ("toSource(new Date(0))", "TypeError"), + ("toSource([/x/])", "TypeError"), + ("toSource(new Error('e'))", "TypeError"), + ("toSource(new String('s'))", "TypeError"), + ("var a = [1]; a.extra = 2; toSource(a)", "TypeError"), + ("toSource(Object.create(Array.prototype))", "TypeError"), + ("var o = {}; Object.defineProperty(o, 'hidden', {value: 1}); toSource(o)", "TypeError"), + ("var a = [0]; Object.defineProperty(a, '0', {value: 1, enumerable: false}); toSource(a)", "TypeError"), + ("toSource(Object.create({a: 1}))", "TypeError"), + ("toSource(Object.create(null))", "TypeError"), + ("function F() { this.a = 1; } toSource(new F())", "TypeError"), + ("var d = []; for (var i = 0; i < 70; i++) d = [d]; toSource(d)", "RangeError"), + ("var a = []; a.length = 2000000; toSource(a)", "RangeError"), + ] { + assert!(thrown(&mut r, source).starts_with(kind), "{source}"); + } + assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run"); +} + +#[test] +fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() { + let mut r = realm(); + // A run's heap in the web build. 2^23 control characters, each written as six, pass a quarter + // of it, which is all one string may take. + r.set_limits(Limits { heap_bytes: 128 << 20, ..Limits::default() }); + let source = "var s = '\\x01'; for (var i = 0; i < 23; i++) s = s + s; + try { toSource(s); 'no error'; } catch (e) { e.name + ': ' + e.message; }"; + assert_eq!(text(&mut r, source), "RangeError: toSource: the source is too long"); + assert_eq!(text(&mut r, "toSource('a\\x01a\\x01a\\x01a\\x01').length + ''"), "30", "short strings are written whole"); +} + +#[test] +fn to_source_stops_at_the_fuel_limit() { + let mut r = realm(); + r.set_limits(Limits { fuel: 200_000, ..Limits::default() }); + let e = r.run("var a = []; for (var i = 0; i < 1000; i++) a.push(i); while (true) toSource(a);").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel)); +} + +#[test] +fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() { + let mut r = realm(); + let items = ["global", "error", "line", "stack"] + .into_iter() + .zip([ScriptItem::Global, ScriptItem::LastError, ScriptItem::Line, ScriptItem::Stack]) + .map(|(n, i)| (n.to_string(), HostItem::Script(i))) + .collect(); + r.define_host_object("h", items).unwrap(); + assert_eq!(r.run("var later = 2; h.global === this && h.global.later === 2").unwrap(), JsValue::Bool(true)); + let source = "var before = h.error; try { null.x; } catch (e) {} var caught = h.error; try { throw 'text'; } catch (e) {} + [before === null, caught instanceof TypeError, h.error === caught].join()"; + assert_eq!(text(&mut r, source), "true,true,true"); + let source = "try { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } } catch (e) {} + h.error.message"; + assert_eq!(text(&mut r, source), "second", "an exception a finally block passes on is not thrown again"); + let source = "try { [1].map(function () { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } }); } catch (e) {} + h.error.message"; + assert_eq!(text(&mut r, source), "second", "nor is one that leaves a built-in's callback"); + let source = "var kept; try { throw new RangeError('first'); } catch (e) { kept = e; } try { throw new TypeError('second'); } catch (e) {} + try { throw kept; } catch (e) {} h.error.message"; + assert_eq!(text(&mut r, source), "first", "throwing a caught error again makes it the last one"); + assert_eq!(r.run("h.error").unwrap(), JsValue::Null, "each run starts without one"); + assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(3.0)); + let source = "function outer() {\n return inner();\n}\nfunction inner() {\n return h.stack;\n}\nvar f = function () { return outer(); };\nf()"; + assert_eq!(text(&mut r, source), "[global]:8\n[anonymous]:7\nouter:2\ninner:5\n"); + let source = "function maker() {\n function named() {\n return h.stack;\n }\n return named;\n}\nvar later = maker();\nlater()"; + assert_eq!(text(&mut r, source), "[global]:8\nnamed:3\n", "a declared function keeps its name after its declaring function returns"); + let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0]"; + let lines = text(&mut r, deep); + assert_eq!(lines, "66,…", "64 frames, the line that some are left out, and the last line's end"); + assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned"); +} + +#[test] +fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() { + let mut r = Realm::new(); + let seen: Rc>> = Rc::default(); + let log = seen.clone(); + let get: HostFunction = Rc::new(move |_| Ok(Data::String("got".into()))); + let set: HostFunction = Rc::new(move |args| match args.first() { + Some(Data::Number(n)) if *n < 0.0 => Err(HostError::Range("no negatives".into())), + Some(d) => { + log.borrow_mut().push(d.clone()); + Ok(Data::Undefined) + } + None => Ok(Data::Undefined), + }); + r.define_host_object("h", vec![("p".into(), HostItem::Property { get, set })]).unwrap(); + assert_eq!(r.run("h.p = 5; h.p = {a: [1]}; h.p").unwrap(), JsValue::String("got".into())); + assert_eq!(*seen.borrow(), [Data::Number(5.0), Data::Object(vec![("a".into(), Data::Array(vec![Data::Number(1.0)]))])]); + let e = r.run("try { h.p = -1; } catch (e) { e instanceof RangeError && e.message }").unwrap(); + assert_eq!(e, JsValue::String("no negatives".into())); +} diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs new file mode 100644 index 000000000..cc22f1834 --- /dev/null +++ b/crates/js/src/file_classes.rs @@ -0,0 +1,864 @@ +//! `File` and `Folder`, the classes the help pages +//! and +//! describe, as built-ins +//! whose file system work a host does ([`crate::Realm::define_file_classes`]). +//! +//! The constructors build their objects with and without `new`: `new File(path)` is always a +//! `File` and `new Folder(path)` always a `Folder`, while `File(path)` gives a `Folder` when the +//! host's `stat` says the path is a folder, and `Folder(path)` a `File` when it is a file. Two +//! objects for one path are different objects. An object keeps its path in the host's platform +//! form, its `error` text, the id of the file it has open (or -1), the encoding set before `open` +//! and its line feed, in properties that are not enumerable; every name the getters give comes from +//! the host's `parse` of that path. `Folder.current` is kept in the realm and starts at the host's +//! `current`. +//! +//! A method whose host call fails sets `error` and returns `false`, `null` or an empty string. A +//! count or position that is not a finite number, a negative count and a seek mode other than 0, 1 +//! or 2 throw a `RangeError`; a method called on an object that is not a `File` or `Folder` throws +//! a `TypeError`. `getFiles` with a function calls it with each entry and keeps those it returns a +//! true value for; every entry takes fuel. + +use crate::HostItem; +use crate::builtins::{arg, constructor, data, method, native, put_at}; +use crate::heap::{CONFIGURABLE, Class, HIDDEN, NativeFn, NativeKind, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::Value; + +/// The objects of `File` and `Folder`, kept by the realm, and `Folder.current`. +#[derive(Clone, Debug)] +pub(crate) struct Classes { + file_proto: ObjId, + folder_proto: ObjId, + host: ObjId, + current: Value, +} + +impl Classes { + /// The objects a collection keeps. + pub(crate) fn objects(&self) -> [ObjId; 3] { + [self.file_proto, self.folder_proto, self.host] + } +} + +const PATH: &str = "__vcPath"; +const ERROR: &str = "__vcError"; +const HANDLE: &str = "__vcHandle"; +const ENCODING: &str = "__vcEncoding"; +const LINE_FEED: &str = "__vcLineFeed"; + +/// The locations `Folder` has as properties, each read from the host's `locations` when it is read. +const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"]; + +fn classes(i: &Interp) -> JsResult { + i.files.clone().ok_or_else(|| Abrupt::Internal("File and Folder are not installed".into())) +} + +/// Calls the host function `name` with `args`. +fn host(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let c = classes(i)?; + let f = i.get_value(&Value::Object(c.host), &key(name))?; + i.call(&f, &Value::Undefined, args) +} + +/// The host's message `name`. +fn message(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + let text = i.get_value(&Value::Object(c.host), &key("text"))?; + i.get_value(&text, &key(name)) +} + +fn field(i: &mut Interp, v: &Value, name: &str) -> JsResult { + i.get_value(v, &key(name)) +} + +/// The value of the own data property `name` of `o`, `undefined` when it has none. +fn slot(i: &Interp, o: ObjId, name: &str) -> JsResult { + Ok(match i.get_own_property(o, &key(name))? { + Some(Prop::Data { value, .. }) => value, + _ => Value::Undefined, + }) +} + +fn set_slot(i: &mut Interp, o: ObjId, name: &str, value: Value) -> JsResult<()> { + if let Value::String(s) = &value { + i.rooted(std::slice::from_ref(&value), |i| i.reserve_heap(PROP_BYTES + string_bytes(s.len())))?; + } + i.define(o, key(name), Prop::Data { value, flags: HIDDEN }) +} + +/// The object `this` and its path, or a `TypeError` naming `what` when it is not a `File` or +/// `Folder`. +fn this_path(i: &mut Interp, this: &Value, what: &str) -> JsResult<(ObjId, Value)> { + if let Some(o) = this.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((o, path)); + } + Err(i.type_error(&format!("{what}: the object is not a File or a Folder"))) +} + +/// The path `x` names, and whether it is already in the platform's form: the path of a `File` or +/// `Folder`, or `String(x)`, which may hold URI escapes. +fn path_text(i: &mut Interp, x: &Value) -> JsResult<(Value, bool)> { + if let Some(o) = x.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((path, true)); + } + let s = i.to_str(x)?; + Ok((Value::String(s), false)) +} + +/// The host's `parse` of the path `x` names, from `Folder.current`. +fn parse(i: &mut Interp, x: &Value) -> JsResult { + let (text, platform) = path_text(i, x)?; + parse_text(i, text, platform) +} + +/// The host's `parse` of `text`, from `Folder.current`; its URI escapes are decoded unless it is +/// already a `platform` path. +fn parse_text(i: &mut Interp, text: Value, platform: bool) -> JsResult { + let current = classes(i)?.current; + host(i, "parse", &[text, current, Value::Bool(platform)]) +} + +fn parsed(i: &mut Interp, this: &Value, what: &str, name: &str) -> JsResult { + let (_, path) = this_path(i, this, what)?; + let p = parse_text(i, path, true)?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, name)) +} + +/// A new object with prototype `proto` for the path `fs`, already in the platform's form. +fn instance(i: &mut Interp, proto: ObjId, fs: Value) -> JsResult { + let line_feed = host_value(i, "lineFeed")?; + i.rooted(&[fs.clone(), line_feed.clone()], |i| { + i.reserve_heap(OBJECT_BYTES + 5 * PROP_BYTES)?; + let o = i.heap.alloc_object(Object::new(Class::Ordinary, Some(proto))); + i.rooted(&[Value::Object(o)], |i| { + set_slot(i, o, PATH, fs.clone())?; + set_slot(i, o, ERROR, Value::str(""))?; + set_slot(i, o, HANDLE, Value::Number(-1.0))?; + set_slot(i, o, ENCODING, Value::str(""))?; + set_slot(i, o, LINE_FEED, line_feed.clone())?; + Ok(Value::Object(o)) + }) + }) +} + +fn host_value(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + i.get_value(&Value::Object(c.host), &key(name)) +} + +/// The host's `stat` of the object's path, or `None` after setting `error` when it failed. +fn stat(i: &mut Interp, this: &Value, what: &str) -> JsResult> { + let (o, path) = this_path(i, this, what)?; + let r = host(i, "stat", &[path])?; + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(None); + } + Ok(Some(r.clone())) + }) +} + +/// The field `name` of the object's `stat`, when it exists, or `missing`. +fn stat_field(i: &mut Interp, this: &Value, what: &str, name: &str, missing: Value) -> JsResult { + let Some(r) = stat(i, this, what)? else { return Ok(missing) }; + i.rooted(std::slice::from_ref(&r), |i| { + if !field(i, &r, "exists")?.truthy() && name != "exists" { + return Ok(missing); + } + field(i, &r, name) + }) +} + +/// The result of a host read: its `text`, or `failed` after setting `error`. +fn text_result(i: &mut Interp, o: ObjId, r: Value, failed: Value) -> JsResult { + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(failed); + } + field(i, &r, "text") + }) +} + +/// The id of the object's open file, or `None` after setting `error` when it has none. +fn opened(i: &mut Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + if matches!(h, Value::Number(n) if n >= 0.0) { + return Ok(Some(h)); + } + let m = message(i, "notOpen")?; + set_slot(i, o, ERROR, m)?; + Ok(None) +} + +fn is_open(i: &Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + Ok(matches!(h, Value::Number(n) if n >= 0.0).then_some(h)) +} + +/// Closes the object's open file, if it has one. +fn close_open(i: &mut Interp, o: ObjId) -> JsResult<()> { + if let Some(h) = is_open(i, o)? { + host(i, "close", &[h])?; + } + set_slot(i, o, HANDLE, Value::Number(-1.0)) +} + +fn date(i: &mut Interp, ms: Value) -> JsResult { + let Value::Number(t) = ms else { return Ok(Value::Null) }; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.date_proto; + Ok(Value::Object(i.heap.alloc_object(Object::new(Class::Date(t), Some(proto))))) +} + +/// A count of characters: `None` for `undefined`, else a whole number 0 or more. +fn count(i: &mut Interp, v: &Value, what: &str) -> JsResult> { + if matches!(v, Value::Undefined) { + return Ok(None); + } + let n = i.to_number(v)?; + if !n.is_finite() || n < 0.0 { + return Err(i.throw(ErrorType::Range, &format!("{what}: the count must be a finite number, 0 or more"))); + } + Ok(Some(n.floor())) +} + +// ---- constructors ---- + +fn file_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, true) +} + +fn folder_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, false) +} + +/// `new File(path)`, `File(path)`, `new Folder(path)` or `Folder(path)`. +fn make(i: &mut Interp, args: &[Value], construct: bool, file: bool) -> JsResult { + let c = classes(i)?; + let (own, other) = if file { (c.file_proto, c.folder_proto) } else { (c.folder_proto, c.file_proto) }; + let (path, platform) = match arg(args, 0) { + Value::Undefined if file => (host(i, "tempName", &[])?, true), + Value::Undefined => (c.current.clone(), true), + v => (v, false), + }; + let fs = i.rooted(std::slice::from_ref(&path), |i| { + let p = if platform { parse_text(i, path.clone(), true)? } else { parse(i, &path)? }; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs")) + })?; + let proto = if construct { + own + } else { + let st = i.rooted(std::slice::from_ref(&fs), |i| host(i, "stat", std::slice::from_ref(&fs)))?; + let flip = i.rooted(&[fs.clone(), st.clone()], |i| { + let error = field(i, &st, "error")?; + let exists = field(i, &st, "exists")?.truthy(); + let dir = field(i, &st, "dir")?.truthy(); + Ok(matches!(error, Value::Undefined) && exists && dir == file) + })?; + if flip { other } else { own } + }; + instance(i, proto, fs) +} + +// ---- both classes ---- + +fn absolute_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "absoluteURI", "uri") +} + +fn fs_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(this_path(i, &this, "fsName")?.1) +} + +fn relative_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "relativeURI")?; + let current = classes(i)?.current; + host(i, "relative", &[path, current]) +} + +fn name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "name", "name") +} + +fn display_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "displayName", "display") +} + +fn path(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "path", "path") +} + +fn parent(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let up = parsed(i, &this, "parent", "parent")?; + if matches!(up, Value::Null | Value::Undefined) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, up) +} + +fn exists(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "exists", "exists", Value::Bool(false))?.truthy())) +} + +fn created(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "created", "created", Value::Null)?; + date(i, ms) +} + +fn modified(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "modified", "modified", Value::Null)?; + date(i, ms) +} + +fn alias(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "alias")? else { return Ok(Value::Bool(false)) }; + let v = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "alias"))?; + Ok(Value::Bool(v.truthy())) +} + +fn get_error(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + slot(i, o, ERROR) +} + +fn set_error(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + let v = match arg(args, 0) { + Value::Undefined | Value::Null => Value::str(""), + v => Value::String(i.to_str(&v)?), + }; + set_slot(i, o, ERROR, v)?; + Ok(Value::Undefined) +} + +fn get_relative_uri(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "getRelativeURI")?; + let base = match arg(args, 0) { + Value::Undefined => classes(i)?.current, + b => { + let p = i.rooted(std::slice::from_ref(&path), |i| parse(i, &b))?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))? + } + }; + host(i, "relative", &[path, base]) +} + +fn change_path(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "changePath")?; + close_open(i, o)?; + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + set_slot(i, o, PATH, fs)?; + Ok(Value::Bool(true)) +} + +fn resolve(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "resolve")?; + let r = host(i, "resolve", &[path])?; + let (error, fs, dir) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "fs")?, field(i, &r, "dir")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let c = classes(i)?; + instance(i, if dir.truthy() { c.folder_proto } else { c.file_proto }, fs) +} + +fn execute(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "execute")?; + let r = host(i, "execute", &[path])?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + set_slot(i, o, ERROR, error)?; + Ok(Value::Bool(false)) +} + +fn to_string(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "toString", "uri") +} + +/// A method that writes: it sets `error` and returns `false`. +fn no_writes(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "write")?; + let m = message(i, "noWrites")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Bool(false)) +} + +/// A setter that writes: it sets `error` and changes nothing. +fn no_writes_setter(i: &mut Interp, this: Value, args: &[Value], construct: bool) -> JsResult { + no_writes(i, this, args, construct)?; + Ok(Value::Undefined) +} + +/// A dialog method: it sets `error` and returns `null`. +fn no_picker(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "dialog")?; + let m = message(i, "noPicker")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Null) +} + +fn null_static(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Null) +} + +fn text_static(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let s = i.to_str(&arg(args, 0))?; + host(i, name, &[Value::String(s)]) +} + +fn encode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encode", args) +} + +fn decode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "decode", args) +} + +fn encoding_available(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encodingAvailable", args) +} + +// ---- File ---- + +fn length(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "length")? else { return Ok(Value::Number(0.0)) }; + i.rooted(std::slice::from_ref(&r), |i| { + let regular = field(i, &r, "exists")?.truthy() && field(i, &r, "file")?.truthy(); + if regular { field(i, &r, "length") } else { Ok(Value::Number(0.0)) } + }) +} + +fn hidden(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "hidden", "hidden", Value::Bool(false))?.truthy())) +} + +fn readonly(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "readonly", "readonly", Value::Bool(false))?.truthy())) +} + +fn four_question_marks(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_path(i, &this, "creator")?; + Ok(Value::str("????")) +} + +fn eof(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "eof")?; + match is_open(i, o)? { + Some(h) => host(i, "eof", &[h]), + None => Ok(Value::Bool(true)), + } +} + +fn get_line_feed(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + slot(i, o, LINE_FEED) +} + +fn set_line_feed(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + let v = i.to_str(&arg(args, 0))?; + set_slot(i, o, LINE_FEED, Value::String(v))?; + Ok(Value::Undefined) +} + +fn get_encoding(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + if let Some(h) = is_open(i, o)? { + return host(i, "encoding", &[h]); + } + Ok(match slot(i, o, ENCODING)? { + Value::String(s) if s.len() != 0 => Value::String(s), + _ => Value::str("UTF-8"), + }) +} + +fn set_encoding(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + let v = Value::String(i.to_str(&arg(args, 0))?); + match is_open(i, o)? { + Some(h) => { + let r = i.rooted(std::slice::from_ref(&v), |i| host(i, "setEncoding", &[h, v.clone()]))?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + } else { + set_slot(i, o, ENCODING, v)?; + } + } + None => set_slot(i, o, ENCODING, v)?, + } + Ok(Value::Undefined) +} + +fn open(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "open")?; + let mode = match arg(args, 0) { + Value::Undefined => "r".to_string(), + m => i.to_str(&m)?.to_rust_string().to_lowercase(), + }; + let refusal = match mode.as_str() { + "r" => None, + "w" | "e" | "a" => Some("noWrites"), + _ => Some("badMode"), + }; + if let Some(name) = refusal { + let m = message(i, name)?; + set_slot(i, o, ERROR, m)?; + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + let encoding = slot(i, o, ENCODING)?; + let r = host(i, "open", &[path, encoding])?; + let (error, id) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "id")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Bool(false)); + } + set_slot(i, o, HANDLE, id)?; + Ok(Value::Bool(true)) +} + +fn close(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "close")?; + if opened(i, o)?.is_none() { + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + Ok(Value::Bool(true)) +} + +fn read(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "read")?; + let n = count(i, &arg(args, 0), "File.read")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "read", &[h, n.map_or(Value::Number(-1.0), Value::Number)])?; + text_result(i, o, r, Value::str("")) +} + +fn read_line(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readln")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readln", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn read_char(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readch")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readch", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn seek(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "seek")?; + let pos = i.to_number(&arg(args, 0))?; + if !pos.is_finite() { + return Err(i.throw(ErrorType::Range, "File.seek: the position must be a finite number")); + } + let mode = match arg(args, 1) { + Value::Undefined => 0.0, + m => i.to_number(&m)?, + }; + if mode != 0.0 && mode != 1.0 && mode != 2.0 { + return Err(i.throw(ErrorType::Range, "File.seek: the mode must be 0, 1 or 2")); + } + let Some(h) = opened(i, o)? else { return Ok(Value::Bool(false)) }; + host(i, "seek", &[h, Value::Number(pos.floor()), Value::Number(mode)]) +} + +fn tell(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "tell")?; + let Some(h) = opened(i, o)? else { return Ok(Value::Number(-1.0)) }; + host(i, "tell", &[h]) +} + +// ---- Folder ---- + +fn get_files(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "getFiles")?; + let mask = arg(args, 0); + let call = i.is_callable(&mask); + let pattern = if call || matches!(mask, Value::Undefined) { Value::str("*") } else { Value::String(i.to_str(&mask)?) }; + let r = host(i, "list", &[path, pattern])?; + i.rooted(&[r.clone(), mask.clone()], |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + let entries = field(i, &r, "entries")?; + let length = field(i, &entries, "length")?; + let n = i.to_number(&length)?; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.array_proto; + let out = i.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + i.rooted(&[Value::Object(out)], |i| { + let c = classes(i)?; + let mut kept = 0.0; + let mut at = 0.0; + while at < n { + i.use_fuel()?; + let e = i.get_value(&entries, &crate::value::number_to_str(at).flat())?; + let (fs, dir) = i.rooted(std::slice::from_ref(&e), |i| Ok((field(i, &e, "fs")?, field(i, &e, "dir")?.truthy())))?; + let item = instance(i, if dir { c.folder_proto } else { c.file_proto }, fs)?; + let keep = if call { + i.rooted(std::slice::from_ref(&item), |i| i.call(&mask, &Value::Undefined, std::slice::from_ref(&item)))?.truthy() + } else { + true + }; + if keep { + put_at(i, out, kept, item)?; + kept += 1.0; + } + at += 1.0; + } + Ok(Value::Object(out)) + }) + }) +} + +fn get_current(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let c = classes(i)?; + instance(i, c.folder_proto, c.current) +} + +fn set_current(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + if let Some(c) = i.files.as_mut() { + c.current = fs; + } + Ok(Value::Undefined) +} + +/// `Folder.`: a new `Folder` for the host's location `place`, or `null`. +fn place(i: &mut Interp, place: &str) -> JsResult { + let all = host(i, "locations", &[])?; + let fs = i.rooted(std::slice::from_ref(&all), |i| field(i, &all, place))?; + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, fs) +} + +macro_rules! places { + ($($f:ident => $name:literal),* $(,)?) => { + $(fn $f(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + place(i, $name) + })* + /// The getter of each of [`PLACES`], in its order. + const PLACE_GETTERS: [NativeFn; 10] = [$($f),*]; + }; +} + +places!( + app_data => "appData", + app_package => "appPackage", + common_files => "commonFiles", + desktop => "desktop", + my_documents => "myDocuments", + startup => "startup", + system => "system", + temp => "temp", + trash => "trash", + user_data => "userData", +); + +// ---- install ---- + +/// Defines the accessor `name` on `on`, not enumerable, as the help pages' objects have them. +fn accessor(i: &mut Interp, on: ObjId, name: &'static str, get: NativeFn, set: Option) -> JsResult<()> { + let g = native(i, name, get, 0, NativeKind::Plain, false)?; + i.rooted(&[Value::Object(g)], |i| { + let s = match set { + Some(f) => Some(native(i, name, f, 1, NativeKind::Plain, false)?), + None => None, + }; + i.define(on, key(name), Prop::Accessor { get: Some(g), set: s, flags: CONFIGURABLE }) + }) +} + +/// What `File` and `Folder` both have. +fn common(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "absoluteURI", absolute_uri, None)?; + accessor(i, proto, "fullName", absolute_uri, None)?; + accessor(i, proto, "fsName", fs_name, None)?; + accessor(i, proto, "relativeURI", relative_uri, None)?; + accessor(i, proto, "name", name, None)?; + accessor(i, proto, "displayName", display_name, None)?; + accessor(i, proto, "path", path, None)?; + accessor(i, proto, "parent", parent, None)?; + accessor(i, proto, "exists", exists, None)?; + accessor(i, proto, "created", created, None)?; + accessor(i, proto, "modified", modified, None)?; + accessor(i, proto, "alias", alias, None)?; + accessor(i, proto, "error", get_error, Some(set_error))?; + method(i, proto, "toString", to_string, 0)?; + method(i, proto, "getRelativeURI", get_relative_uri, 1)?; + method(i, proto, "changePath", change_path, 1)?; + method(i, proto, "resolve", resolve, 0)?; + method(i, proto, "execute", execute, 0)?; + method(i, proto, "remove", no_writes, 0)?; + method(i, proto, "rename", no_writes, 1)?; + method(i, ctor, "encode", encode, 1)?; + method(i, ctor, "decode", decode, 1)?; + method(i, ctor, "isEncodingAvailable", encoding_available, 1)?; + let system = host_value(i, "system")?; + data(i, ctor, "fs", system, HIDDEN) +} + +fn file_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "length", length, Some(no_writes_setter))?; + accessor(i, proto, "hidden", hidden, Some(no_writes_setter))?; + accessor(i, proto, "readonly", readonly, Some(no_writes_setter))?; + accessor(i, proto, "creator", four_question_marks, None)?; + accessor(i, proto, "type", four_question_marks, None)?; + accessor(i, proto, "eof", eof, None)?; + accessor(i, proto, "lineFeed", get_line_feed, Some(set_line_feed))?; + accessor(i, proto, "encoding", get_encoding, Some(set_encoding))?; + method(i, proto, "open", open, 1)?; + method(i, proto, "close", close, 0)?; + method(i, proto, "read", read, 1)?; + method(i, proto, "readln", read_line, 0)?; + method(i, proto, "readch", read_char, 0)?; + method(i, proto, "seek", seek, 2)?; + method(i, proto, "tell", tell, 0)?; + method(i, proto, "write", no_writes, 1)?; + method(i, proto, "writeln", no_writes, 1)?; + method(i, proto, "copy", no_writes, 1)?; + method(i, proto, "createAlias", no_writes, 1)?; + method(i, proto, "openDlg", no_picker, 0)?; + method(i, proto, "saveDlg", no_picker, 0)?; + method(i, ctor, "openDialog", null_static, 0)?; + method(i, ctor, "saveDialog", null_static, 0) +} + +fn folder_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + method(i, proto, "getFiles", get_files, 1)?; + method(i, proto, "create", no_writes, 0)?; + method(i, proto, "selectDlg", no_picker, 0)?; + method(i, ctor, "selectDialog", null_static, 0)?; + accessor(i, ctor, "current", get_current, Some(set_current))?; + for (place, get) in PLACES.into_iter().zip(PLACE_GETTERS) { + accessor(i, ctor, place, get, None)?; + } + Ok(()) +} + +/// Adds `File` and `Folder` to the global object, their work done by the host functions in +/// `items` ([`crate::Realm::define_file_classes`]). +pub(crate) fn install(i: &mut Interp, items: Vec<(String, HostItem)>) -> JsResult<()> { + let host_obj = i.host_object("File", items)?; + let object_proto = i.intrinsics.object_proto; + i.rooted(&[Value::Object(host_obj)], |i| { + i.reserve_heap(2 * OBJECT_BYTES)?; + let file_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + let folder_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str("") }); + let start = host(i, "current", &[])?; + if let Some(c) = i.files.as_mut() { + c.current = start; + } + let file = native(i, "File", file_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "File", file, file_proto)?; + let folder = native(i, "Folder", folder_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "Folder", folder, folder_proto)?; + common(i, file, file_proto)?; + common(i, folder, folder_proto)?; + file_members(i, file, file_proto)?; + folder_members(i, folder, folder_proto) + }) +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{Data, HostItem, JsValue, Realm}; + + /// A host whose paths are `/` and a name, where `/d` is a folder holding `a` and `b` and every + /// other path is missing. + fn realm() -> Realm { + let f = |g: fn(&[Data]) -> Data| HostItem::Function(Rc::new(move |a: Vec| Ok(g(&a)))); + let text = |a: &[Data], i: usize| match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + }; + let obj = |fields: Vec<(&str, Data)>| Data::Object(fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); + let parse = move |a: &[Data]| { + let t = text(a, 0); + let fs = if t.starts_with('/') { t } else { format!("/{t}") }; + let name = fs.trim_start_matches('/').to_string(); + obj(vec![ + ("fs", Data::String(fs.clone())), + ("uri", Data::String(fs)), + ("name", Data::String(name.clone())), + ("display", Data::String(name)), + ("path", Data::String("/".into())), + ("parent", Data::String("/".into())), + ]) + }; + let messages = ["notOpen", "noWrites", "noPicker", "badMode"].map(|k| (k.to_string(), HostItem::Value(Data::String(k.into())))); + let stat = move |a: Vec| -> Result { + let dir = text(&a, 0) == "/d"; + Ok(obj(vec![("exists", Data::Bool(dir)), ("dir", Data::Bool(dir))])) + }; + let list = move |_: Vec| -> Result { + let e = |n: &str| obj(vec![("fs", Data::String(format!("/d/{n}"))), ("dir", Data::Bool(false))]); + Ok(obj(vec![("entries", Data::Array(vec![e("a"), e("b")]))])) + }; + let mut r = Realm::new(); + let items: Vec<(String, HostItem)> = vec![ + ("text".into(), HostItem::Object(messages.to_vec())), + ("system".into(), HostItem::Value(Data::String("Unix".into()))), + ("lineFeed".into(), HostItem::Value(Data::String("Unix".into()))), + ("current".into(), f(|_| Data::String("/".into()))), + ("parse".into(), HostItem::Function(Rc::new(move |a: Vec| Ok(parse(&a))))), + ("stat".into(), HostItem::Function(Rc::new(stat))), + ("list".into(), HostItem::Function(Rc::new(list))), + ("tempName".into(), f(|_| Data::String("/tmp1".into()))), + ("locations".into(), f(|_| Data::Object(vec![]))), + ]; + r.define_file_classes(items).unwrap(); + r + } + + #[test] + fn objects_keep_their_classes_through_collections() { + let mut r = realm(); + r.run("var kept = new File('x'); var listed = new Folder('d').getFiles(); File = null; Folder = null;").unwrap(); + r.collect_garbage(); + r.run("var many = []; for (var i = 0; i < 2000; i++) many.push(kept.parent);").unwrap(); + r.collect_garbage(); + let v = r.run( + "[kept.name, kept.fsName, listed.length, listed[1].name, kept.parent.getFiles(function (f) { return f.name === 'd/a'; }).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("x,/x,2,d/b,1".into())); + } + + #[test] + fn the_constructor_picks_the_class_only_without_new() { + let mut r = realm(); + let v = r.run( + "var F = File, D = Folder; [F('d') instanceof D, new F('d') instanceof F, D('x') instanceof D, \ + typeof Object.keys(new F('x')).length, Object.keys(new F('x')).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("true,true,true,number,0".into())); + let e = r.run("File.prototype.read.call({})").unwrap_err(); + assert!(e.message.contains("is not a File or a Folder"), "{e:?}"); + } +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index a53721fc7..dea53c557 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -175,12 +175,16 @@ pub(crate) enum NativeKind { /// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section /// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given. +/// `Data` is a host function given [`crate::Data`], its arguments read as `read` says. `Eval` is a +/// host function that gives source to run as global code ([`crate::HostItem::Evaluator`]); `end` +/// is called when that code ends. #[derive(Clone)] pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, - Data { f: DataFn, name: Rc }, + Data { f: DataFn, name: Rc, read: crate::host::ArgRead }, + Eval { f: DataFn, end: Rc, strict_writes: Rc Option>, name: Rc }, Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } @@ -224,12 +228,84 @@ pub(crate) enum Class { Math, /// The `JSON` object (section 15.12). Json, + /// An XML value (ECMA-357 section 9.1). + Xml(Box), + /// An XMLList value (ECMA-357 section 9.2). + XmlList(Box), + Namespace(Box), + /// A QName; `attribute` marks an attribute name (ECMA-357 sections 13.3 and 9.3), and + /// `reference` a name made by E4X syntax, which only an XML value can be accessed with. + QName { + name: Box, + attribute: bool, + reference: bool, + }, + /// A `.( )` filter in progress. + XmlFilter(Box), +} + +/// What an XML value is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum XmlKind { + Element, + Attribute, + Text, + Comment, + Pi, +} + +/// A qualified name. `uri` is `None` for a name that matches any namespace. +#[derive(Clone, Debug)] +pub(crate) struct XmlName { + pub(crate) uri: Option, + pub(crate) local: Key, + pub(crate) prefix: Option, +} + +/// A namespace. `prefix` is `None` when it is undefined. +#[derive(Clone, Debug)] +pub(crate) struct XmlNs { + pub(crate) prefix: Option, + pub(crate) uri: Key, +} + +/// An XML value: a node of a tree, linked by object index. +#[derive(Clone)] +pub(crate) struct XmlNode { + pub(crate) kind: XmlKind, + pub(crate) name: Option, + pub(crate) parent: Option, + pub(crate) value: Str, + pub(crate) attrs: Vec, + pub(crate) children: Vec, + pub(crate) ns: Vec, +} + +/// An XMLList value: its items, and the object and name a write to an empty list targets. +#[derive(Clone)] +pub(crate) struct XmlListData { + pub(crate) items: Vec, + pub(crate) target: Option, + pub(crate) target_name: Option<(XmlName, bool)>, +} + +/// The state of a `.( )` filter: the items to test, the next one's position and those kept. +#[derive(Clone)] +pub(crate) struct XmlFilterState { + pub(crate) input: Vec, + pub(crate) pos: usize, + pub(crate) kept: Vec, } impl Class { /// The `[[Class]]` name `Object.prototype.toString` reports. pub(crate) fn name(&self) -> &'static str { match self { + Class::Xml(_) => "XML", + Class::XmlList(_) => "XMLList", + Class::Namespace(_) => "Namespace", + Class::QName { .. } => "QName", + Class::XmlFilter(_) => "Object", Class::Math => "Math", Class::Json => "JSON", Class::Ordinary | Class::ForIn(_) => "Object", @@ -307,6 +383,14 @@ pub(crate) const ENV_BYTES: usize = 64; pub(crate) const ROPE_BYTES: usize = 48; /// The estimated bytes of each argument a bound function holds. pub(crate) const BOUND_ARG_BYTES: usize = 24; +/// The estimated bytes of an XML node without its names and text. +pub(crate) const XML_NODE_BYTES: usize = 160; +/// The estimated bytes of an XML list without its items. +pub(crate) const XML_LIST_BYTES: usize = 96; +/// The estimated bytes of a reference from an XML node or list to another. +pub(crate) const XML_REF_BYTES: usize = 8; +/// The estimated bytes of a namespace an XML node holds. +pub(crate) const XML_NS_BYTES: usize = 48; /// The estimated bytes of a piece of code without its instructions, tables and strings. const CODE_BYTES: usize = 384; @@ -525,7 +609,7 @@ fn mark_code(code: &Code, strings: &mut StringSizes) { strings.add(flags); } let keys = code.names.iter().chain(&code.params).chain(&code.vars).chain(code.fun_decls.iter().map(|(k, _)| k)); - for k in keys.chain(&code.self_name) { + for k in keys.chain(&code.self_name).chain(&code.decl_name) { strings.add_key(k); } stack.extend(code.functions.iter().map(|f| &**f)); @@ -568,11 +652,45 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u work.extend(state.object.iter().map(|o| o.0)); strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::(); } + Class::Xml(node) => { + work.extend(node.parent.iter().chain(&node.attrs).chain(&node.children).map(|o| o.0)); + strings.add(&node.value); + if let Some(name) = &node.name { + add_name(name, strings); + } + for ns in &node.ns { + add_ns(ns, strings); + } + extra = XML_NODE_BYTES + XML_REF_BYTES * (node.attrs.len() + node.children.len()) + XML_NS_BYTES * node.ns.len(); + } + Class::XmlList(list) => { + work.extend(list.target.iter().chain(&list.items).map(|o| o.0)); + if let Some((name, _)) = &list.target_name { + add_name(name, strings); + } + extra = XML_LIST_BYTES + XML_REF_BYTES * list.items.len(); + } + Class::Namespace(ns) => add_ns(ns, strings), + Class::QName { name, .. } => add_name(name, strings), + Class::XmlFilter(state) => { + work.extend(state.input.iter().chain(&state.kept).map(|o| o.0)); + extra = XML_LIST_BYTES + XML_REF_BYTES * (state.input.len() + state.kept.len()); + } _ => {} } OBJECT_BYTES + PROP_BYTES * o.props.len() + extra } +fn add_name(name: &XmlName, strings: &mut StringSizes) { + strings.add_key(&name.local); + name.uri.iter().chain(&name.prefix).for_each(|k| strings.add_key(k)); +} + +fn add_ns(ns: &XmlNs, strings: &mut StringSizes) { + strings.add_key(&ns.uri); + ns.prefix.iter().for_each(|k| strings.add_key(k)); +} + fn trace_env(e: &Env, work: &mut Vec, strings: &mut StringSizes) -> usize { match e { Env::Decl { bindings, outer } => { diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 457b2ce2e..4e0ef7e78 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -55,23 +55,97 @@ pub enum HostError { Type(String), /// Thrown to the script as a `RangeError`. Range(String), + /// Thrown to the script as a `SyntaxError`. + Syntax(String), /// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it. Abort(String), + /// Pauses the run at this call: [`crate::Realm::advance`] returns [`crate::Progress::Paused`], + /// and the call's result is the value [`crate::Realm::answer`] gives it. A function may return + /// it only while [`crate::Realm::wait_flag`] is set; otherwise the run ends with an internal + /// error. + Wait, + /// `error`, with a mark the host chose. When the run ends with the exception `error` throws, + /// or with the limit it ends the run with, [`crate::Realm::error_mark`] gives `mark`. A `catch` + /// clause that takes the exception drops the mark, so a `throw` of the caught value later + /// raises it without one; a `finally` block that ends normally keeps it. + Marked { error: Box, mark: u32 }, } /// A function the host gives a script, with its arguments converted. pub type HostFunction = Rc) -> Result>; +/// How one argument of a [`HostItem::Converted`] function is read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arg { + /// The string `String(value)` gives. + Text, + /// `undefined` as [`Data::Undefined`], any other value as the string `String(value)` gives. + TextOrUndefined, + /// The boolean `Boolean(value)` gives. + Bool, +} + +/// How the arguments of a host function are read. +#[derive(Clone)] +pub(crate) enum ArgRead { + /// Each as [`Data`]. + Data, + /// Each as the string `String(value)` gives. + Text, + /// One by one as the list says; those past its end are not read. + Each(Rc<[Arg]>), +} + /// A member of an object the host adds to the global environment. #[derive(Clone)] pub enum HostItem { /// A read-only copy of a value. Value(Data), Function(HostFunction), + /// A function whose arguments arrive as strings, each the string `String(value)` gives. + TextFunction(HostFunction), + /// A function whose arguments arrive converted one by one as the list says. Arguments past the + /// end of the list are not read and do not reach the function. + Converted(Vec, HostFunction), /// A property whose value the function gives each time it is read. Getter(HostFunction), + /// A property whose value `get` gives each time it is read, and that `set` is given each value + /// assigned to it, as its one argument; an error from `set` is thrown to the script. + Property { + get: HostFunction, + set: HostFunction, + }, /// A frozen object with these members. Object(Vec<(String, HostItem)>), + /// A function whose host function gives JavaScript source as a string, which then runs as + /// global code in the realm, as an indirect `eval` runs it, with the run's fuel and limits: + /// the call gives that code's completion value, and its declarations become globals. A result + /// that is not a string is the call's result. `end` is called when the source has run, + /// normally or not. Each source given has the next tag of [`crate::Realm::error_source`], + /// from 1. `strict_writes`, called after `source`, gives [`crate::Realm::set_strict_writes`] + /// for that source alone; with `None` it takes the calling code's. + Evaluator { + source: HostFunction, + end: Rc, + strict_writes: Rc Option>, + }, + /// A read-only property that reads the interpreter's own state. + Script(ScriptItem), +} + +/// The interpreter's own state a host object can show. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScriptItem { + /// The global object itself, with every global the script has made. + Global, + /// The last `Error` object thrown in the current run, caught or not, or `null` before one. + LastError, + /// The line of the script's code that reads it, counted from 1. + Line, + /// The running frames of the script's code, outermost first, one a line as `name:line`: the + /// global code is `[global]` and a function without a name `[anonymous]`. At most 64 frames + /// are listed, the innermost ones, after a line `…` when some are left out. + Stack, } enum Step { @@ -275,27 +349,127 @@ impl Interp { Ok(Value::Object(id)) } - /// Calls host function `f`: reads its arguments, runs it, and gives its result to the script. - pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value]) -> JsResult { + /// Calls host function `f`: reads its arguments as `how` says, runs it, and gives its result to + /// the script. + pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], how: &ArgRead) -> JsResult { + let data = self.read_args(name, args, how)?; + // Only the script's own code, in a run that can pause, can wait for a result. + let can_wait = self.in_slice && self.reentry == 0; + self.can_wait.set(can_wait); + let result = f(data); + self.can_wait.set(false); + match result { + Ok(d) => self.write_data(d, 0, false), + Err(HostError::Wait) if can_wait => { + self.waiting = true; + self.pause.set(true); + Ok(Value::Undefined) + } + Err(HostError::Wait) => Err(Abrupt::Internal(format!("{name} waited for its result where the run cannot pause"))), + Err(e) => Err(self.host_error(e)?), + } + } + + /// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the + /// source it gives as global code, as an indirect `eval` does, giving that code's completion + /// value. A result that is not a string is given to the script as it is. `end` is called once + /// the source has run, normally or not. `strict_writes` gives the source's + /// [`crate::bytecode::Source::strict_writes`], or `None` for the calling code's. + pub(crate) fn call_evaluator( + &mut self, + f: &crate::heap::DataFn, + end: &Rc, + strict_writes: &Rc Option>, + name: &Rc, + args: &[Value], + ) -> JsResult { + let data = self.read_args(name, args, &ArgRead::Data)?; + let source = match f(data) { + Ok(Data::String(source)) => source, + Ok(d) => return self.write_data(d, 0, false), + Err(e) => return Err(self.host_error(e)?), + }; + let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); + self.evaluated = self.evaluated.saturating_add(1); + let tag = std::mem::replace(&mut self.source_tag, self.evaluated); + let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval)); + self.source_tag = tag; + let r = match code { + Ok(code) => self.run_global(code, true), + Err(e) => Err(self.compile_error_to_abrupt(e)), + }; + end(); + r + } + + /// The arguments of host function `name` as [`Data`], read as `how` says. + fn read_args(&mut self, name: &Rc, args: &[Value], how: &ArgRead) -> JsResult> { let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() }; + let args = match how { + ArgRead::Each(list) => args.get(..list.len().min(args.len())).unwrap_or(args), + _ => args, + }; let mut data = Vec::with_capacity(args.len()); + // A conversion can run the script's own `toString`, which may collect garbage. + let mark = self.temp_roots.len(); + if !matches!(how, ArgRead::Data) { + self.temp_roots.extend(args.iter().cloned()); + } + let mut read = Ok(()); for (i, a) in args.iter().enumerate() { reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); - data.push(self.read_data(a, &mut reading)?); + let d = match how { + ArgRead::Data => self.read_data(a, &mut reading), + ArgRead::Text => self.read_text(a, &mut reading), + ArgRead::Each(list) => match list.get(i) { + Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined), + Some(Arg::Bool) => Ok(Data::Bool(a.truthy())), + _ => self.read_text(a, &mut reading), + }, + }; + match d { + Ok(d) => data.push(d), + Err(e) => { + read = Err(e); + break; + } + } reading.path.pop(); } - match f(data) { - Ok(d) => self.write_data(d, 0, false), - Err(HostError::Abort(message)) => { + self.temp_roots.truncate(mark); + read?; + Ok(data) + } + + /// What host error `e` throws, or the limit it ends the run with. A function that waits + /// ([`HostError::Wait`]) where its caller does not handle the wait ends the run with an internal + /// error. + fn host_error(&mut self, e: HostError) -> JsResult { + match e { + HostError::Wait => Ok(Abrupt::Internal("a host function waited for its result where the run cannot pause".into())), + HostError::Abort(message) => { self.host_abort = Some(message); - Err(Abrupt::Limit(LimitKind::Host)) + Ok(Abrupt::Limit(LimitKind::Host)) + } + HostError::Error(message) => self.throw_host(ErrorType::Error, &message), + HostError::Type(message) => self.throw_host(ErrorType::Type, &message), + HostError::Range(message) => self.throw_host(ErrorType::Range, &message), + HostError::Syntax(message) => self.throw_host(ErrorType::Syntax, &message), + HostError::Marked { error, mark } => { + let abrupt = self.host_error(*error)?; + self.thrown_mark = Some(mark); + Ok(abrupt) } - Err(HostError::Error(message)) => Err(self.throw_host(ErrorType::Error, &message)?), - Err(HostError::Type(message)) => Err(self.throw_host(ErrorType::Type, &message)?), - Err(HostError::Range(message)) => Err(self.throw_host(ErrorType::Range, &message)?), } } + /// `v` as the string `String(v)` gives, within the limits of a crossing. + fn read_text(&mut self, v: &Value, r: &mut Reading) -> JsResult { + let s = self.to_str(v)?; + self.charge_reading(r, s.len().saturating_mul(3).saturating_add(2))?; + Ok(Data::String(s.to_rust_string())) + } + /// The exception for a host error, charged to the heap. fn throw_host(&mut self, t: ErrorType, message: &str) -> JsResult { let bytes = string_bytes(message.encode_utf16().count()); @@ -311,7 +485,7 @@ impl Interp { self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: 0 }) } - fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { + pub(crate) fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { let o = self.new_object(); let mark = self.temp_roots.len(); self.temp_roots.push(Value::Object(o)); @@ -332,16 +506,46 @@ impl Interp { let full = format!("{path}.{name}"); let prop = match item { HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, - HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full)), flags: ENUMERABLE }, - HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full)), set: None, flags: ENUMERABLE }, + HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, + HostItem::Property { get, set } => Prop::Accessor { + get: Some(self.host_function(get, &full, ArgRead::Data)), + set: Some(self.host_function(set, &full, ArgRead::Data)), + flags: ENUMERABLE, + }, HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, + HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Data)), flags: ENUMERABLE }, + HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Text)), flags: ENUMERABLE }, + HostItem::Converted(list, f) => { + Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE } + } + HostItem::Evaluator { source, end, strict_writes } => { + let proto = self.intrinsics.function_proto; + let func = Func::Eval { f: source, end, strict_writes, name: full.into() }; + let f = self.heap.alloc_object(Object::new(Class::Function(func), Some(proto))); + Prop::Data { value: Value::Object(f), flags: ENUMERABLE } + } + HostItem::Script(item) => match crate::builtins::extensions::getter(item) { + None => Prop::Data { value: Value::Object(self.global), flags: ENUMERABLE }, + Some((name, f)) => { + let get = crate::builtins::native(self, name, f, 0, crate::heap::NativeKind::Plain, false)?; + Prop::Accessor { get: Some(get), set: None, flags: ENUMERABLE } + } + }, }; self.define(o, key(&name), prop) } - fn host_function(&mut self, f: HostFunction, name: &str) -> ObjId { + /// Adds the global function `name` with its arguments converted as `args` says. A script may + /// replace or delete it, as it may a built-in function. + pub(crate) fn install_host_function(&mut self, name: &str, args: Vec, f: HostFunction) -> JsResult<()> { + let o = self.host_function(f, name, ArgRead::Each(args.into())); + let global = self.global; + self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: crate::heap::HIDDEN }) + } + + fn host_function(&mut self, f: HostFunction, name: &str, read: ArgRead) -> ObjId { let proto = self.intrinsics.function_proto; - self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into() }), Some(proto))) + self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into(), read }), Some(proto))) } } diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 85e178d87..c699ea285 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -223,6 +223,25 @@ fn a_getter_is_read_each_time() { assert_eq!(r.run("host.n + host.n").unwrap(), JsValue::Number(3.0)); } +#[test] +fn a_text_function_takes_each_argument_as_string_gives_it() { + let mut r = realm(1_000_000); + let seen: Rc>> = Rc::default(); + let log = seen.clone(); + let f: HostFunction = Rc::new(move |args| { + log.borrow_mut().extend(args); + Ok(Data::Undefined) + }); + r.define_host_object("host", vec![("text".into(), HostItem::TextFunction(f))]).unwrap(); + r.run("host.text(1 / 0, NaN, undefined, null, 0.1, [1, [2]], {toString: function () { return 'own'; }}, function f() {}, '\\ud800x')").unwrap(); + let texts: Vec = seen.borrow().iter().map(|d| if let Data::String(s) = d { s.clone() } else { format!("{d:?}") }).collect(); + assert_eq!(texts[..7], ["Infinity", "NaN", "undefined", "null", "0.1", "1,2", "own"]); + assert!(texts[7].starts_with("function f()"), "{}", texts[7]); + assert_eq!(texts[8], "\u{fffd}x", "a lone surrogate becomes U+FFFD"); + let e = r.run("host.text({toString: function () { throw new Error('no text'); }})").unwrap_err(); + assert!(e.message.contains("no text"), "{}", e.message); +} + #[test] fn errors_from_the_host_can_be_caught_and_an_abort_cannot() { let mut r = realm(1_000_000); @@ -241,6 +260,59 @@ fn errors_from_the_host_can_be_caught_and_an_abort_cannot() { assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped by the host")); } +#[test] +fn a_marked_host_error_keeps_its_mark_only_while_no_catch_takes_it() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|a| { + let error = match a.first() { + Some(Data::String(k)) if k == "abort" => HostError::Abort("stopped".into()), + _ => HostError::Syntax("bad file".into()), + }; + Err(HostError::Marked { error: Box::new(error), mark: 7 }) + }); + r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap(); + let cases = [ + ("host.f();", Some(7)), + ("try { host.f(); } finally { var x = 1; }", Some(7)), + ("try { try { host.f(); } finally { 1; } } finally { 2; }", Some(7)), + ("function g() { host.f(); }\ng();", Some(7)), + ("try { host.f(); } finally { try { try { host.f(); } finally { 1; } } catch (e) {} }", Some(7)), + ("var saved; try { host.f(); } catch (e) { saved = e; }\nthrow saved;", None), + ("try { host.f(); } catch (e) { throw e; }", None), + ("try { try { host.f(); } finally { 1; } } catch (e) { throw e; }", None), + ("var m; try { host.f(); } catch (e) { m = e.message; }\nthrow new SyntaxError(m);", None), + ("do { try { host.f(); } finally { break; } } while (false);\nthrow new Error('other');", None), + ("try { host.f('abort'); } catch (e) { 'caught' }", Some(7)), + ]; + for (source, mark) in cases { + assert!(r.run(source).is_err(), "{source}"); + assert_eq!(r.error_mark(), mark, "{source}"); + } + assert_eq!(r.run("1").unwrap(), JsValue::Number(1.0)); + assert_eq!(r.error_mark(), None, "a later run that ends normally has no mark"); +} + +/// Marked errors that `finally` blocks discard with `return`, `break` or `continue` are collected +/// with the rest of the garbage, so a run that discards many of them stays within its heap. +#[test] +fn marked_errors_a_finally_block_discards_are_collected() { + let mut r = Realm::new(); + r.set_limits(Limits { fuel: 200_000_000, heap_bytes: 1 << 20, ..Limits::default() }); + let f: HostFunction = Rc::new(|_| Err(HostError::Marked { error: Box::new(HostError::Syntax("bad file".into())), mark: 7 })); + r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap(); + let cases = [ + "function d() { try { host.f(); } finally { return; } }\nfor (var i = 0; i < 20000; i++) d();", + "for (var i = 0; i < 20000; i++) { try { host.f(); } finally { continue; } }", + "for (var i = 0; i < 20000; i++) { do { try { host.f(); } finally { break; } } while (false); }", + ]; + for source in cases { + r.run(source).unwrap_or_else(|e| panic!("{source}: {e:?}")); + assert_eq!(r.error_mark(), None, "{source}"); + } + assert!(r.run("try { host.f(); } finally { 1; }").is_err()); + assert_eq!(r.error_mark(), Some(7)); +} + #[test] fn an_error_says_where_in_the_source_it_was_raised() { let mut r = realm(1_000_000); @@ -251,6 +323,71 @@ fn an_error_says_where_in_the_source_it_was_raised() { assert_eq!(e.message, "TypeError: no"); } +/// An error a `finally` block passes on unchanged is reported where it was raised; one the block +/// throws itself, or a catch throws again, is reported at that new throw. Each finally block holds +/// a statement, so its end is not at the throw's position. +#[test] +fn an_error_that_passes_through_a_finally_block_says_where_it_was_raised() { + let mut r = realm(1_000_000); + let cases = [ + ("try {\n throw new Error('x');\n} finally {\n var z=1;\n}", (2, 3), "Error: x"), + ("try {\n null();\n} finally {\n var z=1;\n}", (2, 3), "TypeError: the value is not a function"), + ("try {\n throw 5;\n} finally {\n var z=1;\n}", (2, 3), "5"), + ("try {\n try { throw new Error('x'); } finally { var z=1; }\n} catch (e) { throw e; }", (3, 15), "Error: x"), + ("try {\n try { throw new Error('x'); } finally { var z=1; }\n} finally { var z=1; }", (2, 9), "Error: x"), + ("function f() {\n try { throw new Error('x'); } finally { var z=1; }\n}\nf();", (2, 9), "Error: x"), + ("try { throw new Error('x'); } catch (e) {\n throw e;\n}", (2, 3), "Error: x"), + ("try { throw new Error('x'); } finally {\n throw new Error('y');\n}", (2, 3), "Error: y"), + ("var e = new Error('x');\ntry {\n throw e;\n} finally {\n throw e;\n}", (5, 3), "Error: x"), + ("try {\n throw new Error('outer');\n} finally {\n try { throw new Error('inner'); } catch (e) {}\n}", (2, 3), "Error: outer"), + ("function g(){ try { throw new Error('inner'); } catch(e) {} }\ntry { throw new Error('outer'); } finally { g(); }", (2, 7), "Error: outer"), + ( + "for (var i = 0; i < 2000; i++) { try { throw 1; } finally { continue; } }\ntry {\n throw new Error('x');\n} finally {\n var z=1;\n}", + (3, 3), + "Error: x", + ), + ]; + for (source, (line, column), message) in cases { + let e = r.run(source).unwrap_err(); + assert_eq!((e.kind, e.line, e.column, e.message.as_str()), (RunErrorKind::Thrown, line, column, message), "{source}"); + } + let mut nested = String::new(); + for _ in 0..40 { + nested.push_str("try {\n"); + } + nested.push_str(" throw new Error('deep');\n"); + for _ in 0..40 { + nested.push_str("} finally { var z=1; }\n"); + } + let e = r.run(&nested).unwrap_err(); + assert_eq!((e.line, e.column), (41, 3), "forty finally blocks"); + for source in [ + "function f(){ try { throw new Error('x'); } finally { return 4; } }\nf();", + "while(true) { try { throw new Error('x'); } finally { break; } }\n7;", + "for(var i=0;i<1;i++){ try { throw new Error('x'); } finally { continue; } }\n7;", + ] { + assert!(r.run(source).is_ok(), "{source}"); + } +} + +#[test] +fn a_resumed_run_reports_an_error_that_passes_through_a_finally_block_where_it_was_raised() { + let mut r = realm(1_000_000); + r.start("try {\n throw new Error('x');\n} finally {\n var a=1;\n}").unwrap(); + let e = finish(&mut r, 1).0.unwrap_err(); + assert_eq!((e.kind, e.line, e.column), (RunErrorKind::Thrown, 2, 3)); +} + +#[test] +fn a_marked_host_error_that_passes_through_a_finally_block_says_where_it_was_raised() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Err(HostError::Marked { error: Box::new(HostError::Syntax("bad file".into())), mark: 7 })); + r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap(); + let e = r.run("try {\n host.f();\n} finally { var z=1; }").unwrap_err(); + assert_eq!((e.kind, e.line, e.column), (RunErrorKind::Thrown, 2, 3)); + assert_eq!(r.error_mark(), Some(7)); +} + #[test] fn advancing_without_a_run_is_an_error() { let mut r = realm(1_000); @@ -258,6 +395,84 @@ fn advancing_without_a_run_is_an_error() { assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "no run to advance")); } +/// A realm with the global `ask(question)`, which waits for its answer where it can and throws an +/// `Error` where it cannot. → how many times it was called. +fn with_ask(r: &mut Realm) -> Rc> { + let calls: Rc> = Rc::default(); + let (count, can_wait) = (calls.clone(), r.wait_flag()); + let f: HostFunction = Rc::new(move |_| { + count.set(count.get() + 1); + if can_wait.get() { Err(HostError::Wait) } else { Err(HostError::Error("cannot wait here".into())) } + }); + r.define_host_function("ask", vec![Arg::Text], f).unwrap(); + calls +} + +#[test] +fn a_waiting_call_pauses_at_the_call_and_takes_its_answer_once() { + let mut r = realm(1_000_000); + let calls = with_ask(&mut r); + r.start("var a = ask('first'); var b = ask('second'); a + '|' + b").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits()); + let fuel = r.fuel_left(); + let e = r.advance(1_000).unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "the run waits for a host function's result")); + assert_eq!(r.fuel_left(), fuel, "no fuel is spent while the call waits"); + r.answer(Data::String("one".into())).unwrap(); + assert!(!r.waits()); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits()); + r.answer(Data::Null).unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("one|null".into())); + assert_eq!(calls.get(), 2, "each call ran once"); + let e = r.answer(Data::Null).unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Internal); +} + +#[test] +fn only_the_scripts_own_code_in_a_resumable_run_can_wait() { + let mut r = realm(1_000_000); + with_ask(&mut r); + let caught = "var out = []; [1].forEach(function () { try { ask('x'); } catch (e) { out.push(e.message); } }); \ + var o = {}; Object.defineProperty(o, 'g', {get: function () { try { ask('y'); } catch (e) { out.push(e.message); } }}); o.g; \ + try { String({toString: function () { return ask('z'); }}); } catch (e) { out.push(e.message); } out.join()"; + r.start(caught).unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("cannot wait here,cannot wait here,cannot wait here".into())); + let e = r.run("ask('q')").unwrap_err(); + assert!(e.message.contains("cannot wait here"), "a run that cannot pause cannot wait: {}", e.message); + r.start("var f = ask; ask.call(null, 'q'); f.apply(null, ['r'])").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits(), "call and apply from the script's own code wait"); +} + +#[test] +fn a_host_function_that_waits_where_it_cannot_ends_the_run() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Err(HostError::Wait)); + r.define_host_function("ask", vec![], f).unwrap(); + let e = r.run("try { ask(); } catch (e) { 'caught' }").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Internal); +} + +#[test] +fn a_converted_function_reads_each_argument_as_its_list_says() { + let mut r = realm(1_000_000); + let seen: Rc>>> = Rc::default(); + let log = seen.clone(); + let f: HostFunction = Rc::new(move |args| { + log.borrow_mut().push(args); + Ok(Data::Undefined) + }); + r.define_host_function("conv", vec![Arg::Text, Arg::TextOrUndefined, Arg::Bool], f).unwrap(); + r.run("conv(42, undefined, 'x', function () {}); conv(undefined, null, 0); conv({toString: function () { return 'own'; }}); conv()").unwrap(); + let s = |t: &str| Data::String(t.into()); + let want = vec![vec![s("42"), Data::Undefined, Data::Bool(true)], vec![s("undefined"), s("null"), Data::Bool(false)], vec![s("own")], vec![]]; + assert_eq!(*seen.borrow(), want, "arguments past the list are not read"); + let out = r.run("var a = typeof conv; conv = 1; a + ' ' + conv").unwrap(); + assert_eq!(out, JsValue::String("function 1".into()), "a script may replace the function"); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index f86814949..458d68f40 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -72,6 +72,12 @@ pub(crate) struct Intrinsics { pub(crate) error_protos: [ObjId; 7], pub(crate) eval: ObjId, pub(crate) throw_type_error: ObjId, + /// The `XML` constructor, which holds the XML settings. + pub(crate) xml_ctor: ObjId, + pub(crate) xml_proto: ObjId, + pub(crate) xml_list_proto: ObjId, + pub(crate) namespace_proto: ObjId, + pub(crate) qname_proto: ObjId, } impl Intrinsics { @@ -89,6 +95,11 @@ impl Intrinsics { error_protos: [o; 7], eval: o, throw_type_error: o, + xml_ctor: o, + xml_proto: o, + xml_list_proto: o, + namespace_proto: o, + qname_proto: o, } } @@ -117,6 +128,11 @@ impl Intrinsics { self.date_proto, self.eval, self.throw_type_error, + self.xml_ctor, + self.xml_proto, + self.xml_list_proto, + self.namespace_proto, + self.qname_proto, ]; v.extend(self.error_protos); v @@ -128,8 +144,8 @@ pub(crate) struct Frame { pub(crate) pc: usize, /// Where this frame's operands start on the operand stack. base: usize, - env: EnvId, - var_env: EnvId, + pub(crate) env: EnvId, + pub(crate) var_env: EnvId, /// `with` and catch environments entered and not yet left. env_depth: u32, this: Value, @@ -164,6 +180,13 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The last `Error` object thrown in this run, caught or not, or `null` (`$.error`). + pub(crate) last_error: Value, + /// An exception passed on rather than thrown again: one a `finally` block restores, or one + /// leaving a nested run. [`Interp::unwind`] does not make it [`Interp::last_error`] again. + pub(crate) passing: Value, + /// The locale `localize` and `Object.prototype.toString` read. + pub(crate) localization: Rc, /// The state of `Math.random`'s generator. pub(crate) random: u64, /// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a @@ -175,16 +198,51 @@ pub(crate) struct Interp { pub(crate) held_bytes: usize, /// Why installing the built-ins failed; a realm with this set runs no script. pub(crate) install_error: Option, + /// `File` and `Folder`, once a host has added them ([`crate::Realm::define_file_classes`]). + pub(crate) files: Option, /// The clock and time zone `Date` reads. pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc, + /// The global code's default XML namespace URI (ECMA-357 section 12.1). + pub(crate) xml_default_uri: Key, /// Set by the host to end the current slice of a resumable run at the next safe point. pub(crate) pause: Rc>, + /// The outermost loop of a resumable run is running ([`crate::Realm::advance`]). + pub(crate) in_slice: bool, + /// True while a host function runs where it may wait ([`crate::HostError::Wait`]). + pub(crate) can_wait: Rc>, + /// A host function's call waits for its result: the value on top of the stack stands in for it. + pub(crate) waiting: bool, /// The message of the uncatchable error a host function ended the run with. pub(crate) host_abort: Option, /// How much a value passed to or from a host function may carry. pub(crate) data_limits: crate::DataLimits, + /// The tag the source compiled next gets ([`crate::Realm::error_source`]). + pub(crate) source_tag: u32, + /// The sources [`crate::HostItem::Evaluator`] functions have given in this realm. + pub(crate) evaluated: u32, + /// The tag of the source the last run's error was raised in. + pub(crate) error_tag: u32, + /// The mark of the host error ([`crate::HostError::Marked`]) whose exception or limit is + /// propagating. + pub(crate) thrown_mark: Option, + /// The marked exceptions `finally` blocks are running for, innermost last, each with its mark, + /// which the block's end throws it on with, and the stack slot that holds it while the block + /// runs. An entry whose slot no longer holds its exception belongs to a block that `break`, + /// `continue` or `return` left, and [`Self::drop_left_finally_marks`] removes it. + pub(crate) finally_marks: Vec<(ObjId, u32, usize)>, + /// Where the exceptions `finally` blocks are running for were raised, innermost last, each with + /// the stack slot that holds the exception while the block runs. The block's end throws the + /// exception on from that site. An entry whose slot is at or above the stack's length belongs to + /// a block that was left, and [`Self::drop_left_finally_sites`] removes it. [`Self::unwind`] + /// removes the entries at or above a handler's slot before it adds one, so the slots rise and + /// the list is never longer than the stack. + pub(crate) finally_sites: Vec<(ThrowSite, usize)>, + /// The mark of the host error the last run ended with. + pub(crate) error_mark: Option, + /// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs. + pub(crate) strict_writes: bool, } /// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's @@ -256,13 +314,21 @@ fn index_keys_bytes(len: usize) -> usize { total } -/// A seed for `Math.random` that differs from realm to realm, from the standard library's -/// randomly keyed hasher; never zero. +/// A seed for `Math.random` that differs from realm to realm and from run to run, from the OS +/// random source (the browser's crypto on the web), or the standard library's randomly keyed +/// hasher when that fails; never zero. fn random_seed() -> u64 { + random_seed_from(getrandom::u64()) +} + +/// [`random_seed`] from what the OS random source returned. +pub(crate) fn random_seed_from(os: Result) -> u64 { use std::hash::{BuildHasher, Hasher}; - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(0x9e37_79b9_7f4a_7c15); - h.finish() | 1 + os.unwrap_or_else(|_| { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(0x9e37_79b9_7f4a_7c15); + h.finish() + }) | 1 } /// The estimated bytes of a binding or property named by `len` code units. @@ -367,15 +433,31 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + last_error: Value::Null, + passing: Value::Undefined, + localization: Rc::default(), random: random_seed(), join_stack: Vec::new(), held_bytes: 0, install_error: None, + files: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + xml_default_uri: Rc::from(Vec::new()), pause: Rc::default(), + in_slice: false, + can_wait: Rc::default(), + waiting: false, host_abort: None, data_limits: crate::DataLimits::default(), + source_tag: crate::realm::UNKNOWN_SOURCE, + evaluated: 0, + error_tag: crate::realm::UNKNOWN_SOURCE, + thrown_mark: None, + finally_marks: Vec::new(), + finally_sites: Vec::new(), + error_mark: None, + strict_writes: false, }; crate::builtins::install(&mut interp); interp.fuel = limits.fuel; @@ -414,16 +496,16 @@ impl Interp { // ---- the operand stack ---- - fn push(&mut self, v: Value) -> JsResult<()> { + pub(crate) fn push(&mut self, v: Value) -> JsResult<()> { self.stack.push(v); Ok(()) } - fn frame(&self) -> JsResult<&Frame> { + pub(crate) fn frame(&self) -> JsResult<&Frame> { self.frames.last().ok_or_else(|| Abrupt::Internal("no frame".into())) } - fn frame_mut(&mut self) -> JsResult<&mut Frame> { + pub(crate) fn frame_mut(&mut self) -> JsResult<&mut Frame> { self.frames.last_mut().ok_or_else(|| Abrupt::Internal("no frame".into())) } @@ -431,7 +513,7 @@ impl Interp { self.frames.last().map_or(0, |f| f.base) } - fn pop(&mut self) -> JsResult { + pub(crate) fn pop(&mut self) -> JsResult { if self.stack.len() <= self.base() { return internal("operand stack underflow"); } @@ -439,7 +521,7 @@ impl Interp { } /// The value `n` places below the top (0 is the top). - fn peek(&self, n: usize) -> JsResult { + pub(crate) fn peek(&self, n: usize) -> JsResult { let len = self.stack.len(); match len.checked_sub(n + 1).filter(|&i| i >= self.base()) { Some(i) => self.stack.get(i).cloned().ok_or_else(|| Abrupt::Internal("operand stack underflow".into())), @@ -459,7 +541,7 @@ impl Interp { } /// Replaces the top `n` values with `v`. - fn replace(&mut self, n: usize, v: Value) -> JsResult<()> { + pub(crate) fn replace(&mut self, n: usize, v: Value) -> JsResult<()> { self.drop_n(n)?; self.push(v) } @@ -529,11 +611,30 @@ impl Interp { Ok(()) } + /// Removes the [`Self::finally_marks`] entries of `finally` blocks that `break`, `continue` or + /// `return` left: those whose stack slot no longer holds their exception. + fn drop_left_finally_marks(&mut self) { + let stack = &self.stack; + self.finally_marks.retain(|&(id, _, slot)| matches!(stack.get(slot), Some(Value::Object(o)) if *o == id)); + } + + /// Removes the [`Self::finally_sites`] entries whose stack slot is at or above `len`: those of + /// `finally` blocks that were left. + fn drop_left_finally_sites(&mut self, len: usize) { + self.finally_sites.retain(|&(_, slot)| slot < len); + } + pub(crate) fn collect_garbage(&mut self) { + self.drop_left_finally_marks(); + self.drop_left_finally_sites(self.stack.len()); let mut roots = Roots::default(); roots.values.extend(self.stack.iter()); roots.values.extend(self.temp_roots.iter()); roots.values.push(&self.last_thrown); + roots.values.push(&self.last_error); + roots.values.push(&self.passing); + roots.objects.extend(self.finally_marks.iter().map(|&(id, _, _)| id)); + roots.codes.extend(self.finally_sites.iter().map(|((code, _), _)| &**code)); for f in &self.frames { roots.values.push(&f.this); roots.values.push(&f.completion); @@ -544,6 +645,7 @@ impl Interp { } roots.objects.push(self.global); roots.objects.extend(self.intrinsics.all()); + roots.objects.extend(self.files.iter().flat_map(crate::file_classes::Classes::objects)); roots.envs.push(self.global_env); self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. @@ -619,9 +721,18 @@ impl Interp { /// boundary frame. Returns the exception when no handler takes it before the boundary frame. fn unwind(&mut self, v: Value) -> JsResult<()> { self.note_site(); + let passed = matches!((&v, std::mem::replace(&mut self.passing, Value::Undefined)), (Value::Object(a), Value::Object(b)) if *a == b); + if !passed + && let Value::Object(o) = v + && self.heap.object(o).is_some_and(|o| matches!(o.class, Class::Error)) + { + self.last_error = v.clone(); + } loop { let Some(frame) = self.frames.last_mut() else { return Err(Abrupt::Throw(v)) }; if let Some(h) = frame.code.handler(frame.pc.saturating_sub(1)) { + // A catch clause starts with `PushCatch`; every other handler is a `finally` block's. + let caught = matches!(frame.code.ops.get(h.target as usize), Some(Op::PushCatch(_))); let target_len = frame.base + h.depth as usize; while frame.env_depth > h.env_depth { let outer = self.heap.env(frame.env).and_then(Env::outer); @@ -634,18 +745,33 @@ impl Interp { return internal("handler stack deeper than the stack"); } self.stack.truncate(target_len); + self.drop_left_finally_marks(); + self.drop_left_finally_sites(target_len); + if let (Some(mark), false, Value::Object(id)) = (self.thrown_mark.take(), caught, &v) { + self.finally_marks.push((*id, mark, target_len)); + } self.stack.push(v); - self.throw_site = None; + let site = self.throw_site.take(); + if let (false, Some(site)) = (caught, site) { + self.finally_sites.push((site, target_len)); + } return Ok(()); } let Some(frame) = self.frames.pop() else { return Err(Abrupt::Throw(v)) }; self.stack.truncate(frame.base); if frame.boundary { + self.passing = v.clone(); return Err(Abrupt::Throw(v)); } } } + /// Each running frame of script code, outermost first: its code, the index of its next + /// instruction, and whether it runs a function (not global or eval code). + pub(crate) fn script_frames(&self) -> impl Iterator, usize, bool)> { + self.frames.iter().map(|f| (&f.code, f.pc, f.callee.is_some())) + } + fn abort_to_boundary(&mut self) { while let Some(frame) = self.frames.pop() { self.stack.truncate(frame.base); @@ -669,7 +795,7 @@ impl Interp { Ok(None) } - fn jump_to(&mut self, target: u32) -> JsResult<()> { + pub(crate) fn jump_to(&mut self, target: u32) -> JsResult<()> { self.frame_mut()?.pc = target as usize; Ok(()) } @@ -682,6 +808,18 @@ impl Interp { self.frames.last().is_some_and(|f| f.code.strict) } + /// Does a property write the running code makes throw when it cannot be made? It does in + /// strict mode code and in code from a source with [`crate::bytecode::Source::strict_writes`]. + fn strict_writes(&self) -> bool { + self.frames.last().is_some_and(|f| f.code.strict || f.code.source.strict_writes) + } + + /// [`crate::bytecode::Source::strict_writes`] for source compiled now: the running code's, + /// or the realm's between runs. + pub(crate) fn writes_mode(&self) -> bool { + self.frames.last().map_or(self.strict_writes, |f| f.code.source.strict_writes) + } + fn exec(&mut self, op: Op) -> JsResult<()> { match op { Op::Undefined => self.push(Value::Undefined), @@ -735,7 +873,10 @@ impl Interp { Op::Jump(t) => self.jump_to(t), Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => self.op_branch(t, matches!(op, Op::JumpIfTrue(_)), false), Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => self.op_branch(t, matches!(op, Op::JumpIfTrueKeep(_)), true), - Op::Throw => Err(Abrupt::Throw(self.pop()?)), + Op::Throw => { + self.thrown_mark = None; + Err(Abrupt::Throw(self.pop()?)) + } Op::ThrowReferenceError(i) => self.op_throw_reference_error(i), Op::EndFinally(t) => self.op_end_finally(t), Op::NewObject => self.op_new_object(), @@ -761,6 +902,17 @@ impl Interp { let v = self.frame()?.completion.clone(); self.push(v) } + Op::XmlMakeName { attribute, qualified } => self.op_xml_make_name(attribute, qualified), + Op::XmlDescendants => self.op_xml_descendants(), + Op::XmlEscape(kind) => self.op_xml_escape(kind), + Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), + Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlFilterStart => self.op_xml_filter_start(), + Op::XmlFilterNext(t) => self.op_xml_filter_next(t), + Op::XmlFilterTest => self.op_xml_filter_test(), + Op::XmlFilterEnd => self.op_xml_filter_end(), + Op::ForEachValue => self.op_for_each_value(), + Op::SetDefaultNamespace => self.op_set_default_namespace(), Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } } @@ -817,7 +969,20 @@ impl Interp { return self.pop().map(drop); } if kind == 1.0 { - return Err(Abrupt::Throw(self.pop()?)); + let v = self.pop()?; + self.passing = v.clone(); + if let Value::Object(o) = v + && let Some(i) = self.finally_marks.iter().rposition(|&(id, _, _)| id == o) + { + self.thrown_mark = self.finally_marks.get(i).map(|&(_, mark, _)| mark); + self.finally_marks.truncate(i); + } + let slot = self.stack.len(); + if let Some(i) = self.finally_sites.iter().rposition(|&(_, s)| s == slot) { + self.throw_site = self.finally_sites.get(i).map(|(site, _)| site.clone()); + self.finally_sites.truncate(i); + } + return Err(Abrupt::Throw(v)); } let k = (kind - 2.0) as usize; let target = self.frame()?.code.finally_tables.get(table as usize).and_then(|t| t.get(k)).copied(); @@ -829,7 +994,7 @@ impl Interp { // ---- variables ---- - fn resolve(&self, name: &[u16]) -> JsResult { + fn resolve(&mut self, name: &[u16]) -> JsResult { let mut cur = Some(self.frame()?.env); while let Some(e) = cur { match self.heap.env(e) { @@ -839,11 +1004,11 @@ impl Interp { } cur = *outer; } - Some(Env::Obj { object, with_this, outer }) => { - if self.has_property(*object, name)? { - return Ok(Resolved::Obj { object: *object, with_this: *with_this }); + Some(&Env::Obj { object, with_this, outer }) => { + if self.scope_has(object, name)? { + return Ok(Resolved::Obj { object, with_this }); } - cur = *outer; + cur = outer; } None => return internal("environment out of range"), } @@ -873,7 +1038,7 @@ impl Interp { match r { Resolved::Decl(e) => Ok((self.binding_value(e, name)?, Value::Undefined)), Resolved::Obj { object, with_this } => { - let v = self.get_from(object, name, &Value::Object(object))?; + let v = self.get_value(&Value::Object(object), name)?; Ok((v, if with_this { Value::Object(object) } else { Value::Undefined })) } Resolved::Unresolved => Err(self.not_defined(name)), @@ -900,17 +1065,30 @@ impl Interp { _ => Ok(()), } } - Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict), + Resolved::Obj { object, .. } if self.options.e4x && self.is_xml(&Value::Object(object)) => { + self.put_value(&Value::Object(object), name.clone(), v) + } + Resolved::Obj { object, .. } => { + let throw = self.strict_writes(); + self.put(object, name.clone(), v, throw) + } Resolved::Unresolved if strict => Err(self.not_defined(name)), Resolved::Unresolved => { - let global = self.global; - self.put(global, name.clone(), v, false) + let (global, throw) = (self.global, self.strict_writes()); + self.put(global, name.clone(), v, throw) } } } fn op_get_var(&mut self, n: u32, with_this: bool) -> JsResult<()> { let name = self.name(n)?; + if with_this + && self.options.e4x + && let Some((f, this)) = self.xml_scope_method(&name)? + { + self.push(f)?; + return self.push(this); + } let (v, this) = self.get_var(&name)?; self.push(v)?; if with_this { self.push(this) } else { Ok(()) } @@ -1087,6 +1265,9 @@ impl Interp { /// `base[k]` for any value (GetValue, section 8.7.1). pub(crate) fn get_value(&mut self, base: &Value, k: &[u16]) -> JsResult { + if self.options.e4x && self.is_xml(base) { + return self.xml_get(base, &Value::String(Str::from_units(k.to_vec()))); + } if let Value::String(s) = base { if is(k, "length") { return Ok(Value::Number(s.len() as f64)); @@ -1433,9 +1614,15 @@ impl Interp { /// PutValue on a property reference (section 8.7.2), whose base may be a primitive. fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> { + if self.options.e4x && self.is_xml(base) { + return self.xml_put(base, &Value::String(Str::from_rc(k)), &v); + } let strict = self.strict(); match base { - Value::Object(o) => self.put(*o, k, v, strict), + Value::Object(o) => { + let throw = self.strict_writes(); + self.put(*o, k, v, throw) + } Value::Undefined | Value::Null => { let message = format!( "cannot set property '{}' of {}", @@ -1458,6 +1645,11 @@ impl Interp { fn op_get_member(&mut self, n: u32, with_this: bool) -> JsResult<()> { let name = self.name(n)?; let base = self.peek(0)?; + if with_this && self.options.e4x && self.is_xml(&base) { + let (f, this) = self.xml_method(&base, &name)?; + self.replace(1, f)?; + return self.push(this); + } let v = self.get_value(&base, &name)?; self.replace(1, v)?; if with_this { self.push(base) } else { Ok(()) } @@ -1486,6 +1678,9 @@ impl Interp { fn op_to_key(&mut self) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + return Ok(()); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; self.replace(1, Value::String(Str::from_rc(name))) @@ -1494,6 +1689,16 @@ impl Interp { fn op_get_index(&mut self, with_this: bool) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + if with_this { + let name = self.xml_method_key(&k)?; + let (f, this) = self.xml_method(&base, &name)?; + self.replace(2, f)?; + return self.push(this); + } + let v = self.xml_get(&base, &k)?; + return self.replace(2, v); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let v = self.get_value(&base, &name)?; @@ -1505,6 +1710,10 @@ impl Interp { let v = self.peek(0)?; let k = self.peek(1)?; let base = self.peek(2)?; + if self.is_xml_access(&base, &k) { + self.xml_put(&base, &k, &v)?; + return self.replace(3, v); + } let name = self.to_key(&k)?; self.put_value(&base, name, v.clone())?; self.replace(3, v) @@ -1513,6 +1722,10 @@ impl Interp { fn op_delete_member(&mut self, n: u32) -> JsResult<()> { let name = self.name(n)?; let base = self.peek(0)?; + if self.options.e4x && self.is_xml(&base) { + let r = self.xml_delete(&base, &Value::String(Str::from_rc(name)))?; + return self.replace(1, Value::Bool(r)); + } let o = self.to_object(&base)?; let strict = self.strict(); let r = self.delete(o, &name, strict)?; @@ -1522,6 +1735,10 @@ impl Interp { fn op_delete_index(&mut self) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + let r = self.xml_delete(&base, &k)?; + return self.replace(2, Value::Bool(r)); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let o = self.to_object(&base)?; @@ -1548,6 +1765,13 @@ impl Interp { fn op_inc_index(&mut self, prefix: bool, dec: bool) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + let v = self.xml_get(&base, &k)?; + let old = self.to_number(&v)?; + let new = if dec { old - 1.0 } else { old + 1.0 }; + self.xml_put(&base, &k, &Value::Number(new))?; + return self.replace(2, Value::Number(if prefix { new } else { old })); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let r = self.inc_property(&base, name, prefix, dec)?; @@ -1713,6 +1937,9 @@ impl Interp { /// For a String object, the heap budget for all its index names is checked before any is /// built. Each index name and each property visited takes a unit of fuel. fn enumerable_keys(&mut self, id: ObjId) -> JsResult> { + if self.options.e4x && self.is_xml(&Value::Object(id)) { + return self.xml_enumerate(id); + } let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); let mut cur = Some(id); @@ -1779,7 +2006,7 @@ impl Interp { state.pos += 1; let object = state.object; if let Some(o) = object - && self.has_property(o, &k)? + && self.scope_has(o, &k)? { return self.push(Value::String(Str::from_rc(k))); } @@ -1846,10 +2073,13 @@ impl Interp { self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } - Func::Data { f, name } => { + Func::Data { f, name, read } => { // The arguments are read before the function runs, and a result being built // keeps itself reachable. - return Ok(Prepared::Done(self.call_data(&f, &name, &args)?)); + return Ok(Prepared::Done(self.call_data(&f, &name, &args, &read)?)); + } + Func::Eval { f, end, strict_writes, name } => { + return Ok(Prepared::Done(self.call_evaluator(&f, &end, &strict_writes, &name, &args)?)); } Func::Host { f: host, .. } => { let js_args: Vec = args.iter().map(|v| self.to_js_value(v)).collect(); @@ -2119,11 +2349,17 @@ impl Interp { /// Parses and compiles source for eval code or a script, counting it against the source /// limit. pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result, CompileError> { + let writes = self.writes_mode(); + self.compile_source_writes(units, strict, writes, kind) + } + + /// [`Interp::compile_source`], with [`crate::bytecode::Source::strict_writes`] set to `writes`. + pub(crate) fn compile_source_writes(&mut self, units: &[u16], strict: bool, writes: bool, kind: CodeKind) -> Result, CompileError> { self.source_units = self.source_units.saturating_add(units.len()); let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len())); let options = Options { strict, max_source_units: remaining, ..self.options.clone() }; let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?; - let source = Rc::new(Source { lines: program.lines.clone(), text: units.into() }); + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag, strict_writes: writes }); compile_program(&program, &source, kind).map_err(|e| match e { CompileFail::Syntax(s) => CompileError::Syntax(s), CompileFail::Internal(m) => CompileError::Internal(m), @@ -2279,6 +2515,7 @@ impl Interp { Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", + Value::Object(_) if self.options.e4x && self.is_xml(v) => "xml", Value::Object(_) if self.is_callable(v) => "function", Value::Object(_) => "object", } @@ -2440,6 +2677,9 @@ impl Interp { BinaryOp::StrictNotEq => Ok(Value::Bool(!strict_eq(a, b))), BinaryOp::Instanceof => self.instance_of(a, b).map(Value::Bool), BinaryOp::In => { + if self.options.e4x && self.is_xml(b) { + return self.xml_has(b, a).map(Value::Bool); + } let Value::Object(o) = b else { return Err(self.type_error("the right side of 'in' is not an object")) }; let k = self.to_key(a)?; self.has_property(*o, &k).map(Value::Bool) @@ -2452,6 +2692,9 @@ impl Interp { if let (Value::Number(x), Value::Number(y)) = (a, b) { return Ok(Value::Number(x + y)); } + if self.options.e4x && self.is_xml(a) && self.is_xml(b) { + return self.xml_concat(a, b); + } let pa = self.to_primitive(a, Hint::Default)?; let pb = self.to_primitive(b, Hint::Default)?; if matches!(pa, Value::String(_)) || matches!(pb, Value::String(_)) { @@ -2484,7 +2727,12 @@ impl Interp { } /// The abstract equality comparison (section 11.9.3). - fn loose_eq(&mut self, a: &Value, b: &Value) -> JsResult { + pub(crate) fn loose_eq(&mut self, a: &Value, b: &Value) -> JsResult { + if self.options.e4x + && let Some(r) = self.xml_loose_eq(a, b)? + { + return Ok(r); + } let (mut x, mut y) = (a.clone(), b.clone()); loop { match (&x, &y) { diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 03d9e6639..d2a12b5fb 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() { assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into()))); } +#[test] +fn strict_writes_are_separate_from_es5_strict() { + let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \ + var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};"; + let mut realm = Realm::new(); + realm.set_strict_writes(true); + realm.run(setup).unwrap(); + for (src, why) in [ + ("ro.x = 2", "cannot assign to property 'x': it is read-only"), + ("ro['x'] += 1", "cannot assign to property 'x': it is read-only"), + ("ro.x++", "cannot assign to property 'x': it is read-only"), + ("getter.g = 2", "cannot assign to property 'g': it has only a getter"), + ("shut.b = 2", "cannot assign to property 'b': the object is not extensible"), + ("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"), + ("NaN = 1", "cannot assign to property 'NaN': it is read-only"), + ("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"), + ("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"), + ("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"), + ("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"), + ] { + let e = realm.run(src).unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Thrown, "{src}"); + assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message); + } + // Writes that can be made, and the rest of strict mode, are as before. + assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0))); + assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0))); + assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0))); + assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0))); + assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0))); + assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into()))); + assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false))); + assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode"); + // 'use strict' keeps all of ES5 strict mode. + assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax)); + assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown)); + // Off, the same writes are ignored, as in sloppy mode code. + realm.set_strict_writes(false); + assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into()))); + // A function compiled with the writes on keeps them when called from code without. + let mut realm = Realm::new(); + realm.set_strict_writes(true); + realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap(); + realm.set_strict_writes(false); + assert!(realm.run("w()").unwrap_err().message.contains("it is read-only")); + assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0))); + // An undeclared name added to a global object that is not extensible. + let mut realm = Realm::new(); + realm.set_strict_writes(true); + let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err(); + assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message); + let mut realm = Realm::new(); + assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into()))); +} + #[test] fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() { // Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay @@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() { // ---- the verifier ---- fn code(ops: Vec) -> Code { - let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()) }); + let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()), tag: 0, strict_writes: false }); Code { positions: vec![0; ops.len()], ops, @@ -788,6 +843,7 @@ fn code(ops: Vec) -> Code { max_stack: 0, strict: false, self_name: None, + decl_name: None, params: Vec::new(), vars: Vec::new(), fun_decls: Vec::new(), diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index 8b82570a7..a2a7153dc 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -47,13 +47,30 @@ pub struct Lexer<'s> { preprocessor_lines: bool, /// The deepest nesting of groups a regular expression literal may have. max_depth: usize, + /// Read the E4X punctuators `..`, `::` and `@`. + e4x: bool, } impl<'s> Lexer<'s> { /// A lexer of `src`. `preprocessor_lines` turns on the reading of preprocessor lines; /// `max_depth` limits how deeply a regular expression literal's groups may nest. pub fn new(src: &'s [u16], preprocessor_lines: bool, max_depth: usize) -> Self { - Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth } + Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth, e4x: false } + } + + /// The same lexer, reading the E4X punctuators when `e4x` is true. + pub fn with_e4x(mut self, e4x: bool) -> Self { + self.e4x = e4x; + self + } + + /// Moves to source offset `pos`, at the given line and column (both from 1), after the parser + /// has read an XML literal up to there. + pub(crate) fn seek(&mut self, pos: usize, line: u32, column: u32) { + self.pos = pos; + self.line = line; + self.line_start = pos.saturating_sub(column.saturating_sub(1) as usize); + self.blank_line = false; } /// The code unit at `pos`, as a code point value. @@ -341,7 +358,7 @@ impl<'s> Lexer<'s> { } else { self.decimal() }; - if self.peek().is_some_and(|c| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) { + if self.code_point(self.pos).is_some_and(|(c, _)| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) { return Err(self.error_at(self.pos, "identifier starts immediately after numeric literal")); } let mut token = self.token(TokenKind::Number(value), start, line, column, false); @@ -578,6 +595,9 @@ impl<'s> Lexer<'s> { fn punct(&mut self) -> Option { let rest = &self.src[self.pos..]; for (spelling, punct) in Punct::ALL { + if !self.e4x && matches!(punct, Punct::DotDot | Punct::ColonColon | Punct::At) { + continue; + } let bytes = spelling.as_bytes(); if rest.len() >= bytes.len() && rest.iter().zip(bytes).all(|(&u, &b)| u == u16::from(b)) { self.pos += bytes.len(); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index a95a4b884..7df9953a6 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -37,6 +37,13 @@ //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. +//! +//! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005): +//! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and +//! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`, +//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are +//! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and +//! serializing them use no native recursion and take fuel for each node. #![forbid(unsafe_code)] pub mod ast; @@ -46,6 +53,7 @@ mod chars; mod compiler; mod date_builtins; mod error; +mod file_classes; mod heap; mod host; mod interp; @@ -60,16 +68,19 @@ mod regexp_vm; mod string_builtins; mod token; mod value; +mod xml; +mod xml_builtins; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, - PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, XmlNamePart, XmlPart, XmlQualifier, }; +pub use builtins::extensions::Localization; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; -pub use host::{Data, DataLimits, HostError, HostFunction, HostItem}; +pub use host::{Arg, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; -pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind}; +pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE}; pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; /// How a script is read. @@ -84,11 +95,13 @@ pub struct Options { pub max_depth: usize, /// The most UTF-16 code units a source may hold. pub max_source_units: usize, + /// Read and run E4X (ECMA-357). Off, XML syntax is an error and a realm has no XML built-ins. + pub e4x: bool, } impl Default for Options { fn default() -> Self { - Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 } + Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20, e4x: false } } } @@ -187,6 +200,8 @@ impl LineTable { #[cfg(test)] mod builtins_tests; #[cfg(test)] +mod extension_tests; +#[cfg(test)] mod host_tests; #[cfg(test)] mod interp_tests; @@ -194,3 +209,5 @@ mod interp_tests; mod tests; #[cfg(test)] mod text_tests; +#[cfg(test)] +mod xml_tests; diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index d56b75617..3c2ab3ed3 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -7,6 +7,8 @@ //! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long //! runs of them do not nest at all. +mod xml; + use std::collections::{HashMap, HashSet}; use crate::LineTable; @@ -69,11 +71,13 @@ pub(crate) struct Parser<'s> { /// The next statement stands in a statement list, where a label's body may be a function /// declaration. Read and cleared as each statement starts. in_list: bool, + /// Read E4X syntax. + e4x: bool, } impl<'s> Parser<'s> { pub(crate) fn new(src: &'s [u16], options: &crate::Options, keep_tokens: bool) -> Result { - let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth); + let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth).with_e4x(options.e4x); let placeholder = Token { kind: TokenKind::Eof, span: Span::default(), line: 1, column: 1, newline_before: false, escaped: false, legacy_octal: false }; let mut parser = Self { @@ -90,6 +94,7 @@ impl<'s> Parser<'s> { tokens: keep_tokens.then(Vec::new), function_allowed: false, in_list: false, + e4x: options.e4x, }; parser.tok = parser.next_significant()?; Ok(parser) @@ -391,6 +396,7 @@ impl<'s> Parser<'s> { self.semicolon()?; Ok(self.ast.push_stmt(Stmt::Debugger, self.span_from(start))) } + Keyword::Default if self.at_default_xml_namespace() => self.parse_default_xml_namespace(start), _ => { let expr = self.parse_expression(false)?; self.semicolon()?; @@ -488,7 +494,14 @@ impl<'s> Parser<'s> { fn parse_for(&mut self, start: Span) -> Result { self.bump()?; + let each = self.e4x && self.tok.ident() == Some("each"); + if each { + self.bump()?; + } self.expect(Punct::LParen)?; + if each { + return self.parse_for_each(start); + } let init = if self.at(Punct::Semicolon) { None } else if self.at_keyword(Keyword::Var) { @@ -501,14 +514,14 @@ impl<'s> Parser<'s> { if self.ctx.strict && decl.init.is_some() { return Err(self.error_at_token(&self.tok, "a for-in variable cannot have an initializer in strict mode code")); } - return self.parse_for_in(ForInTarget::Var(decl), start); + return self.parse_for_in(ForInTarget::Var(decl), start, false); } Some(ForInit::Var(decls)) } else { let expr = self.parse_expression(true)?; if self.at_keyword(Keyword::In) { self.check_target(expr, "invalid left-hand side in for-in loop")?; - return self.parse_for_in(ForInTarget::Expr(expr), start); + return self.parse_for_in(ForInTarget::Expr(expr), start, false); } Some(ForInit::Expr(expr)) }; @@ -522,12 +535,27 @@ impl<'s> Parser<'s> { } /// The rest of `for (target in object) body`, from `in`. - fn parse_for_in(&mut self, target: ForInTarget, start: Span) -> Result { + fn parse_for_in(&mut self, target: ForInTarget, start: Span, each: bool) -> Result { self.expect_keyword(Keyword::In)?; let object = self.parse_expression(false)?; self.expect(Punct::RParen)?; let body = self.parse_loop_body()?; - Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body }, self.span_from(start))) + Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body, each }, self.span_from(start))) + } + + /// `for each (target in object) body`, from the target (ECMA-357 section 12.3). + fn parse_for_each(&mut self, start: Span) -> Result { + if self.at_keyword(Keyword::Var) { + self.bump()?; + let mut decls = self.parse_var_decls(true)?; + let (Some(decl), true) = (decls.pop(), decls.is_empty()) else { + return Err(self.error_at_token(&self.tok, "for each takes one variable")); + }; + return self.parse_for_in(ForInTarget::Var(decl), start, true); + } + let expr = self.parse_expression(true)?; + self.check_target(expr, "invalid left-hand side in for each loop")?; + self.parse_for_in(ForInTarget::Expr(expr), start, true) } /// `break` or `continue`, with an optional label on the same line, which must name a label @@ -608,7 +636,7 @@ impl<'s> Parser<'s> { let test = if self.at_keyword(Keyword::Case) { self.bump()?; Some(self.parse_expression(false)?) - } else if self.at_keyword(Keyword::Default) { + } else if self.at_keyword(Keyword::Default) && !self.at_default_xml_namespace() { if has_default { return Err(self.error_at_token(&self.tok, "more than one default clause in switch statement")); } @@ -620,7 +648,10 @@ impl<'s> Parser<'s> { }; self.expect(Punct::Colon)?; let mut body = Vec::new(); - while !(self.at_keyword(Keyword::Case) || self.at_keyword(Keyword::Default) || self.at(Punct::RBrace)) { + while !(self.at_keyword(Keyword::Case) + || (self.at_keyword(Keyword::Default) && !self.at_default_xml_namespace()) + || self.at(Punct::RBrace)) + { if self.tok.kind == TokenKind::Eof { return Err(self.unexpected()); } @@ -953,8 +984,13 @@ impl<'s> Parser<'s> { let mut expr = self.parse_primary()?; loop { match &self.tok.kind { + TokenKind::Punct(Punct::DotDot) => expr = self.parse_xml_descendants(expr)?, TokenKind::Punct(Punct::Dot) => { self.bump()?; + if self.e4x && self.at_e4x_member() { + expr = self.parse_xml_member(expr)?; + continue; + } let Some(name) = self.tok.identifier_name().map(str::to_string) else { return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); }; @@ -1050,10 +1086,16 @@ impl<'s> Parser<'s> { self.expect(Punct::RParen)?; return Ok(self.ast.push_expr(Expr::Paren(inner), self.span_from(start))); } + TokenKind::Punct(Punct::Lt) if self.e4x => return self.parse_xml_literal(), + TokenKind::Punct(Punct::At | Punct::Star) if self.e4x => return self.parse_xml_scope_name(), _ => return Err(self.unexpected()), }; let token = self.bump()?; - Ok(self.ast.push_expr(expr, token.span)) + let id = self.ast.push_expr(expr, token.span); + if self.e4x && self.at(Punct::ColonColon) && matches!(token.kind, TokenKind::Ident(_)) { + return self.parse_xml_qualified_primary(id); + } + Ok(id) } fn parse_function_expression(&mut self) -> Result { @@ -1232,5 +1274,6 @@ fn describe(token: &Token) -> String { TokenKind::String(_) => "string".into(), TokenKind::RegExp { .. } => "regular expression".into(), TokenKind::Directive(_) => "preprocessor line".into(), + TokenKind::XmlLiteral => "XML literal".into(), } } diff --git a/crates/js/src/parser/xml.rs b/crates/js/src/parser/xml.rs new file mode 100644 index 000000000..e91691a58 --- /dev/null +++ b/crates/js/src/parser/xml.rs @@ -0,0 +1,439 @@ +//! E4X syntax (ECMA-357 sections 11.1 and 12.1): literals, names and the operators on them. +//! +//! A literal is read by scanning the source units with an explicit stack of open tags, so tag +//! nesting uses no native recursion and is held to [`crate::Options::max_depth`]. Only an embedded +//! `{expression}` re-enters the expression parser, and that recursion is counted as any other. + +use super::{Parser, Result, describe}; +use crate::ast::{Expr, ExprId, Stmt, StmtId, XmlNamePart, XmlPart, XmlQualifier}; +use crate::error::TOO_DEEP; +use crate::token::{Keyword, Punct, Span, TokenKind}; +use crate::xml::{is_name_start, is_xml_space, scan_name, scan_reference}; + +const LT: u16 = b'<' as u16; +const GT: u16 = b'>' as u16; +const SLASH: u16 = b'/' as u16; +const AMP: u16 = b'&' as u16; +const LBRACE: u16 = b'{' as u16; +const EQ: u16 = b'=' as u16; +const DQUOTE: u16 = b'"' as u16; +const SQUOTE: u16 = b'\'' as u16; + +/// A literal being read: the markup since the last embedded expression, and the finished parts. +#[derive(Default)] +struct Scan { + text: Vec, + parts: Vec, +} + +impl Scan { + fn flush(&mut self) { + if !self.text.is_empty() { + self.parts.push(XmlPart::Text(std::mem::take(&mut self.text))); + } + } +} + +impl Parser<'_> { + pub(super) fn at_default_xml_namespace(&self) -> bool { + self.e4x && self.peek().is_some_and(|t| t.ident() == Some("xml")) + } + + /// `default xml namespace = expression;`, from `default`. + pub(super) fn parse_default_xml_namespace(&mut self, start: Span) -> Result { + self.expect_keyword(Keyword::Default)?; + self.bump()?; + if self.tok.ident() != Some("namespace") { + return Err(self.error_at_token(&self.tok, format!("expected 'namespace' but found {}", describe(&self.tok)))); + } + self.bump()?; + self.expect(Punct::Eq)?; + let value = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::DefaultXmlNamespace(value), self.span_from(start))) + } + + /// After a `.`: the next token starts an E4X name or a filter rather than a property name. + pub(super) fn at_e4x_member(&self) -> bool { + match &self.tok.kind { + TokenKind::Punct(Punct::At | Punct::Star | Punct::LParen) => true, + _ => self.tok.identifier_name().is_some() && self.peek().is_some_and(|t| t.is_punct(Punct::ColonColon)), + } + } + + /// `object.@name`, `object.*`, `object.ns::name` or `object.(predicate)`, from the token after + /// the `.`. + pub(super) fn parse_xml_member(&mut self, object: ExprId) -> Result { + let start = self.ast.expr_span(object); + if self.at(Punct::LParen) { + self.bump()?; + let predicate = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + return Ok(self.ast.push_expr(Expr::XmlFilter { object, predicate }, self.span_from(start))); + } + let index = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::Index { object, index }, self.span_from(start))) + } + + /// `object..name`, from the `..`. + pub(super) fn parse_xml_descendants(&mut self, object: ExprId) -> Result { + let start = self.ast.expr_span(object); + self.bump()?; + let name = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::XmlDescendants { object, name }, self.span_from(start))) + } + + /// `@name`, `*` or `@*` standing alone as a primary expression. + pub(super) fn parse_xml_scope_name(&mut self) -> Result { + let start = self.tok.span; + let name = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::XmlScopeName(name), self.span_from(start))) + } + + /// `qualifier::name` as a primary expression, from the `::`; `qualifier` is already read. + pub(super) fn parse_xml_qualified_primary(&mut self, qualifier: ExprId) -> Result { + let start = self.ast.expr_span(qualifier); + self.bump()?; + let name = self.parse_xml_name_part()?; + let qualifier = Some(XmlQualifier::Expr(qualifier)); + let id = self.ast.push_expr(Expr::XmlName { attribute: false, qualifier, name }, self.span_from(start)); + Ok(self.ast.push_expr(Expr::XmlScopeName(id), self.span_from(start))) + } + + /// An E4X name: `@`, then `*`, a name or `[expression]`, with an optional `qualifier::`. + fn parse_xml_name(&mut self) -> Result { + let start = self.tok.span; + let attribute = self.eat(Punct::At)?; + let first_is_star = self.at(Punct::Star); + let qualifier_ident = self.tok.identifier_name().map(str::to_string).filter(|_| matches!(self.tok.kind, TokenKind::Ident(_))); + let first = self.parse_xml_name_part()?; + let (qualifier, name) = if self.at(Punct::ColonColon) { + let qualifier = if first_is_star { + XmlQualifier::Any + } else if let Some(name) = qualifier_ident { + let span = self.span_from(start); + XmlQualifier::Expr(self.ast.push_expr(Expr::Ident(name), span)) + } else { + return Err(self.error_at_token(&self.tok, "expected a namespace name before '::'")); + }; + self.bump()?; + (Some(qualifier), self.parse_xml_name_part()?) + } else { + (None, first) + }; + Ok(self.ast.push_expr(Expr::XmlName { attribute, qualifier, name }, self.span_from(start))) + } + + fn parse_xml_name_part(&mut self) -> Result { + if self.eat(Punct::Star)? { + return Ok(XmlNamePart::Star); + } + if self.eat(Punct::LBracket)? { + let e = self.parse_expression(false)?; + self.expect(Punct::RBracket)?; + return Ok(XmlNamePart::Computed(e)); + } + let Some(name) = self.tok.identifier_name().map(str::to_string) else { + return Err(self.error_at_token(&self.tok, format!("expected an XML name but found {}", describe(&self.tok)))); + }; + self.bump()?; + Ok(XmlNamePart::Ident(name)) + } + + /// An XML or XMLList literal, from its `<` (the current token). + pub(super) fn parse_xml_literal(&mut self) -> Result { + let start = self.tok.span; + let token_slot = self.tokens.as_ref().map(Vec::len); + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let mut pos = start.start as usize; + let list = self.unit(pos + 1) == Some(GT); + if list { + pos += 2; + } + let mut scan = Scan::default(); + // The name of each open element; `None` when it is computed. + let mut open: Vec>> = Vec::new(); + loop { + if !list && open.is_empty() && !scan.text.is_empty() | !scan.parts.is_empty() { + break; + } + let Some(c) = self.unit(pos) else { return Err(self.xml_error(pos, "unterminated XML literal")) }; + if c == LT { + if self.unit(pos + 1) == Some(SLASH) { + if open.is_empty() { + if list && self.unit(pos + 2) == Some(GT) { + pos += 3; + break; + } + return Err(self.xml_error(pos, "a closing tag has no matching open tag")); + } + pos = self.xml_close_tag(pos, &mut open, &mut scan)?; + } else if self.starts_with(pos, ""); + } + XmlKind::Pi => { + let target = self.xml_node(x)?.name.as_ref().map(|n| n.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); + out.push_str(self, ""); + } + XmlKind::Element => {} + } + let ns_mark = scope.len(); + let node = self.xml_node(x)?; + let declared = node.ns.clone(); + let attrs = node.attrs.clone(); + let name = node.name.clone().ok_or_else(|| Abrupt::Internal("element without a name".into()))?; + self.xml_take_fuel(declared.len().saturating_mul(scope.len() + 1))?; + let mut unnamed = Vec::new(); + for ns in declared { + if ns.prefix.is_none() { + unnamed.push(ns); + } else if !scope.iter().any(|a| a.uri == ns.uri && a.prefix == ns.prefix) { + self.xml_charge(XML_NS_BYTES)?; + scope.push(ns); + } + } + for ns in unnamed { + if !scope.iter().any(|a| a.uri == ns.uri) { + let prefix = fresh_prefix(scope); + self.xml_charge(XML_NS_BYTES)?; + scope.push(XmlNs { prefix: Some(prefix), uri: ns.uri }); + } + } + let prefix = self.out_prefix(&name, scope, false)?; + let mut attr_prefixes = Vec::new(); + for &a in &attrs { + self.use_fuel()?; + let an = self.xml_node(a)?.name.clone().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; + attr_prefixes.push(self.out_prefix(&an, scope, true)?); + } + out.push_units(self, &[LT])?; + self.write_name(out, &prefix, &name.local)?; + for (&a, attr_prefix) in attrs.iter().zip(&attr_prefixes) { + let an = self.xml_node(a)?; + let local = an.name.as_ref().map(|q| q.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); + let value = an.value.flat(); + out.push_units(self, &[SPACE])?; + self.write_name(out, attr_prefix, &local)?; + out.push_units(self, &[EQ, DQUOTE])?; + self.write_escaped(out, &value, true)?; + out.push_units(self, &[DQUOTE])?; + } + for at in ns_mark..scope.len() { + let Some(ns) = scope.get(at).cloned() else { break }; + let ns_prefix = ns.prefix.unwrap_or_else(|| Rc::from(Vec::new())); + out.push_str(self, " xmlns")?; + if !ns_prefix.is_empty() { + out.push_units(self, &[COLON])?; + out.push_units(self, &ns_prefix)?; + } + out.push_units(self, &[EQ, DQUOTE])?; + self.write_escaped(out, &ns.uri, true)?; + out.push_units(self, &[DQUOTE])?; + } + let count = self.children_len(x)?; + if count == 0 { + scope.truncate(ns_mark); + return out.push_str(self, "/>"); + } + out.push_units(self, &[GT])?; + let first_is_text = match self.child_at(x, 0)? { + Some(c) => self.xml_kind(c)? == XmlKind::Text, + None => false, + }; + let pretty_children = pretty && (count > 1 || !first_is_text); + let child_indent = if pretty_children { indent.saturating_add(u64::from(settings.indent)) } else { 0 }; + stack.push(OutFrame { node: x, next: 0, indent, child_indent, pretty_children, ns_mark, prefix, local: name.local }); + Ok(()) + } + + fn write_node(&mut self, out: &mut Builder, root: ObjId, indent: u64, settings: &Settings, pretty: bool) -> JsResult<()> { + let mut scope: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + self.write_enter(out, root, indent, settings, pretty, &mut scope, &mut stack)?; + while let Some(frame) = stack.last_mut() { + let (node, next, child_indent, pretty_children) = (frame.node, frame.next, frame.child_indent, frame.pretty_children); + frame.next += 1; + match self.child_at(node, next)? { + Some(child) => { + if pretty_children { + out.push_units(self, &[LF])?; + } + self.write_enter(out, child, child_indent, settings, pretty && pretty_children, &mut scope, &mut stack)?; + } + None => { + let Some(frame) = stack.pop() else { break }; + if frame.pretty_children { + out.push_units(self, &[LF])?; + out.push_repeated(self, &[SPACE], frame.indent)?; + } + out.push_units(self, &[LT, SLASH])?; + self.write_name(out, &frame.prefix, &frame.local)?; + out.push_units(self, &[GT])?; + scope.truncate(frame.ns_mark); + } + } + } + Ok(()) + } + + /// ToXMLString (ECMA-357 section 10.2). `pretty` overrides the setting when it is given. + pub(crate) fn xml_string_of(&mut self, v: &Value, pretty: Option) -> JsResult { + let settings = self.xml_settings()?; + let pretty = pretty.unwrap_or(settings.pretty); + let mut out = Builder::new(); + match self.classify(v) { + Xv::Node(n) => self.write_node(&mut out, n, 0, &settings, pretty)?, + Xv::List(l) => { + for i in 0..self.list_len(l)? { + self.use_fuel()?; + let Some(item) = self.list_at(l, i)? else { break }; + if i > 0 && pretty { + out.push_units(self, &[LF])?; + } + self.write_node(&mut out, item, 0, &settings, pretty)?; + } + } + Xv::Other => return Err(self.not_xml()), + } + let Value::String(s) = out.finish(self) else { return Err(Abrupt::Internal("not a string".into())) }; + Ok(s) + } + + // ---- the parser ---- + + fn syntax(&mut self, message: &str) -> Abrupt { + self.throw(ErrorType::Syntax, message) + } + + fn decode_into(&mut self, src: &[u16], from: usize, to: usize, out: &mut Vec, attribute: bool) -> JsResult<()> { + self.xml_text_fuel(to.saturating_sub(from))?; + let mut pos = from; + while pos < to { + let Some(&c) = src.get(pos) else { break }; + if c == AMP { + let Some((after, ch)) = scan_reference(src, pos).filter(|(after, _)| *after <= to) else { + return Err(self.syntax("invalid XML character or entity reference")); + }; + push_char(out, ch); + pos = after; + continue; + } + if pos + 1 < to && starts_pair(c, src.get(pos + 1).copied()) { + out.extend_from_slice(src.get(pos..pos + 2).unwrap_or_default()); + pos += 2; + continue; + } + if !is_xml_char(u32::from(c)) { + return Err(self.syntax("invalid character in XML text")); + } + pos += 1; + match (c, attribute) { + (CR, _) => { + if src.get(pos) == Some(&LF) { + pos += 1; + } + out.push(if attribute { SPACE } else { LF }); + } + (TAB | LF, true) => out.push(SPACE), + _ => out.push(c), + } + } + Ok(()) + } + + fn find_from(src: &[u16], from: usize, needle: &[u16]) -> Option { + src.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| from + i) + } + + fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { + scan_name(src, pos).ok_or_else(|| self.syntax("invalid XML name")) + } + + fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { + if valid_xml_units(src.get(from..to).unwrap_or_default()) { + return Ok(()); + } + Err(self.syntax("invalid character in XML text")) + } + + /// Splits `prefix:local`. + fn split_name<'a>(&mut self, raw: &'a [u16]) -> JsResult<(Option<&'a [u16]>, &'a [u16])> { + let Some(colon) = raw.iter().position(|&c| c == COLON) else { return Ok((None, raw)) }; + let (prefix, rest) = raw.split_at(colon); + let local = rest.get(1..).unwrap_or_default(); + if !is_ncname(prefix) || !is_ncname(local) { + return Err(self.syntax("invalid XML qualified name")); + } + Ok((Some(prefix), local)) + } + + fn resolve_prefix(&mut self, prefix: &[u16], own: &[XmlNs], open: &[ObjId]) -> JsResult> { + if prefix == key("xml").as_ref() { + return Ok(Some(key(XML_URI))); + } + self.xml_take_fuel(own.len() + open.len() + 1)?; + if let Some(ns) = prefix_in_scope(own, prefix) { + return Ok(Some(ns.uri.clone())); + } + for &e in open.iter().rev() { + let node = self.xml_node(e)?; + let len = node.ns.len(); + if let Some(ns) = prefix_in_scope(&node.ns, prefix) { + return Ok(Some(ns.uri.clone())); + } + self.xml_take_fuel(len / UNITS_PER_FUEL)?; + } + Ok(None) + } + + fn place_parsed(&mut self, node: ObjId, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + if let Some(&top) = open.last() { + self.xml_charge_link(node)?; + self.xml_node_mut(top)?.children.push(node); + self.xml_node_mut(node)?.parent = Some(top); + } else { + self.xml_keep(node); + self.xml_charge(XML_REF_BYTES)?; + roots.push(node); + } + Ok(()) + } + + fn flush_text(&mut self, text: &mut Vec, ignore_whitespace: bool, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + if text.is_empty() { + return Ok(()); + } + if ignore_whitespace && text.iter().all(|&c| is_xml_space(c)) { + text.clear(); + return Ok(()); + } + let t = self.new_text_node(Str::from_units(std::mem::take(text)), None)?; + self.place_parsed(t, open, roots) + } + + fn parse_open_tag( + &mut self, + src: &[u16], + pos: usize, + open: &mut Vec, + names: &mut Vec<(usize, usize)>, + roots: &mut Vec, + ) -> JsResult { + if open.len() >= self.options.max_depth { + return Err(self.syntax(TOO_DEEP)); + } + let name_start = pos + 1; + let name_end = self.read_name(src, name_start)?; + let raw_name = src.get(name_start..name_end).unwrap_or_default(); + let (elem_prefix, elem_local) = self.split_name(raw_name)?; + let mut at = name_end; + let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); + let closes = loop { + let before = at; + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + match src.get(at).copied() { + None => return Err(self.syntax("unterminated XML tag")), + Some(SLASH) if src.get(at + 1) == Some(>) => { + at += 2; + break true; + } + Some(GT) => { + at += 1; + break false; + } + Some(_) if at == before => return Err(self.syntax("expected white space before an attribute")), + Some(_) => { + self.use_fuel()?; + let end = self.read_name(src, at)?; + let mut p = end; + while src.get(p).copied().is_some_and(is_xml_space) { + p += 1; + } + if src.get(p) != Some(&EQ) { + return Err(self.syntax("expected '=' after an attribute name")); + } + p += 1; + while src.get(p).copied().is_some_and(is_xml_space) { + p += 1; + } + let quote = match src.get(p).copied() { + Some(q @ (DQUOTE | SQUOTE)) => q, + _ => return Err(self.syntax("expected a quoted attribute value")), + }; + let value_start = p + 1; + let Some(value_end) = Self::find_from(src, value_start, &[quote]) else { + return Err(self.syntax("unterminated attribute value")); + }; + if src.get(value_start..value_end).is_some_and(|v| v.contains(<)) { + return Err(self.syntax("'<' cannot appear in an attribute value")); + } + let mut value = Vec::new(); + self.decode_into(src, value_start, value_end, &mut value, true)?; + raw_attrs.push((at, end, value)); + at = value_end + 1; + } + } + }; + self.xml_charge(XML_NS_BYTES.saturating_mul(raw_attrs.len()))?; + let mut ns: Vec = Vec::new(); + let mut seen_decl: HashSet> = HashSet::new(); + for (start, end, value) in &raw_attrs { + let raw = src.get(*start..*end).unwrap_or_default(); + if raw == key("xmlns").as_ref() { + if !ns_binding_allowed(&[], value) { + return Err(self.syntax(RESERVED_NS_BINDING)); + } + if !seen_decl.insert(Vec::new()) { + return Err(self.syntax("a namespace is declared twice")); + } + ns.push(XmlNs { prefix: Some(Rc::from(Vec::new())), uri: Rc::from(value.as_slice()) }); + } else if let (Some(p), local) = self.split_name(raw)? + && p == key("xmlns").as_ref() + { + if value.is_empty() { + return Err(self.syntax("a namespace prefix cannot be declared with an empty URI")); + } + if !ns_binding_allowed(local, value) { + return Err(self.syntax(RESERVED_NS_BINDING)); + } + if !seen_decl.insert(local.to_vec()) { + return Err(self.syntax("a namespace is declared twice")); + } + ns.push(XmlNs { prefix: Some(Rc::from(local)), uri: Rc::from(value.as_slice()) }); + } + } + let uri = match elem_prefix { + Some(p) => match self.resolve_prefix(p, &ns, open)? { + Some(u) => u, + None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), + }, + None => match self.resolve_prefix(&[], &ns, open)? { + Some(u) => u, + None => self.xml_default_uri()?, + }, + }; + let name = XmlName { uri: Some(uri), local: Rc::from(elem_local), prefix: Some(Rc::from(elem_prefix.unwrap_or_default())) }; + let element = self.new_xml_node(XmlNode { + kind: XmlKind::Element, + name: Some(name), + parent: None, + value: Str::from(""), + attrs: Vec::new(), + children: Vec::new(), + ns, + })?; + self.place_parsed(element, open, roots)?; + let mut unique: HashSet<(Key, Vec)> = HashSet::new(); + for (start, end, value) in raw_attrs { + self.use_fuel()?; + let raw = src.get(start..end).unwrap_or_default(); + let (prefix, local) = self.split_name(raw)?; + if raw == key("xmlns").as_ref() || prefix.is_some_and(|p| p == key("xmlns").as_ref()) { + continue; + } + let (auri, stored_prefix): (Key, Key) = match prefix { + Some(p) => { + let own = self.xml_node(element)?.ns.clone(); + match self.resolve_prefix(p, &own, open)? { + Some(u) => (u, Rc::from(p)), + None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), + } + } + None => (Rc::from(Vec::new()), Rc::from(Vec::new())), + }; + if !unique.insert((auri.clone(), local.to_vec())) { + return Err(self.type_error("an XML element has two attributes with the same name")); + } + let a = self.new_xml_node(XmlNode { + kind: XmlKind::Attribute, + name: Some(XmlName { uri: Some(auri), local: Rc::from(local), prefix: Some(stored_prefix) }), + parent: Some(element), + value: Str::from_units(value), + attrs: Vec::new(), + children: Vec::new(), + ns: Vec::new(), + })?; + self.xml_charge_link(a)?; + self.xml_node_mut(element)?.attrs.push(a); + } + if !closes { + open.push(element); + names.push((name_start, name_end)); + } + Ok(at) + } + + fn parse_close_tag(&mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>) -> JsResult { + let name_start = pos + 2; + let name_end = self.read_name(src, name_start)?; + let mut at = name_end; + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + if src.get(at) != Some(>) { + return Err(self.syntax("expected '>' at the end of a closing tag")); + } + let Some((open_start, open_end)) = names.pop() else { + return Err(self.syntax("a closing tag has no matching open tag")); + }; + if src.get(open_start..open_end) != src.get(name_start..name_end) { + return Err(self.syntax("the closing tag does not match the open tag")); + } + open.pop(); + Ok(at + 1) + } + + /// Parses XML text into nodes, kept reachable through the temporary roots until the caller's + /// scope ends. + pub(crate) fn xml_parse(&mut self, source: &[u16]) -> JsResult> { + let mut held = HeldBytes::new(self); + held.add(self, string_bytes(source.len()).saturating_mul(2))?; + let settings = self.xml_settings()?; + let mut src = source; + let mut offset_start = 0; + while src.get(offset_start).copied().is_some_and(is_xml_space) { + offset_start += 1; + } + if src.get(offset_start..).is_some_and(|r| r.starts_with(&parser_units("")) else { + return Err(self.syntax("unterminated XML declaration")); + }; + src = src.get(end + 2..).unwrap_or_default(); + } + let mut pos = 0; + let mut roots = Vec::new(); + let mut open: Vec = Vec::new(); + let mut names: Vec<(usize, usize)> = Vec::new(); + let mut text: Vec = Vec::new(); + while let Some(&c) = src.get(pos) { + self.use_fuel()?; + if c != LT { + let end = Self::find_from(src, pos, &[LT]).unwrap_or(src.len()); + let section_end = parser_units("]]>"); + if src.get(pos..end).is_some_and(|run| run.windows(3).any(|w| w == section_end)) { + return Err(self.syntax("']]>' cannot appear in XML text")); + } + self.decode_into(src, pos, end, &mut text, false)?; + pos = end; + continue; + } + let rest = src.get(pos..).unwrap_or_default(); + if rest.starts_with(&parser_units("")) else { + return Err(self.syntax("unterminated CDATA section")); + }; + self.xml_text_fuel(end - pos)?; + self.check_units(src, pos + 9, end)?; + text.extend_from_slice(src.get(pos + 9..end).unwrap_or_default()); + pos = end + 3; + continue; + } + self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; + if rest.starts_with(&parser_units("&y]]>');"; + truth(&format!("{src} t.children().length() === 5")); + truth(&format!("{src} t.children()[0].nodeKind() === 'text' && t.children()[0].toString() === 'x'")); + truth(&format!("{src} t.children()[1].nodeKind() === 'comment' && t.comments().length() === 1")); + truth(&format!("{src} t.children()[2].nodeKind() === 'processing-instruction' && t.children()[2].name().localName === 'pi'")); + truth(&format!("{src} t.children()[3].nodeKind() === 'element'")); + truth(&format!("{src} t.children()[4].toString() === '&y'")); + truth(&format!("{src} t.@p == '1' && t.@q == '<' && t.attributes().length() === 2 && t.@p.nodeKind() === 'attribute'")); +} + +#[test] +fn serialization_matches_the_fixed_markup() { + assert_eq!(string("(1).toXMLString()"), "\n 1\n \n"); + assert_eq!(string("XML.prettyPrinting = false; (1).toXMLString()"), "1"); + assert_eq!(string("XML.prettyIndent = 4; ().toXMLString()"), "\n \n"); + assert_eq!(string("(<&>).toXMLString()"), "<&>"); + assert_eq!(string("().toXMLString()"), "\n \n"); + assert_eq!(string("(<>).toXMLString()"), "\n"); +} + +#[test] +fn embedded_expressions_are_escaped_by_position() { + assert_eq!(string("var v = '<&>'; ({v}).toXMLString()"), "<&>"); + assert_eq!(string("var v = 'a\"<'; ().toXMLString()"), ""); + assert_eq!(string("var n = 'tag'; (<{n} x=\"1\">t).toXMLString()"), "t"); + assert_eq!(string("var c = 1; ({c}).toXMLString()"), "\n 1\n"); + assert_eq!(string("var a = 'p=\"1\"'; ().toXMLString()"), ""); + truth("var i = 0; var x = {i++}{i++}; x == '01' && i === 2"); + truth("var o = {t: 1}; ({o.t ? 'y' : 'n'}) == 'y'"); + truth("({'}'}) == '}'"); + truth("({1 < 2}) == 'true'"); +} + +#[test] +fn a_computed_tag_name_cannot_add_markup() { + for src in [ + r#"(<{'a x="1"'}/>)"#, + "(<{'a>)", + "(t)", + "(<{''}/>)", + "(<{'1a'}/>)", + "()", + r#"()"#, + ] { + assert_eq!(thrown_name(src), "SyntaxError", "{src}"); + } +} + +#[test] +fn a_computed_name_may_be_any_xml_name() { + assert_eq!(string("(<{'a.b-c'}/>).toXMLString()"), ""); + assert_eq!(string("(<{'tag'}>t).toXMLString()"), "t"); + assert_eq!(string("var n = 'n:b'; (<{n}/>).toXMLString()"), "\n \n"); +} + +#[test] +fn a_computed_attribute_name_matches_the_standard_example() { + let vars = "var tagname = 'name', attributename = 'id', attributevalue = 5, content = 'Fred';"; + let literal = "<{tagname} {attributename}={attributevalue}>{content}"; + assert_eq!(string(&format!("{vars} ({literal}).toXMLString()")), "Fred"); + assert_eq!(string(r#"var a = 'id'; ().toXMLString()"#), ""); +} + +#[test] +fn a_computed_attribute_run_cannot_add_markup() { + for src in [ + r#"()"#, + r#"(<>)"#, + "()", + "()", + r#"()"#, + r#"()"#, + ] { + assert_eq!(thrown_name(src), "SyntaxError", "{src}"); + } +} + +#[test] +fn a_computed_attribute_run_may_hold_any_attributes() { + truth( + r#"var a = " a=\"1\"\n b = '>' "; var x = ; x.@a == '1' && x.@b == '>' && x.attributes().length() === 2 && x.children().length() === 0"#, + ); + assert_eq!(string("().toXMLString()"), ""); + assert_eq!(string("().toXMLString()"), ""); +} + +// ---- selectors and updates ---- + +#[test] +fn selectors_return_the_documented_lists() { + let s = |rest: &str| truth(&format!("{BOOKS} {rest}")); + s("xml.book.length() === 4 && xml.book[0].author == 'Farmer'"); + s("xml.book[0].@category == 'COOKING' && xml.book.@category.length() === 4"); + s("xml.book[1].@category.toString() === 'CHILDREN'"); + s("xml.*.length() === 4 && xml.book[0].*.length() === 3"); + s("xml.book[0].@*.length() === 1"); + s("xml.nothing.length() === 0 && xml.@nothing.length() === 0"); + s("xml..title.length() === 4 && xml..title == xml.descendants('title')"); + s("xml..@lang.length() === 4 && xml..@category.length() === 4"); + s("xml.book.(@category == 'CHILDREN').length() === 2"); + s("xml.book.(year > 1899).title.length() === 2"); + s("xml.book.(author == 'Baum').title == 'Oz'"); + s("xml.*::book.length() === 4"); + s("var n = null; xml.n::book.length() === 4"); + s("xml['book'].length() === 4 && xml.book[0]['author'] == 'Farmer' && xml.book[0]['@category'] == 'COOKING'"); + s("var k = 'category'; xml.book[0].@[k] == 'COOKING'"); +} + +#[test] +fn the_top_sample_has_three_children_two_elements_and_seven_descendants() { + truth(&format!("{TOP} x.children().length() === 3")); + truth(&format!("{TOP} x.elements().length() === 2")); + truth(&format!("{TOP} x.descendants().length() === 7")); +} + +#[test] +fn a_filter_tests_each_item_once_in_order() { + truth( + "var seen = []; var l = <>123; var r = l.(seen.push(toString()), true); seen.join() === '1,2,3' && r.length() === 3", + ); + truth("().(false).length() === 0"); + assert_eq!(thrown_name("({}).(1)"), "TypeError"); + assert_eq!(thrown_name("var o = {}; o.@a"), "TypeError"); + assert_eq!(thrown_name("var o = {}; o..a"), "TypeError"); +} + +#[test] +fn assignment_and_delete_update_the_tree() { + let s = |rest: &str| truth(&format!("{BOOKS} {rest}")); + s( + "xml.book[2].author = 'Dodgeson'; xml.book[2].author.length() === 1 && xml.book[2].author == 'Dodgeson' && xml.book[2].*[1].localName() === 'author'", + ); + s("xml.book[2].author[0] = 'First'; xml.book[2].author.length() === 3 && xml.book[2].author[0] == 'First'"); + s("xml.book[1].year = 1901; xml.book[1].year == '1901'"); + s("xml.book[1].rating = '*****'; xml.book[1].*[3].localName() === 'rating' && xml.book[1].rating == '*****'"); + s("xml.book[1].@category = 'FANTASY'; xml.book[1].@category == 'FANTASY'"); + s("xml.book[1].@added = 'y'; xml.book[1].@added == 'y' && xml.book[1].attributes().length() === 2"); + s("delete xml.book[2].author; xml.book[2].author.length() === 0"); + s("delete xml.book[2].author[1]; xml.book[2].author.length() === 2 && xml.book[2].author[1] == 'C'"); + s("delete xml.book[2].@category; xml.book[2].@category.length() === 0"); + s("xml.book[0] = ; xml.book.length() === 4 && xml.book[0].@id == 'new' && xml.book[0].parent() === xml"); + assert_eq!(thrown_name("var x = ; x[0] = 1"), "TypeError"); + assert_eq!(thrown_name("var x = ; delete x[0]"), "TypeError"); + truth("var x = ; x.b.c = 'd'; x.b.c == 'd'"); + truth("var x = ; x.b += 'v'; x.b == 'v'"); + truth("var x = 1; x.n++; x.n == '2'"); + truth("var x = ; x.@t++; x.@t == '2'"); +} + +#[test] +fn operators_follow_the_standard() { + truth("var l = + ; typeof l === 'xml' && l.length() === 2"); + truth("var l = <> + ; l.length() === 2"); + truth("typeof === 'xml' && typeof <> === 'xml' && typeof new Namespace() === 'object'"); + truth("1 == 1 && !(1 == 2)"); + truth(" == "); + truth("1 == '1' && '1' == 1 && 1 == 1"); + truth("!(01 == 1) && true == true && null == null && undefined == undefined && !(x == null)"); + truth("<> == <> && !(<> == <>)"); + truth("new XMLList() == undefined"); + truth("new Namespace('p', 'u') == new Namespace('q', 'u') && new QName('u', 'a') == new QName('u', 'a')"); + truth("var s = ''; for (var i in <>) s += i; s === '01'"); + truth("var s = ''; for each (var v in <>12) s += v; s === '12'"); + truth("var s = ''; for each (var v in [3, 4]) s += v; s === '34'"); + truth("var s = ''; for each (var v in 7) s += v; s === '7'"); + truth("var x = 5; x.length == '5' && x.length() === 1"); + truth("var x = abc; x.charAt(1) === 'b'"); + truth("var l = <>abc; l.toUpperCase() === 'ABC'"); + assert_eq!(thrown_name("().noSuchMethod()"), "TypeError"); + assert_eq!(thrown_name("(<>).nodeKind()"), "TypeError"); + truth("with (1) { b == '1' }"); +} + +#[test] +fn the_default_namespace_is_scoped_to_the_function() { + truth( + "var ns = new Namespace('u'); default xml namespace = ns; var x = ; x.name().uri === 'u' && x.b.length() === 1 && x.b.name().uri === 'u'", + ); + truth("default xml namespace = 'u'; ().@p.name().uri === ''"); + truth( + "function f() { default xml namespace = 'u'; return ().name().uri; } \ + f() === 'u' && ().name().uri === ''", + ); + truth("function f() { default xml namespace = 'u'; return function () { return ().name().uri; }; } f()() === 'u'"); + truth("setDefaultXMLNamespace(new Namespace('p', 'v')); ().name().uri === 'v' && new QName('b').uri === 'v'"); + truth("default xml namespace = 'u'; new QName('a').uri === 'u' && new QName('*').uri === null"); + truth("var x = ; x.b.length() === 0"); + truth("var x = ; var n = new Namespace('u'); x.n::b.length() === 1"); + truth( + "var soap = new Namespace('http://schemas.xmlsoap.org/soap/envelope/'); default xml namespace = soap; \ + var m = ; \ + m.@soap::encodingStyle == 'e' && m.Body.length() === 1", + ); +} + +// ---- built-ins ---- + +#[test] +fn the_constructors_keep_identity_or_copy_as_specified() { + truth("XML().nodeKind() === 'text' && XML(null) == '' && XML(undefined) == ''"); + truth("var x = ; XML(x) === x && new XML(x) !== x && new XML(x) == x"); + truth("var l = <>; XML(l) === l[0]"); + truth("var p =

; var l = p.a; var y = new XML(l); y !== l[0] && y == l[0] && y.parent() === null && l[0].parent() === p"); + truth( + "var p =

; var l = p.a; var y = new XML(l); y.@q = '2'; y.c.@r = '3'; l[0].@q == '1' && l[0].c.@r.length() === 0 && y.c.@r == '3'", + ); + assert_eq!(thrown_name("XML(<>)"), "TypeError"); + assert_eq!(thrown_name("XML('')"), "SyntaxError"); + assert_eq!(thrown_name("XML('')"), "SyntaxError"); + truth("XMLList().length() === 0 && XMLList('').length() === 2"); + truth("var l = <>; XMLList(l) === l && new XMLList(l) !== l && new XMLList(l)[0] === l[0]"); + truth("var n = new Namespace('p', 'u'); Namespace(n) === n && new Namespace(n) !== n && new Namespace(n).prefix === 'p'"); + truth("var n = new Namespace(); n.prefix === '' && n.uri === '' && n.toString() === ''"); + truth("var n = new Namespace('u'); n.prefix === undefined && n.uri === 'u'"); + truth("new Namespace('1bad', 'u').prefix === undefined"); + assert_eq!(thrown_name("new Namespace('p', '')"), "TypeError"); + truth("var q = new QName('u', 'a'); QName(q) === q && new QName(q) !== q && q.uri === 'u' && q.localName === 'a'"); + truth("var q = new QName(new Namespace('p', 'u'), 'a'); q.uri === 'u' && q.toString() === 'u::a'"); + truth("new QName('*').toString() === '*::*' && new QName(null, 'a').uri === null && new QName().localName === ''"); + truth("isXMLName('abc') && isXMLName('a-b') && !isXMLName('a.b') && !isXMLName('$a') && !isXMLName('1a') && !isXMLName('')"); + truth("().localName() === 'a.b'"); +} + +#[test] +fn the_settings_are_per_realm_and_restore() { + truth( + "var d = XML.defaultSettings(); !d.ignoreComments && !d.ignoreProcessingInstructions && d.ignoreWhitespace && d.prettyPrinting && d.prettyIndent === 2", + ); + truth("var s = XML.settings(); XML.prettyIndent = 7; XML.settings().prettyIndent === 7 && (XML.setSettings(s), XML.prettyIndent === 2)"); + truth("XML.prettyIndent = 7; XML.setSettings(); XML.prettyIndent === 2"); + truth("XML.setSettings({ignoreWhitespace: false, prettyIndent: 'bad'}); !XML.ignoreWhitespace && XML.prettyIndent === 2"); + truth("(new XML('')).children().length() === 2"); + truth("XML.ignoreComments = true; XML.ignoreProcessingInstructions = true; (new XML('')).children().length() === 0"); + truth("XML.ignoreWhitespace = false; (new XML(' ')).children().length() === 3"); + truth("(new XML(' ')).children().length() === 1"); + truth("(new XML(' t ')).toString() === ' t '"); + let mut first = realm(); + first.run("XML.prettyIndent = 9").unwrap(); + assert_eq!(realm().run("XML.prettyIndent"), Ok(JsValue::Number(2.0))); +} + +#[test] +fn the_xml_methods_behave_as_specified() { + truth("var x = ; x.appendChild() === x && x.*[1].localName() === 'c' && x.c.parent() === x"); + truth("var x = ; x.appendChild('t'); x.text() == 't'"); + truth("var x = ; x.prependChild(); x.*[0].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(x.b[0], ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildBefore(x.d[0], ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(null, ); x.*[0].localName() === 'c'"); + truth("var x = ; x.insertChildBefore(null, ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(, ) === undefined && x.*.length() === 1"); + truth("var x = ; x.c[0].childIndex() === 1 && x.childIndex() === -1"); + truth("var x = ; x.attribute('p') == '1' && x.attributes().length() === 1"); + truth("var x = t; x.child('b').length() === 1 && x.child(1) == 't' && x.child(9).length() === 0"); + truth("var x = ; x.copy() !== x && x.copy() == x && x.copy().parent() === null && x.b.copy().parent() === undefined"); + truth("var c = ().b[0].copy(); c.parent() === null"); + truth("var x = ; x.contains()"); + truth("var x = ; x.hasOwnProperty('b') && !x.hasOwnProperty('c') && x.hasComplexContent() && !x.hasSimpleContent()"); + truth("var x = t; !x.hasComplexContent() && x.hasSimpleContent() && x.length() === 1"); + truth( + "var x = ; x.b[0].inScopeNamespaces().length === 1 && x.namespaceDeclarations()[0].prefix === 'p' && x.b[0].namespaceDeclarations().length === 0", + ); + truth("var x = ; x.namespace('p').uri === 'u' && x.namespace('q') === undefined && x.namespace().uri === ''"); + truth("var x = ; x.addNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); + truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p') === undefined"); + truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); + truth("var x = ; x.name().localName === 'a' && x.localName() === 'a'"); + truth("(t).*[0].name() === null && (t).*[0].localName() === null"); + truth("var x = 1; x.b[0].appendChild('2'); x.b[0].*.length() === 2 && (x.normalize(), x.b[0].*.length() === 1) && x.b == '12'"); + truth("var x = ; x.b[0].parent() === x && x.parent() === null"); + truth("var x = ; x.processingInstructions().length() === 2 && x.processingInstructions('r').length() === 1"); + truth("var x = ; x.propertyIsEnumerable(0) && !x.propertyIsEnumerable(1)"); + truth("var x = ; x.replace('b', ); x.*.length() === 2 && x.*[0].localName() === 'z' && x.*[1].localName() === 'c'"); + truth("var x = ; x.replace(0, 't'); x.* == 't'"); + truth("var x = ; x.replace('nothing', ) === x && x.b.length() === 1"); + truth("var x = ; x.setChildren( + ) === x && x.*.length() === 2 && x.c.length() === 1"); + truth("var x = ; x.setLocalName('b'); x.localName() === 'b'"); + truth("var x = ; x.setName('b'); x.localName() === 'b'"); + truth("var x = ; x.setNamespace(new Namespace('p', 'u')); x.name().uri === 'u' && x.toXMLString() === ''"); + assert_eq!(thrown_name("().setLocalName('1 x')"), "TypeError"); + assert_eq!(thrown_name("().setName('a b')"), "TypeError"); + assert_eq!(string("(1).toString()"), "1"); + assert_eq!(string("().toString()"), "\n \n"); + truth("var x = ; x.valueOf() === x"); + truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')"); + truth("var x = ; x.b.attributes !== undefined"); +} + +#[test] +fn the_xml_list_methods_work_item_by_item() { + let l = "var l = <>s;"; + truth(&format!("{l} l.length() === 2 && l.attribute('p').length() === 2 && l.attributes().length() === 2")); + truth(&format!("{l} l.child('x').length() === 1 && l.children().length() === 4 && l.child(0).length() === 2")); + truth(&format!("{l} l.comments().length() === 1 && l.elements().length() === 2 && l.text() == 's'")); + truth(&format!("{l} l.descendants().length() === 4")); + truth(&format!("{l} l.contains(s) && !l.contains()")); + truth(&format!("{l} l.copy()[0] !== l[0] && l.copy() == l")); + truth(&format!("{l} l.hasOwnProperty(0) && !l.hasOwnProperty(5) && l.propertyIsEnumerable(1) && !l.propertyIsEnumerable(2)")); + truth(&format!("{l} l.hasComplexContent() && !l.hasSimpleContent()")); + truth(&format!("{l} l.parent() === undefined && l.valueOf() === l")); + truth("var x = ; x.*.parent() === x"); + truth("new XMLList().hasSimpleContent() && !new XMLList().hasComplexContent() && new XMLList().parent() === undefined"); + truth("var l = <>12; l.normalize() === l && l.toString() === '1\\n2'"); + truth("var r = 1; var l = r.a; l[0] = 2; r.a == '2' && l[0] == '2'"); + truth("var r = 1; r.a[1] = 'n'; r.a.length() === 2 && r.a[1] == 'n' && r.*[1].parent() === r"); + truth("var r = 1; var list = r.b; list.c = 'v'; r.b.c == 'v'"); + truth("var r = ; r.a.@t[0] = '2'; r.a.@t == '2'"); + truth("var r = 1; r.a[0] = <>

; r.*.length() === 3 && r.*[0].localName() === 'p'"); +} + +#[test] +fn inserting_a_node_moves_it_out_of_its_old_parent() { + truth("var b = ; var a = ; var c = b.c[0]; a.appendChild(c); b.c.length() === 0 && a.c.length() === 1 && c.parent() === a"); + truth("var a = ; a.appendChild(a.b[0]); a.*.length() === 2 && a.*[0].localName() === 'c'"); + truth("var b = ; var a = ; var c = b.c[0]; a.appendChild(c); c.appendChild(b); a..*.length() === 2 && b.parent() === c"); +} + +#[test] +fn namespaces_survive_serialization() { + assert_eq!(string("var x = ; x.toXMLString()"), "\n \n"); + assert_eq!(string("var x = ; x.toXMLString()"), ""); + truth("var x = ; var y = new XML(x.toXMLString()); x == y && y.*[0].name().uri === 'u'"); + truth( + "var q = new QName(new Namespace('u'), 'a'); var x = new XML(''); x.appendChild(); x.*[0].setName(q); (new XML(x.toXMLString())).*[0].name().uri === 'u'", + ); + truth( + "var x = ; x.@[new QName('u', 'b')] = '1'; var y = new XML(x.toXMLString()); y.attributes()[0].name().uri === 'u' && y.attributes()[0] == '1'", + ); +} + +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; +const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/"; + +#[test] +fn markup_cannot_declare_a_reserved_namespace_binding() { + let declarations = [ + "xmlns:xml='wrong'".to_string(), + "xmlns:xmlns='u'".to_string(), + format!("xmlns:xmlns='{XMLNS_NS}'"), + format!("xmlns:p='{XML_NS}'"), + format!("xmlns:p='{XMLNS_NS}'"), + format!("xmlns='{XML_NS}'"), + format!("xmlns='{XMLNS_NS}'"), + ]; + for declaration in declarations { + assert_eq!(thrown_name(&format!("")), "SyntaxError", "{declaration}"); + assert_eq!(thrown_name(&format!("new XML(\"\")")), "SyntaxError", "{declaration}"); + } + assert_eq!(string(&format!("().toXMLString()")), format!("")); + assert_eq!(string("().toXMLString()"), ""); +} + +#[test] +fn add_namespace_rejects_a_reserved_binding_and_keeps_the_element() { + for (prefix, uri) in [("xmlns", "u"), ("xmlns", XMLNS_NS), ("xml", "u"), ("p", XML_NS), ("p", XMLNS_NS)] { + let src = + format!("var x = ; var name = 'none'; try {{ x.addNamespace(new Namespace('{prefix}', '{uri}')); }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{prefix} {uri}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{prefix} {uri}"); + } + assert_eq!( + string(&format!("var x = ; x.addNamespace(new Namespace('xml', '{XML_NS}')); x.toXMLString()")), + format!("") + ); +} + +#[test] +fn names_cannot_write_a_reserved_namespace_binding() { + for setter in [ + "setNamespace(new Namespace('xmlns', 'u'))", + "setName(new QName(new Namespace('xml', 'u'), 'b'))", + "setNamespace(new Namespace('p', 'http://www.w3.org/2000/xmlns/'))", + "setNamespace('http://www.w3.org/2000/xmlns/')", + ] { + let src = format!("var x = ; var name = 'none'; try {{ x.{setter}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{setter}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{setter}"); + } + let q = |prefix: &str| format!("new QName(new Namespace('{prefix}', 'u'), 'b')"); + assert_eq!(string(&format!("var x = ; x[{}] = '1'; x.toXMLString()", q("xmlns"))), "\n 1\n"); + assert_eq!(string(&format!("var x = ; x.@[{}] = '1'; x.toXMLString()", q("xml"))), ""); +} + +#[test] +fn an_attribute_cannot_be_renamed_xmlns() { + for setter in ["setName('xmlns')", "setLocalName('xmlns')"] { + let src = format!("var x = ; var name = 'none'; try {{ x.@b[0].{setter}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{setter}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{setter}"); + } +} + +#[test] +fn new_nodes_cannot_take_a_reserved_name() { + let q = format!("new QName('{XMLNS_NS}', 'b')"); + for update in ["x.@xmlns = 'u'".to_string(), format!("x[{q}] = '1'"), format!("x.@[{q}] = '1'"), format!("x[{q}][0] = '1'")] { + let src = format!("var x = ; var name = 'none'; try {{ {update}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{update}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{update}"); + } +} + +#[test] +fn the_xmlns_uri_cannot_be_the_default_namespace() { + assert_eq!(thrown_name(&format!("default xml namespace = '{XMLNS_NS}'")), "TypeError"); + assert_eq!(thrown_name(&format!("setDefaultXMLNamespace('{XMLNS_NS}')")), "TypeError"); +} + +#[test] +fn the_garbage_collector_keeps_trees_and_lists_alive() { + let mut r = realm(); + r.run("var x = text; var l = x.b; var ns = new Namespace('p', 'u'); var q = new QName('u', 'n');").unwrap(); + r.collect_garbage(); + assert_eq!(r.run("x.b.@t == '1' && l[0] == 'text' && l[0].parent() === x && ns.uri === 'u' && q.localName === 'n'"), Ok(JsValue::Bool(true))); + r.run("x = l = ns = q = null;").unwrap(); + let live = r.collect_garbage(); + assert!(live < 100_000, "{live}"); +} + +// ---- hostile input ---- + +#[test] +fn malformed_literals_are_located_syntax_errors() { + let cases = [ + "var a = ", + "", + "", + "", + "", + "", + "&bad;", + "�", + "{1", + "{", + "<1x/>", + "", + "", + ",<", + "", + "", + "<>", + "", + ]; + for src in cases { + let e = parse(src, &e4x()).expect_err(src); + assert!(e.line >= 1 && e.column >= 1, "{src}: {e:?}"); + } + let e = parse("var a =\n ;", &e4x()).unwrap_err(); + assert_eq!(e.line, 2); +} + +#[test] +fn bad_xml_text_is_a_catchable_syntax_error() { + for s in [ + "", + "", + "&nope;", + "", + "", + "", + "", + "]]>", + "", + ] { + assert_eq!(thrown_name(&format!("new XML({s:?})")), "SyntaxError", "{s}"); + } + assert_eq!(thrown_name("new XML('')"), "TypeError"); +} + +#[test] +fn unpaired_surrogates_are_rejected_in_xml_text() { + let lone = "String.fromCharCode(0xD800)"; + let low = "String.fromCharCode(0xDC00)"; + for value in [ + format!("'' + {lone} + ''"), + format!("'' + {low} + ''"), + format!("''"), + format!("''"), + format!("''"), + format!("''"), + format!("'<' + {lone} + '/>'"), + ] { + assert_eq!(thrown_name(&format!("new XML({value})")), "SyntaxError", "{value}"); + } + assert_eq!(thrown_name(&format!("().setLocalName({lone})")), "TypeError"); + let e = parse("var a = <\u{FFFD}/>;", &e4x()).unwrap_err(); + assert_eq!((e.line, e.column), (1, 10)); + truth("var p = String.fromCharCode(0xD83D, 0xDE00); var x = new XML('' + p + ''); x == p && x.@b == p"); + truth("var p = String.fromCharCode(0xD83D, 0xDE00); new XML('<' + p + '/>').localName() === p"); +} + +#[test] +fn collections_during_parsing_and_copying_keep_new_nodes() { + let src = "for (var i = 0; i < 200; i++) { \ + var x = new XML('t'); var c = x.copy(); \ + if (c.@p != '1' || c.b.@r != '3' || c.*.length() !== 3 || x.@q != '2') throw new Error('lost'); \ + } 'done'"; + for spare in (16_000..20_000).step_by(37) { + let mut r = realm(); + let live = r.collect_garbage(); + r.set_limits(Limits { heap_bytes: live + spare, ..Limits::default() }); + assert_eq!(r.run(src), Ok(JsValue::String("done".into())), "{spare}"); + } +} + +#[test] +fn collections_during_updates_and_queries_keep_new_values() { + let src = "for (var i = 0; i < 60; i++) { \ + var x = new XML('t'); var c = x.copy(); \ + var l = <>{x.b}{'t'}; \ + c.appendChild(); c.prependChild('s'); c.insertChildAfter(c.b[0], ); c.insertChildBefore(c.b[0], 'n'); \ + c.replace(0, 1); c.n.o = 'v'; c.@t = 'w'; c.z[0] = ; c.g.h = 'deep'; \ + var f = x.*.(nodeKind() === 'element'); var d = x..*; var j = x + c; var m = x.b + ; \ + var ns = new Namespace('p', 'u'); var qn = new QName(ns, 'a'); c.addNamespace(ns); c.setNamespace(ns); c.setName(qn); \ + var scope = c.inScopeNamespaces(); var decl = c.namespaceDeclarations(); c.normalize(); \ + var text = c.toXMLString(); var again = new XML(text); var children = c.children(); var el = c.elements(); var attrs = c.attributes(); \ + var filled = new XMLList(l); var copied = l.copy(); c.setChildren(); \ + if (x.@p != '1' || x.*.length() !== 3 || f.length() !== 1 || j.length() !== 2 || m.length() !== 2) throw new Error('lost'); \ + if (l.length() !== 3 || copied.length() !== 3 || c.*.length() !== 1 || scope.length < 1 || d.length() !== 3) throw new Error('lost'); \ + } 'done'"; + let mut first_error = None; + let mut completed = 0; + for spare in (40_000..56_000).step_by(211) { + let mut r = realm(); + let live = r.collect_garbage(); + r.set_limits(Limits { heap_bytes: live + spare, ..Limits::default() }); + let got = r.run(src); + if got == Ok(JsValue::String("done".into())) { + completed += 1; + } else if !matches!(&got, Err(e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) { + first_error = Some(format!("{spare}: {got:?}")); + break; + } + } + assert_eq!(first_error, None); + assert!(completed > 0); +} + +#[test] +fn a_scope_name_skips_a_list_without_that_name() { + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '1' }"); + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '2' }"); +} + +#[test] +fn a_bracket_key_converts_to_a_string_before_it_names_a_child() { + truth("var x = 1; x[true] == '1' && (true in x) && !(false in x)"); + truth("var x = 1; x[null] == '1' && x[undefined].length() === 0 && x[1.5].length() === 0 && x[-1].length() === 0"); + truth("var x = ; x[false] = 'v'; x['false'] == 'v' && (delete x[false], x.*.length() === 0)"); + assert_eq!(thrown_name("().attribute(true)"), "TypeError"); + assert_eq!(thrown_name("().descendants(null)"), "TypeError"); +} + +fn kind_of(limits: Limits, src: &str) -> RunErrorKind { + match limited(limits).run(src) { + Ok(v) => panic!("{} completed with {v:?}", &src[..src.len().min(80)]), + Err(e) => e.kind, + } +} + +fn on_small_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new().stack_size(512 << 10).spawn(f).unwrap().join().unwrap() +} + +#[test] +fn deep_trees_end_in_errors_on_a_small_stack() { + on_small_stack(|| { + let tags = 100_000; + let literal = format!("{}{}", "".repeat(tags), "".repeat(tags)); + let e = parse(&literal, &e4x()).unwrap_err(); + assert_eq!(e.message, crate::TOO_DEEP); + let text = format!("new XML('{literal}')"); + assert_eq!(limited(Limits::default()).run(&text).map_err(|e| e.message.contains(crate::TOO_DEEP)), Err(true)); + let chain = "var x = ; for (var i = 0; i < 100000; i++) { var y = ; y.appendChild(x); x = y; }"; + assert_eq!(kind_of(Limits::default(), chain), RunErrorKind::Limit(LimitKind::Heap)); + let within = "var x = ; for (var i = 0; i < 150; i++) { var y = ; y.appendChild(x); x = y; } \ + var c = x.copy(); [c == x, x.descendants().length(), x.toXMLString().length > 0, (x.normalize(), 1)].join()"; + assert_eq!(limited(Limits::default()).run(within), Ok(JsValue::String("true,150,true,1".into()))); + }); +} + +#[test] +fn cyclic_insertion_throws_and_leaves_the_tree_alone() { + for src in [ + "x.appendChild(x)", + "x.b[0].appendChild(x)", + "x.b[0].c[0].appendChild(x.b[0])", + "x.b[0].prependChild(x)", + "x.b[0].insertChildAfter(null, x)", + "x.b[0].insertChildBefore(null, x)", + ] { + let setup = "var x = ; var before = x.toXMLString();"; + let script = format!("{setup} var n; try {{ {src}; n = 'none' }} catch (e) {{ n = e.name }} x.toXMLString() === before ? n : 'changed'"); + assert_eq!(string(&script), "TypeError", "{src}"); + } +} + +#[test] +fn replace_inserts_a_copy_so_an_ancestor_can_be_the_value() { + truth("var x = ; x.b[0].replace(0, x); x.b.a.b.c.length() === 1 && x.b.a[0] !== x && x.descendants().length() === 4"); +} + +const TIGHT: Limits = Limits { fuel: 200_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }; + +#[test] +fn big_values_end_with_the_heap_limit() { + let attrs = format!("var x = new XML('');", (0..50_000).map(|i| format!("a{i}=\"1\"")).collect::>().join(" ")); + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, &attrs), RunErrorKind::Limit(LimitKind::Heap)); + let wide = "var l = <>; for (;;) { l = l + l; }"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, wide), RunErrorKind::Limit(LimitKind::Heap)); + let giant = "var s = 'x'; for (var i = 0; i < 40; i++) s += s; new XML('' + s + '')"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, giant), RunErrorKind::Limit(LimitKind::Heap)); + let escaped = "var s = '<'; for (var i = 0; i < 17; i++) s += s; ({s}).toXMLString().length"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, escaped), RunErrorKind::Limit(LimitKind::Heap)); +} + +#[test] +fn catastrophic_queries_end_with_the_fuel_limit() { + let build = "var x = ; for (var i = 0; i < 2000; i++) x.appendChild();"; + let limits = Limits { fuel: 1_500_000, heap_bytes: 64 << 20, frames: 200, reentry: 8 }; + let filter = format!("{build} for (;;) x.a.(true);"); + assert_eq!(kind_of(limits.clone(), &filter), RunErrorKind::Limit(LimitKind::Fuel)); + let descendants = format!("{build} for (;;) x..a;"); + assert_eq!(kind_of(limits.clone(), &descendants), RunErrorKind::Limit(LimitKind::Fuel)); + let serialize = format!("{build} for (;;) x.toXMLString();"); + assert_eq!(kind_of(limits, &serialize), RunErrorKind::Limit(LimitKind::Fuel)); +} + +/// The fuel that `action` takes when it runs after `setup`. +fn fuel_of(setup: &str, action: &str) -> u64 { + let mut r = realm(); + r.run(setup).unwrap(); + r.run(action).unwrap(); + Limits::default().fuel - r.fuel_left() +} + +#[test] +fn insert_child_before_and_after_take_fuel_for_each_child_they_scan() { + let narrow = "var x = ; var last = x.b[0];"; + let wide = "var x = ; for (var i = 0; i < 2000; i++) x.appendChild(); var last = x.b[1999];"; + for action in + ["x.insertChildAfter(, )", "x.insertChildBefore(, )", "x.insertChildAfter(last, )", "x.insertChildBefore(last, )"] + { + let (n, w) = (fuel_of(narrow, action), fuel_of(wide, action)); + assert!(w >= n + 1999, "{action}: {n} with one child, {w} with 2000"); + } +} + +#[test] +fn limits_cannot_be_caught_by_the_script() { + assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); +} + +/// Collects garbage and sets the heap limit to the live size plus the argument, so a script can +/// run out of heap at a chosen charge. A negative argument puts the heap over its limit, which +/// also happens when a lowered limit meets a larger heap. +fn squeeze( + i: &mut crate::interp::Interp, + _: crate::value::Value, + args: &[crate::value::Value], + _: bool, +) -> crate::interp::JsResult { + i.collect_garbage(); + if let Some(crate::value::Value::Number(spare)) = args.first() { + i.limits.heap_bytes = (i.heap.bytes as f64 + *spare) as usize; + } + Ok(crate::value::Value::Undefined) +} + +/// Runs `setup`, then the action for each step from 0 until one completes, and after each stop +/// asserts that `check` is true with the limits lifted. Returns how many runs stopped at `kind`. +fn stops_with_the_tree_intact(setup: &str, action: impl Fn(usize) -> String, check: &str, kind: LimitKind, limit: impl Fn(usize) -> Limits) -> usize { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + let mut stops = 0; + for step in 0..200_000 { + r.set_limits(Limits::default()); + r.run(setup).unwrap(); + r.set_limits(limit(step)); + let action = action(step); + let got = r.run(&action); + r.set_limits(Limits::default()); + assert_eq!(r.run(check), Ok(JsValue::Bool(true)), "{action}: {got:?}"); + match got { + Ok(_) => return stops, + Err(e) if e.kind == RunErrorKind::Limit(kind) => stops += 1, + Err(_) => {} + } + } + panic!("{check} never completed"); +} + +/// Runs `action`, whose `SPARE` is replaced by a call that leaves each number of bytes free in +/// turn, from 512 too few. Actions put it after the steps whose allocations a collection could +/// free: an argument is evaluated after the callee is looked up. +fn heap_stops(setup: &str, action: &str, check: &str) -> usize { + let action = |spare: usize| action.replace("SPARE", &format!("squeeze({})", spare as i64 - 512)); + stops_with_the_tree_intact(setup, action, check, LimitKind::Heap, |_| Limits::default()) +} + +fn fuel_stops(setup: &str, action: &str, check: &str) -> usize { + stops_with_the_tree_intact(setup, |_| action.to_string(), check, LimitKind::Fuel, |fuel| Limits { fuel: fuel as u64, ..Limits::default() }) +} + +const MOVE_SETUP: &str = "var o = ; var t = ; var n = o.b[0];"; +const MOVE_CHECK: &str = + "o.b.length() + t.b.length() === 1 && (o.b.length() === 0 || o.b[0].parent() === o) && (t.b.length() === 0 || t.b[0].parent() === t)"; + +#[test] +fn a_heap_limit_during_a_move_leaves_the_node_in_its_old_parent() { + for action in + ["t.appendChild((SPARE, n))", "t.prependChild((SPARE, n))", "t.insertChildAfter(null, (SPARE, n))", "t.insertChildBefore(null, (SPARE, n))"] + { + assert!(heap_stops(MOVE_SETUP, action, MOVE_CHECK) > 0, "{action}"); + } +} + +#[test] +fn a_fuel_limit_during_a_move_leaves_the_node_in_its_old_parent() { + for action in ["t.appendChild(n)", "t.prependChild(n)"] { + assert!(fuel_stops(MOVE_SETUP, action, MOVE_CHECK) > 0, "{action}"); + } + let replaced = "var l = t.*; l[0] = o.b[0]"; + let setup = "var o = ; var t = ;"; + assert!(fuel_stops(setup, replaced, MOVE_CHECK) > 0); +} + +#[test] +fn a_heap_limit_on_replace_past_the_end_leaves_no_child_without_a_parent() { + let check = "var n = x.*.length(); if (n === 0) { true } else { \ + var v = x.*[n - 1]; var name; try { v.appendChild(x); name = 'none' } catch (e) { name = e.name } v.parent() === x && name === 'TypeError' }"; + let action = "x.replace({ toString: function () { SPARE; return '5'; } }, v)"; + assert!(heap_stops("var x = ; var v = ;", action, check) > 0); +} + +#[test] +fn limits_during_deletes_and_puts_keep_the_other_nodes() { + let setup = "var x = old;"; + let others = "x.c.length() === 1 && x.@q == '2'"; + for action in ["delete x.b", "delete x.@p", "x.@p = 'v'", "x.@n = 'v'", "x.b = 'v'"] { + assert!(fuel_stops(setup, action, others) > 0, "{action}"); + } + let held = format!("{others} && x.b.length() === 1 && (x.b == 'old' || x.b == 'v') && x.b.*[0].parent() === x.b[0]"); + assert!(fuel_stops(setup, "x.b = 'v'", &held) > 0); + assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &held) > 0); +} + +/// True when the markup of `tree` is `before` or `after`, ignoring white space. +fn tree_is(tree: &str, before: &str, after: &str) -> String { + format!("(function () {{ var s = {tree}.toXMLString().replace(/\\s/g, ''); return s === '{before}' || s === '{after}'; }})()") +} + +#[test] +fn a_limit_while_a_list_replaces_a_child_leaves_the_children() { + let setup = "var x = ; var v = <>

;"; + let replaced = tree_is("x", "", "

"); + for action in ["x.replace(0, v)", "x.b = v"] { + assert!(fuel_stops(setup, action, &replaced) > 0, "{action}"); + } + let spare = "x.replace({ toString: function () { SPARE; return '0'; } }, v)"; + assert!(heap_stops(setup, spare, &replaced) > 0); + let set = tree_is("x", "", "

"); + assert!(fuel_stops(setup, "x.setChildren(v)", &set) > 0); +} + +#[test] +fn a_limit_while_a_list_is_inserted_leaves_the_children() { + let setup = "var o =

; var x = ; var v = o.*;"; + let check = |after: &str| { + format!("{} && (o.*.length() === 0 ? x.*.length() === 3 : o.*.length() === 2 && x.*.length() === 1)", tree_is("x", "", after)) + }; + for (action, after) in [ + ("x.appendChild(v)", "

"), + ("x.prependChild(v)", "

"), + ("x.insertChildAfter(null, v)", "

"), + ("x.insertChildBefore(null, v)", "

"), + ] { + assert!(fuel_stops(setup, action, &check(after)) > 0, "{action}"); + } + assert!(heap_stops(setup, "x.appendChild((SPARE, v))", &check("

")) > 0); +} + +#[test] +fn a_heap_limit_on_a_list_put_at_an_index_keeps_the_list_and_tree_in_step() { + let setup = "var x = ; var l = x.*; var v = <>

;"; + let check = "var s = x.toXMLString().replace(/\\s/g, ''); \ + (s === '' && l.length() === 2 && l[0].name() == 'b') \ + || (s === '

' && l.length() === 3 && l[0].name() == 'p')"; + assert!(heap_stops(setup, "l[(SPARE, 0)] = v", check) > 0); +} + +#[test] +fn an_index_put_of_an_earlier_sibling_keeps_that_node_in_the_list() { + let setup = "var x = ; var l = x.*;"; + truth(&format!("{setup} l[2] = x.b; l[2] === x.b[0] && l[3] === x.e[0]")); + truth(&format!("{setup} l[2] = x.b[0]; l[2] === x.b[0] && l[3] === x.e[0]")); +} + +/// A realm whose trees may be 32 levels deep, and a script line making `deep`, a tree of 32 levels +/// that cannot go under any element. +fn shallow_with_deep_value() -> (Realm, &'static str) { + let deep = "var s = ''; var e = ''; for (var i = 0; i < 32; i++) { s += ''; e += ''; } var deep = new XML(s + e);"; + (Realm::with_options(Options { max_depth: 32, ..e4x() }, Limits::default()), deep) +} + +#[test] +fn a_too_deep_value_leaves_every_match_in_place() { + for action in ["x.replace('b', deep)", "x.b = deep"] { + let (mut r, deep) = shallow_with_deep_value(); + r.run(deep).unwrap(); + r.run("var x = ;").unwrap(); + assert_eq!(r.run(action).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Heap)), "{action}"); + assert_eq!(r.run("x.b.length() === 2 && x.*.length() === 3"), Ok(JsValue::Bool(true)), "{action}"); + } +} + +#[test] +fn a_limit_while_matches_are_replaced_keeps_every_match() { + let setup = "var x = ; var n = ; var l = <>

;"; + let two = ""; + for (action, after) in [ + ("x.replace('b', n)", ""), + ("x.replace('b', l)", "

"), + ("x.b = n", ""), + ("x.b = l", "

"), + ("x.b = 'v'", "v"), + ] { + assert!(fuel_stops(setup, action, &tree_is("x", two, after)) > 0, "{action}"); + } + let by_name = "x.replace({ toString: function () { SPARE; return 'b'; } }, n)"; + assert!(heap_stops(setup, by_name, &tree_is("x", two, "")) > 0); + assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &tree_is("x", two, "v")) > 0); +} + +#[test] +fn matches_are_replaced_in_document_order() { + truth("var x = 123; x.replace('b', ); x.*.length() === 3 && x.*[0].name() == 'n' && x.d.length() === 1"); + truth("var x = 12; var old = x.b[1]; x.b = 'v'; x.b.length() === 1 && x.b == 'v' && old.parent() === null"); + truth("var x = ; x.b = 'v'; x.b == 'v' && x.*.length() === 2 && x.b[0].parent() === x"); +} + +#[test] +fn an_attribute_put_among_children_goes_in_as_text_and_stays_on_its_owner() { + let setup = "var y = ; var x = ;"; + let check = "x.b.length() === 0 && x.children().length() === 1 && x.children()[0].nodeKind() === 'text' && x.toString() === 'v' \ + && y.@p.length() === 1 && y.@p.parent() === y && y.@p[0].parent() === y"; + for edit in ["x.replace(0, y.@p)", "x.replace(0, y.@p[0])", "x.b = y.@p", "x.setChildren(y.@p)", "x.*[0] = y.@p"] { + truth(&format!("{setup} {edit}; {check}")); + } + let owner = "y.@p.length() === 1 && y.@p.parent() === y && y.@p[0].parent() === y"; + for (edit, k) in [ + ("x.prependChild(y.@p)", "0"), + ("x.prependChild(y.@p[0])", "0"), + ("x.appendChild(y.@p)", "1"), + ("x.appendChild(y.@p[0])", "1"), + ("x.insertChildAfter(x.b[0], y.@p)", "1"), + ("x.insertChildBefore(x.b[0], y.@p[0])", "0"), + ] { + truth(&format!( + "{setup} {edit}; x.children().length() === 2 && x.children()[{k}].nodeKind() === 'text' && x.children()[{k}] == 'v' && {owner}" + )); + } +} + +#[test] +fn a_list_assigned_attributes_holds_the_text_nodes_in_the_tree() { + truth( + "var y = ; var x = ; var l = x.*; l[0] = y.@*; \ + l.length() === 2 && l[0].nodeKind() === 'text' && l[0].parent() === x && l[1].nodeKind() === 'text' && l[1].parent() === x \ + && x.toString() === 'vw' && y.@*.length() === 2", + ); +} + +#[test] +fn an_attribute_list_goes_in_as_text_at_the_depth_limit() { + let open = "".repeat(7); + let close = "".repeat(7); + let src = format!( + "var x = new XML('{open}{close}'); var c = x; while (c.a.length()) c = c.a[0]; \ + var y = ; c.prependChild(y.@p); c.toString() === 'v'" + ); + let mut realm = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default()); + assert_eq!(realm.run(&src), Ok(JsValue::Bool(true))); +} + +#[test] +fn a_limit_during_normalize_does_not_repeat_text() { + let setup = "var x = ; x.appendChild('p'); x.appendChild('q'); x.appendChild('r');"; + let check = "x.toString() === 'pqr'"; + assert!(fuel_stops(setup, "x.normalize()", check) > 0); +} + +/// Runs `action` as [`heap_stops`] does, and asserts that every run completes or stops at the heap +/// limit and that `check` is true after it. Returns how many runs stopped. +fn heap_stops_only(setup: &str, action: &str, check: &str) -> usize { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + let mut stops = 0; + for spare in 0..100_000i64 { + r.set_limits(Limits::default()); + r.run(setup).unwrap(); + let action = action.replace("SPARE", &format!("squeeze({})", spare - 512)); + let got = r.run(&action); + r.set_limits(Limits::default()); + assert_eq!(r.run(check), Ok(JsValue::Bool(true)), "{action}: {got:?}"); + match got { + Ok(_) => return stops, + Err(e) if e.kind == RunErrorKind::Limit(LimitKind::Heap) => stops += 1, + Err(e) => panic!("{action}: {e:?}"), + } + } + panic!("{action} never completed"); +} + +#[test] +fn collections_while_a_list_is_copied_keep_every_copy() { + let setup = "var x = ; var v = + ; var w = undefined;"; + let set = tree_is("x", "", ""); + for action in ["x.setChildren((SPARE, v))", "x.* = (SPARE, v)"] { + assert!(heap_stops_only(setup, action, &set) > 0, "{action}"); + } + let copied = "w === undefined || (w.length() === 2 && w[0].name() == 'c' && w[1].name() == 'd' && w[0] !== v[0])"; + assert!(heap_stops_only(setup, "w = (SPARE, v).copy()", copied) > 0); +} + +#[test] +fn a_limit_during_path_assignment_leaves_no_new_element() { + let setup = "var x = ; var v = ;"; + for (target, value, after) in [ + ("x.a.b.c", "v", ""), + ("x.a.b", "'text'", "text"), + ("x.a.@p", "'text'", ""), + ("x.a.b[0]", "'text'", "text"), + ] { + let check = tree_is("x", "", after); + assert!(fuel_stops(setup, &format!("{target} = {value}"), &check) > 0, "{target}"); + assert!(heap_stops_only(setup, &format!("{target} = (SPARE, {value})"), &check) > 0, "{target}"); + } +} + +#[test] +fn comparing_xml_values_is_charged_to_the_heap() { + let setup = "var x = t; var y = x.copy();"; + assert!(heap_stops_only(setup, "(SPARE, x == y)", "x == y && y.@q == '2'") > 0); +} + +/// Runs `setup` with no limit, then `action` in the same realm, and returns how `action` ended. +fn after_setup(setup: &str, action: &str) -> Result { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + r.run(setup).unwrap(); + r.run(action).map_err(|e| e.kind) +} + +#[test] +fn namespace_declarations_and_qname_strings_are_charged_to_the_heap() { + let heap = Err(RunErrorKind::Limit(LimitKind::Heap)); + let distinct = "var x = ; var a = []; for (var i = 0; i < 400; i++) a.push(new Namespace('p' + i, 'u' + i));"; + for method in ["addNamespace", "setNamespace"] { + let action = format!("squeeze(4096); for (var i = 0; i < 400; i++) x.{method}(a[i]); 1"); + assert_eq!(after_setup(distinct, &action), heap, "{method}"); + } + let same = "var x = ; var n = new Namespace('p', 'u');"; + let action = "squeeze(4096); for (var i = 0; i < 400; i++) x.addNamespace(n); 1"; + assert_eq!(after_setup(same, action), Ok(JsValue::Number(1.0))); + let long = "var s = 'x'; for (var i = 0; i < 12; i++) s += s; var q = new QName('u', s);"; + let action = "squeeze(12000); var t1 = q.toString(); var t2 = q.toString(); 1"; + assert_eq!(after_setup(long, action), heap); +} + +#[test] +fn an_interrupt_stops_serialization() { + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 500; i++) x.appendChild();").unwrap(); + let interrupt = r.interrupt(); + r.define_function("stop", move |_| { + interrupt.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); +} + +// ---- generated input ---- + +const PIECES: &[&str] = &[ + "<", + ">", + "/", + "", + "", + "", + "", + "", + "<>", + "", + "{", + "}", + "{1}", + "'", + "\"", + "&", + "&", + "A", + "&bad;", + "", + "", + "", + "@", + "::", + "..", + ".(", + ")", + "*", + "/x/", + " ", + "\n", + "a", + "1", + "=", + "ns", + ":", + "xmlns", + "x.@a", + "x..y", + "default xml namespace", +]; + +fn tree_markup(shape: &[u8]) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + out.push_str(""); + for (i, &b) in shape.iter().enumerate() { + match b % 6 { + 0 if open.len() < 20 => { + out.push_str(&format!("")); + open.push(i); + } + 1 => { + if let Some(e) = open.pop() { + out.push_str(&format!("")); + } + } + 2 => out.push_str(&format!("")), + 3 => out.push_str(&format!("t{b}&")), + 4 => out.push_str(&format!("")), + _ => out.push_str(&format!("")), + } + } + while let Some(e) = open.pop() { + out.push_str(&format!("")); + } + out.push_str(""); + out +} + +const TAG_PIECES: [&str; 11] = ["a", "b", " ", "=", "\"", "'", "<", "/", "<", "a=\"1\"", ">; v.children().length() === 0 && v.attributes().length() === 0"), + format!("var v = ; v.children().length() === 0 && v.name().localName === 'x'"), + format!("(<>).length() === 1"), + format!("var v = ; v.children().length() === 1"), + ] { + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + match r.run(&src) { + Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", src), + Err(e) => prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"), + } + } + } + + #[test] + fn source_mixes_parse_or_give_a_located_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let src: String = parts.iter().map(|&i| PIECES[i]).collect(); + if let Err(e) = parse(&src, &e4x()) { + prop_assert!(e.line >= 1 && e.column >= 1, "{src:?}: {e:?}"); + } + } + + #[test] + fn random_programs_never_reach_an_internal_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let src: String = format!("var x = ; {}", parts.iter().map(|&i| PIECES[i]).collect::()); + let mut r = limited(Limits { fuel: 20_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }); + if let Err(e) = r.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"); + } + } + + #[test] + fn xml_text_parses_or_throws(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let text: String = parts.iter().map(|&i| PIECES[i]).collect(); + let quoted = format!("{text:?}"); + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + if let Err(e) = r.run(&format!("new XML({quoted})")) { + prop_assert!(e.kind == RunErrorKind::Thrown, "{quoted}: {e:?}"); + } + } + + #[test] + fn serialized_trees_parse_to_an_equal_tree(shape in proptest::collection::vec(any::(), 0..80)) { + let markup = tree_markup(&shape); + let quoted = format!("{markup:?}"); + let plain = format!("XML.prettyPrinting = false; XML.ignoreWhitespace = false; var x = new XML({quoted}); var y = new XML(x.toXMLString()); x.normalize(); y.normalize(); x == y"); + prop_assert_eq!(limited(Limits::default()).run(&plain), Ok(JsValue::Bool(true)), "{}", markup); + let pretty = format!("var x = new XML({quoted}); var y = new XML(x.toXMLString()); x.toXMLString() === y.toXMLString()"); + prop_assert_eq!(limited(Limits::default()).run(&pretty), Ok(JsValue::Bool(true)), "{}", markup); + } + + #[test] + fn insert_sequences_stay_acyclic(steps in proptest::collection::vec((0usize..8, 0usize..8), 0..40)) { + let mut src = String::from("var n = []; for (var i = 0; i < 8; i++) n.push(new XML('')); var bad = 0;"); + for (a, b) in steps { + src.push_str(&format!("try {{ n[{a}].appendChild(n[{b}]); }} catch (e) {{ if (e.name !== 'TypeError') bad++; }}")); + } + src.push_str("var ok = bad === 0; for (var j = 0; j < 8; j++) { ok = ok && n[j].toXMLString().length > 0 && n[j].descendants().length() < 8; } ok"); + let got = limited(Limits::default()).run(&src); + prop_assert_eq!(got, Ok(JsValue::Bool(true)), "{}", src); + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index a6e16ea4c..bf32bcfcd 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -14,8 +14,8 @@ //! //! A second test runs the same ES5 tests through the interpreter, each in a new realm after the //! suite's `assert.js`, `sta.js` and the files its `includes:` names: every test that must parse -//! and uses only the built-ins the interpreter has (those that name a missing global or method, -//! in their own source or an included file, are counted as skipped, as are `async` tests). A test +//! and uses only the built-ins the interpreter has (those whose code, or an included file's, uses a +//! missing global or method are counted as skipped, as are `async` tests). A test //! passes when it completes, or, for `negative: phase: runtime`, when it throws an object whose //! `constructor` is the global its `type:` names. Its counts are printed apart from the parse //! counts, and its failures go to `run-failures.txt`. @@ -31,7 +31,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; -use vectorcraft_js::{JsValue, Limits, Options, Realm, RunErrorKind, TokenKind, parse, tokenize}; +use vectorcraft_js::{JsValue, Limits, Options, Punct, Realm, RunErrorKind, Token, TokenKind, parse, tokenize}; /// A test file's metadata, from its `/*--- … ---*/` block. #[derive(Default)] @@ -248,15 +248,41 @@ fn missing_builtins<'a>(provided: &[&str]) -> HashSet<&'a str> { MISSING_BUILTINS.split_whitespace().filter(|name| !provided.contains(name)).collect() } -/// The source names a missing built-in, as an identifier, a property name or a word inside a -/// string literal (code passed to `eval` or `Function`), or does not parse as ES5 (the tests that -/// use `let` and `const`). -fn needs_missing_builtin(source: &str, missing: &HashSet<&str>) -> bool { - let Ok(tokens) = tokenize(source, &Options::default()) else { return true }; - tokens.iter().any(|t| match &t.kind { +/// The code of `sources` (a test and the harness files it includes) uses a missing built-in, or +/// one of them does not parse as ES5 (the tests that use `let` and `const`). A name counts as an +/// identifier or a property name, as a string literal that is the name alone (a property key, as +/// in `this["Map"]`), and as a word in any string literal when one of the sources can run a string +/// as code. Comments, and strings that only mention the name in a message, do not count. +fn needs_missing_builtin(sources: &[&str], missing: &HashSet<&str>) -> bool { + let mut lists = Vec::with_capacity(sources.len()); + for source in sources { + let Ok(tokens) = tokenize(source, &Options::default()) else { return true }; + lists.push(tokens); + } + let strings_are_code = lists.iter().any(|tokens| runs_strings_as_code(tokens)); + lists.iter().flatten().any(|t| match &t.kind { TokenKind::Ident(name) => missing.contains(name.as_str()), TokenKind::String(units) => { - String::from_utf16_lossy(units).split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')).any(|word| missing.contains(word)) + let text = String::from_utf16_lossy(units); + missing.contains(text.as_str()) + || (strings_are_code && text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')).any(|word| missing.contains(word))) + } + _ => false, + }) +} + +/// The tokens can run a string as code: they name `eval`, `Function` or a `constructor` property, +/// as an identifier or as a string key (`this["eval"]`), called directly or through an alias. +/// `Function.prototype` alone does not count. +fn runs_strings_as_code(tokens: &[Token]) -> bool { + const RUNNERS: [&str; 3] = ["eval", "Function", "constructor"]; + tokens.iter().enumerate().any(|(i, t)| match &t.kind { + TokenKind::String(units) => RUNNERS.iter().any(|r| r.encode_utf16().eq(units.iter().copied())), + TokenKind::Ident(name) if name == "eval" || name == "constructor" => true, + TokenKind::Ident(name) if name == "Function" => { + let prototype = tokens.get(i + 1).is_some_and(|d| d.is_punct(Punct::Dot)) + && tokens.get(i + 2).is_some_and(|p| matches!(&p.kind, TokenKind::Ident(p) if p == "prototype")); + !prototype } _ => false, }) @@ -428,7 +454,7 @@ fn run_tests(dir: &Path, root: &Path, missing: &HashSet<&str>, later: bool) -> ( } let counts = if meta.es5 { &mut es5_counts } else { &mut later_counts }; let includes: String = meta.includes.iter().map(|i| read(&harness_dir.join(i)) + "\n").collect(); - if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, missing) || needs_missing_builtin(&includes, missing) { + if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&[&source, &includes], missing) { counts.skipped += 1; continue; } @@ -467,3 +493,68 @@ fn run_tests(dir: &Path, root: &Path, missing: &HashSet<&str>, later: bool) -> ( } (es5_counts, later_counts) } + +/// `needs_missing_builtin` with `Map`, `Symbol` and `Set` missing. +fn skips(sources: &[&str]) -> bool { + let missing: HashSet<&str> = ["Map", "Symbol", "Set"].into_iter().collect(); + needs_missing_builtin(sources, &missing) +} + +#[test] +fn a_missing_name_in_a_message_string_does_not_skip() { + assert!(!skips(&[r##"if (x !== 1) { $ERROR("#1: new Map() should not be needed"); }"##])); + assert!(!skips(&[r#"assert.sameValue(f(), 1, 'Set the value: ' + "a Symbol");"#])); + // Quotes escaped inside the string, and a regular expression literal holding quotes. + assert!(!skips(&[r#"var r = /"Map'/; var s = "say \"Map\" and \'Set\'";"#])); + // A string in an included harness file is a message too. + assert!(!skips(&["var x = 1;", r#"function check(v) { if (!v) throw new Test262Error("no Symbol"); }"#])); +} + +#[test] +fn a_missing_name_in_code_skips() { + assert!(skips(&["var m = new Map();"])); + assert!(skips(&["var s = this.Symbol;"])); + assert!(skips(&["", "function helper() { return typeof Set; }"])); + // A name both in a message and in code. + assert!(skips(&[r#"var m = new Map(); assert(m, "Map");"#])); +} + +#[test] +fn a_missing_name_in_a_string_that_can_run_as_code_skips() { + assert!(skips(&[r#"eval("new Map()");"#])); + assert!(skips(&[r#"var e = eval; var s = "Symbol()"; e(s);"#])); + assert!(skips(&[r#"var f = Function("return Set");"#])); + assert!(skips(&[r#"var f = new Function("a", "return new Map(a)");"#])); + assert!(skips(&[r#"var f = Function.apply(null, ["return Symbol"]);"#])); + assert!(skips(&[r#"var f = (function () {}).constructor("return Set");"#])); + assert!(skips(&[r#"var F = Function; F("return new Map()")();"#])); + assert!(skips(&[r#"var C = Function.prototype.constructor; C("return new Map()")();"#])); + assert!(skips(&[r#"this["eval"]("new Map()");"#])); + assert!(skips(&[r#"var F = this['Function']; F("return new Map()")();"#])); + // The string is in the test and the eval in an included file. + assert!(skips(&[r#"run("new Map()");"#, "function run(code) { return eval(code); }"])); +} + +#[test] +fn a_string_that_is_only_a_missing_name_skips() { + // A property key reads the global by name, with or without `eval` in the source. + assert!(skips(&[r#"var M = this["Map"];"#])); + assert!(skips(&[r#"for (var k in this) { if (k === 'Set') { throw 1; } }"#])); + assert!(skips(&[r#"var names = ["Object", "Symbol"];"#])); +} + +#[test] +fn comments_and_longer_names_do_not_skip() { + assert!(!skips(&["// new Map()\n/* Symbol() */ var x = 1;"])); + assert!(!skips(&["var Mapping = 1, Settings = 2; Symbolic = 3;"])); + assert!(!skips(&[r#"eval("var Mapping = 1, Settings = 2;");"#])); + // `Function.prototype` alone does not make strings code. + assert!(!skips(&[r#"assert(Function.prototype, "a Map");"#])); +} + +#[test] +fn a_source_that_does_not_parse_as_es5_skips() { + assert!(skips(&["let x = 1;"])); + assert!(skips(&["var x = 1;", "const y = 2;"])); + assert!(skips(&["var s = `${x}`;"])); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 32735d9a0..00e914ddb 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -129,10 +129,17 @@ impl Headless { fn exec(&mut self, id: &str, params: &Value) -> Result { // A key the command's description doesn't name is an error, not a default (#1442). match HOST_COMMANDS.iter().find(|c| c.0 == id) { - Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params), - None => self.session.check_params(id, params), + Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params).map_err(|e| e.to_string())?, + None if vectorcraft_engine::find_command(id).is_some() => { + self.session.check_params(id, params).map_err(|e| e.to_string())?; + } + None if crate::ui_catalog::UI_COMMANDS.iter().any(|(cid, ..)| *cid == id) => { + return Err(format!( + "`{id}` is a desktop UI command (`ui: true`); start `vectorcraft --control 7979` and use `vectorcraft-cli mcp --connect`" + )); + } + None => self.session.check_params(id, params).map_err(|e| e.to_string())?, } - .map_err(|e| e.to_string())?; match id { "file.open" => self.open(params), "file.save" => self.save(params), @@ -146,9 +153,30 @@ impl Headless { fn commands(&self) -> Value { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + let mut seen: std::collections::HashSet = v.iter().filter_map(|c| c.get("id").and_then(Value::as_str).map(str::to_owned)).collect(); for (id, label, params) in HOST_COMMANDS { + if !seen.insert((*id).to_owned()) { + continue; + } v.push(json!({"id": id, "label": label, "menu": [], "shortcut": null, "params": params, "enabled": true, "host": true})); } + // Desktop menu commands (`UI_COMMANDS`): listed for discovery with `ui: true`. Running one + // that only the desktop app implements still needs `--connect` (see `exec`). + for (id, label, shortcut, params) in crate::ui_catalog::UI_COMMANDS { + if !seen.insert((*id).to_owned()) { + continue; + } + let sc = if shortcut.is_empty() { Value::Null } else { json!(shortcut) }; + v.push(json!({ + "id": id, + "label": label, + "menu": [], + "shortcut": sc, + "params": params, + "enabled": true, + "ui": true, + })); + } Value::Array(v) } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 785746a38..d2f114536 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -22,6 +22,7 @@ mod prompts; mod resources; mod server; mod tools; +mod ui_catalog; pub use backend::{Backend, Remote}; pub use headless::Headless; diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 8ad74c6ba..7741d9228 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -365,6 +365,110 @@ Method: Live effects are kept on export (geometry baked; shadows, glows and blur become SVG filters), and text stays text unless you pass outlineText.", }, + PromptDef { + name: "check-spelling", + title: "Check spelling", + description: "Run text.spelling.check on the selection or document, then apply suggestions or dictionary changes.", + arguments: &[ + PromptArg { + name: "scope", + description: "What to check", + required: false, + default: "the selection, or every story when nothing is selected", + values: Values::Fixed(&[ + "the selection, or every story when nothing is selected", + "the active story only", + "every story in the document", + ]), + }, + PromptArg { + name: "action", + description: "What to do with each unknown word", + required: false, + default: "list suggestions and ask before changing", + values: Values::Fixed(&[ + "list suggestions and ask before changing", + "apply the first suggestion for each word", + "add unknown words to the custom dictionary", + "ignore all for this document", + ]), + }, + ], + text: "\ +Check spelling in VectorCraft for {scope}, then {action}. + +Method: +1. inspect_document to see the selection and type objects. +2. run_command text.spelling.check (and text.spelling.suggest for each unknown word). Offsets are + byte ranges for text.editRange / text.spelling.changeWord. +3. For a fix, text.spelling.changeWord; for the custom dictionary, text.spelling.addWord / + removeWord / dictionary; for this document only, text.spelling.ignoreAll. +4. Prefer those commands over inventing replacements. screenshot if the layout may reflow. + +Desktop-only Auto Spell Check is edit.autoSpellCheck (ui: true); use --connect for the underlines.", + }, + PromptDef { + name: "variables-bind", + title: "Bind variables", + description: "Define text, visibility or image variables, bind them to art, and load or save a variable library.", + arguments: &[ + PromptArg { + name: "kind", + description: "Which variable kinds to set up", + required: false, + default: "text", + values: Values::Fixed(&["text", "visibility", "image", "graph", "mixed"]), + }, + PromptArg { + name: "source", + description: "Where the data comes from", + required: false, + default: "define variables on the selection by hand", + values: Values::Fixed(&["define variables on the selection by hand", "import a CSV or TSV data set", "load a variable library XML"]), + }, + ], + text: "\ +Set up {kind} variables in VectorCraft from {source}. + +Method: +1. inspect_document and variable.list (or the Variables panel commands) to see what exists. +2. variable.define / variable.bind for text, visibility, image or graph bindings; one text + variable per type object. +3. For CSV/TSV, dataset.import; for a library file, variable.loadLibrary / variable.saveLibrary. +4. Switch data sets with dataset.apply and verify with inspect_document / screenshot. +5. Prefer run_command over inventing panel clicks. Undo covers each binding step.", + }, + PromptDef { + name: "path-tools", + title: "Path Tools add-on", + description: "Use addon.pathtools.* commands (and addon.path.* live effects) to edit path geometry.", + arguments: &[PromptArg { + name: "task", + description: "What to do to the selection", + required: true, + default: "", + values: Values::Fixed(&[ + "extend open ends", + "close paths", + "add anchors along curves", + "simplify anchors", + "split at anchors", + "expand dashes to geometry", + "create a parametric shape", + ]), + }], + text: "\ +Perform this Path Tools task in VectorCraft: {task}. + +Method: +1. command_list with filter \"addon.pathtools\" for the command ids (extend, close, addPoints, + simplify, crossings, shape.create, …). Those are commands. +2. Live Path Effects keep the effect ids addon.path.* (dashes, addAnchors, extend, close, + smooth, retract, reverse, simplify, split). Apply them with apply_effect / effect.apply; + they are not command ids. +3. inspect_document the selection, run the command or effect, then screenshot. +4. Expand Appearance (effect.expandAppearance) only when the brief needs baked geometry.", + }, ]; /// The message body as a client should receive it. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index c07aa9efc..d7de8dd7f 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -209,9 +209,77 @@ fn tool_schemas_are_valid() { "export", "undo", "redo", + "command_list", + "command_run", + "command_batch", + "doc_inspect", + "render_preview", + "ui_inspect", + "ui_screenshot", + "type_text", + "answer_dialog", + "add_text", + "apply_effect", + "pathfinder", + "transform", + "create_graph", + "text_wrap", ] { assert!(names.contains(want), "missing tool {want}"); } + assert_eq!(tools.len(), 33, "parity docs and #6460 expect 33 MCP tools"); +} + +/// Headless `command_list` must expose engine commands that a stale release CLI used to omit (#6460). +#[test] +fn headless_command_list_includes_post_022_engine_commands() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION})); + s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + let want = [ + "actionSet.read", + "engine.apiReference", + "script.run", + "script.stop", + "text.spelling.check", + "document.move", + "document.compare", + "edit.newDocumentFromState", + ]; + for (n, id) in want.iter().enumerate() { + let r = call(&mut s, 10 + n as u64, "command_list", json!({"filter": id, "brief": true})); + assert_eq!(r["isError"], false, "{id}: {r}"); + let list: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert!(list.as_array().unwrap().iter().any(|c| c["id"] == *id), "missing {id} in {list}"); + } +} + +/// Headless `command_list` lists desktop UI commands with `ui: true` (#6460). +#[test] +fn headless_command_list_includes_ui_commands() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION})); + s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + let want = [ + "addon.arrange.zoomSelection", + "file.newDialog", + "view.fitArtboard", + "view.rotateReset", + "app.quit", + "window.arrange.tile", + "align.horizontalLeft", + "ui.action.play", + ]; + for (n, id) in want.iter().enumerate() { + let r = call(&mut s, 20 + n as u64, "command_list", json!({"filter": id, "brief": true})); + assert_eq!(r["isError"], false, "{id}: {r}"); + let list: Value = serde_json::from_str(&text_of(&r)).unwrap(); + let entry = list.as_array().unwrap().iter().find(|c| c["id"] == *id).unwrap_or_else(|| panic!("missing {id} in {list}")); + assert_eq!(entry["ui"], true, "{entry}"); + } + let r = call(&mut s, 40, "command_run", json!({"id": "view.fitArtboard", "params": {}})); + assert_eq!(r["isError"], true, "{r}"); + assert!(text_of(&r).contains("--connect"), "{r}"); } // ---------- headless end-to-end ---------- @@ -723,6 +791,13 @@ fn fake_app() -> (String, std::thread::JoinHandle>) { /// [`fake_app`], answering `ui.dialog.confirm` with the error `refusal` when one is given. fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thread::JoinHandle>) { + fake_app_with(refusal, true) +} + +/// [`fake_app_refusing_confirm`]. With `answers_in_set`, `ui.dialog.set {action}` also confirms or +/// cancels the dialog and replies `{dialog, result}`, as the app does; without it the set ignores +/// `action`, as an app that predates it does. +fn fake_app_with(refusal: Option<&'static str>, answers_in_set: bool) -> (String, std::thread::JoinHandle>) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let h = std::thread::spawn(move || { @@ -738,6 +813,17 @@ fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thr "engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}), "ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}), "ui.dialog.set" if !dialog_open => json!({"ok": false, "error": "no dialog open"}), + "ui.dialog.set" if answers_in_set && msg["params"].get("action").is_some() => { + let dialog = json!({"echo": {"fields": msg["params"]["fields"]}}); + match msg["params"]["action"].as_str() { + Some("confirm") if refusal.is_some() => json!({"ok": false, "error": refusal}), + Some("confirm") => json!({"ok": true, "result": {"dialog": dialog, "result": {"echo": {}}}}), + _ => { + dialog_open = false; + json!({"ok": true, "result": {"dialog": dialog, "result": null}}) + } + } + } "ui.dialog.confirm" if refusal.is_some() => json!({"ok": false, "error": refusal}), m if m.starts_with("ui.dialog.") => { dialog_open &= m != "ui.dialog.cancel"; @@ -775,8 +861,10 @@ fn remote_forwards_methods() { assert_eq!(seen[1]["params"], json!({"command": "object.group", "params": {}})); } +/// Fields and `action` go to the app in one `ui.dialog.set`, so the dialog the fields are set in is +/// the one confirmed or canceled. #[test] -fn answer_dialog_sets_fields_in_order_then_confirms() { +fn answer_dialog_sets_fields_in_order_and_confirms_in_one_request() { let (addr, h) = fake_app(); let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); let set = json!([{"field": "discard", "value": true}, {"field": "note"}]); @@ -785,7 +873,11 @@ fn answer_dialog_sets_fields_in_order_then_confirms() { let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); let fields = json!([{"field": "discard", "value": true}, {"field": "note", "value": null}]); assert_eq!(reply, json!({"dialog": {"echo": {"fields": fields}}, "result": {"echo": {}}})); - let r = call(&mut s, 2, "answer_dialog", json!({"action": "cancel"})); + let r = call(&mut s, 2, "answer_dialog", json!({"set": set})); + assert_eq!(r["isError"], false, "{r}"); + let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(reply, json!({"dialog": {"echo": {"fields": fields}}, "result": null})); + let r = call(&mut s, 3, "answer_dialog", json!({"action": "cancel"})); assert_eq!(r["isError"], false, "{r}"); // Bad arguments are refused before anything reaches the app. for (i, args) in [ @@ -807,8 +899,26 @@ fn answer_dialog_sets_fields_in_order_then_confirms() { drop(s); let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); - assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm", "ui.dialog.cancel"]); - assert_eq!(seen[0]["params"], json!({"fields": fields})); + assert_eq!(methods, ["ui.dialog.set", "ui.dialog.set", "ui.dialog.cancel"]); + assert_eq!(seen[0]["params"], json!({"fields": fields, "action": "confirm"})); + assert_eq!(seen[1]["params"], json!({"fields": fields})); +} + +/// An app whose `ui.dialog.set` predates `action` replies with the dialog alone; the dialog is +/// then confirmed with its own `ui.dialog.confirm`. +#[test] +fn answer_dialog_confirms_separately_when_the_app_set_did_not_answer() { + let (addr, h) = fake_app_with(None, false); + let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let set = json!([{"field": "copy", "value": true}]); + let r = call(&mut s, 1, "answer_dialog", json!({"set": set, "action": "confirm"})); + assert_eq!(r["isError"], false, "{r}"); + let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(reply, json!({"dialog": {"echo": {"fields": set, "action": "confirm"}}, "result": {"echo": {}}})); + drop(s); + let seen = h.join().unwrap(); + let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); + assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]); } /// When the app refuses the `ui.dialog.set`, the call is an error and nothing is confirmed. @@ -826,7 +936,7 @@ fn answer_dialog_does_not_confirm_when_the_app_refuses_the_set() { let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); assert_eq!(methods, ["ui.dialog.cancel", "ui.dialog.set"], "one set call, refused, and no confirm"); - assert_eq!(seen[1]["params"], json!({"fields": set})); + assert_eq!(seen[1]["params"], json!({"fields": set, "action": "confirm"})); } /// The app refuses to confirm a plug-in's install approval for automation: the call is an error @@ -843,7 +953,7 @@ fn answer_dialog_reports_that_the_app_refuses_to_approve_a_plugin_install() { drop(s); let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); - assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]); + assert_eq!(methods, ["ui.dialog.set"]); } #[test] @@ -1463,6 +1573,60 @@ fn pointer_gesture_forwards_backquote_on_each_event() { assert_eq!(rect["bounds"]["x"], 100.0, "{doc}"); } +/// The documented Rotate View calls reach the desktop control methods, preserving document +/// coordinates and modifiers, and a successful null reset remains a successful MCP response. +#[test] +fn rotate_view_calls_forward_to_desktop_control() { + use std::cell::RefCell; + use std::rc::Rc; + + struct Recording(Rc>>); + impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.0.borrow_mut().push((method.into(), params)); + Ok(Value::Null) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "desktop control contract".into() + } + } + + let seen = Rc::new(RefCell::new(Vec::new())); + let mut s = Server::new(Box::new(Recording(seen.clone()))); + let selected = call(&mut s, 1, "select_tool", json!({"tool": "rotateView"})); + let turned = call( + &mut s, + 2, + "pointer_gesture", + json!({"events": [ + {"kind": "down", "x": 300, "y": 150}, {"kind": "drag", "x": 200, "y": 250}, {"kind": "up", "x": 200, "y": 250} + ], "mods": {"shift": true}}), + ); + let reset = call(&mut s, 3, "command_run", json!({"id": "view.rotateReset", "params": {}})); + for reply in [&selected, &turned, &reset] { + assert_eq!(reply["isError"], false, "{reply}"); + } + assert_eq!(text_of(&reset), "null"); + assert_eq!( + *seen.borrow(), + vec![ + ("ui.tool.select".into(), json!({"tool": "rotateView"})), + ( + "ui.pointer".into(), + json!({"events": [ + {"kind": "down", "x": 300, "y": 150, "space": "doc"}, + {"kind": "drag", "x": 200, "y": 250, "space": "doc"}, + {"kind": "up", "x": 200, "y": 250, "space": "doc"} + ], "mods": {"shift": true}}) + ), + ("engine.execute".into(), json!({"command": "view.rotateReset", "params": {}})) + ] + ); +} + /// A `backquote` that is not a boolean is an error, and none of the gesture's events runs. #[test] fn pointer_gesture_rejects_a_backquote_that_is_not_a_boolean() { diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 8fe45b83e..17c7ecc91 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec { tool( "command_list", "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", + "Discover command ids, labels, parameter descriptions and enabled state. Lists engine and plug-in commands, and desktop UI menu commands (ui: true; running a UI-only id headless needs --connect). A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}. Effect ids (effect.list) are not command ids.", obj( json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), &[], @@ -156,7 +156,7 @@ pub fn tool_definitions() -> Vec { tool( "list_commands", "List commands", - "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", + "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Includes engine commands and desktop UI menu commands (ui: true; a UI-only id run headless needs --connect). Every editing action is a command; run any of them with run_command (or invoke_menu for a menu path). Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only. Effect ids (effect.list) are not command ids.", obj( json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), &[], @@ -259,7 +259,7 @@ pub fn tool_definitions() -> Vec { "radius1": num("Star outer radius"), "radius2": num("Star inner radius"), "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, - "rotation": num("Rotation in degrees (polygon/star)"), + "rotation": num("Rotation in degrees clockwise (polygon/star; 0: a corner or point straight up)"), "x1": num("Line start x"), "y1": num("Line start y"), "x2": num("Line end x"), @@ -307,7 +307,7 @@ pub fn tool_definitions() -> Vec { tool( "answer_dialog", "Answer dialog", - "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none. When the app refuses the ui.dialog.set, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", + "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none and answers that same dialog in the same request (ui.dialog.set with action). When the app refuses the ui.dialog.set, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", obj( json!({ "set": { @@ -316,7 +316,7 @@ pub fn tool_definitions() -> Vec { "items": obj(json!({"field": string("Dialog field"), "value": {"description": "The field's new value (any JSON; default null)"}}), &["field"]), "description": "Fields to set, in order (one ui.dialog.set that sets all of them or none)", }, - "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog"}, + "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog the fields were set in"}, }), &[], ), @@ -471,12 +471,12 @@ pub fn tool_definitions() -> Vec { "ids": {"type": "array", "items": {"type": "integer"}}, "dx": num("Move right"), "dy": num("Move down"), - "rotate": num("Rotation in degrees"), + "rotate": num("Rotation in degrees counter-clockwise"), "scale": num("Uniform scale in %"), "scaleX": num("Horizontal scale in %"), "scaleY": num("Vertical scale in %"), "reflect": {"type": "string", "enum": ["vertical", "horizontal"]}, - "shear": num("Shear angle in degrees (horizontal axis)"), + "shear": num("Shear angle in degrees (horizontal axis: positive moves the top to the right, turning the vertical edges clockwise)"), "origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "copy": {"type": "boolean"}, }), diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index ea2c6ec70..e76b5704a 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -334,9 +334,10 @@ pub(super) fn transform(b: &mut dyn Backend, a: &Args) -> Result /// Most fields one `answer_dialog` call may set. pub(super) const MAX_DIALOG_FIELDS: usize = 256; -/// Sets the open dialog's fields in order with one `ui.dialog.set {fields}`, which stores all of -/// them or none, then confirms or cancels it. Every argument is checked before anything is sent to -/// the app. +/// Sets the open dialog's fields in order with one `ui.dialog.set {fields, action?}`, which stores +/// all of them or none and then confirms or cancels that same dialog, so a dialog another +/// connection opens in between is not answered in its place. Every argument is checked before +/// anything is sent to the app. pub(super) fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result { let set = match a.get("set") { None => &[][..], @@ -355,21 +356,37 @@ pub(super) fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result None, - Some(v) if v == "confirm" => Some("ui.dialog.confirm"), - Some(v) if v == "cancel" => Some("ui.dialog.cancel"), + let (action, then) = match a.get("action") { + None => (None, None), + Some(v) if v == "confirm" => (Some("confirm"), Some("ui.dialog.confirm")), + Some(v) if v == "cancel" => (Some("cancel"), Some("ui.dialog.cancel")), Some(_) => return Err("`action` must be confirm or cancel".into()), }; if fields.is_empty() && then.is_none() { return Err("give `set` and/or `action`".into()); } - let dialog = if fields.is_empty() { Value::Null } else { b.call("ui.dialog.set", json!({"fields": fields}))? }; - let result = match then { - Some(method) => b.call(method, json!({}))?, - None => Value::Null, - }; - Ok(json!({"dialog": dialog, "result": result})) + if fields.is_empty() { + let result = match then { + Some(method) => b.call(method, json!({}))?, + None => Value::Null, + }; + return Ok(json!({"dialog": Value::Null, "result": result})); + } + let mut params = json!({"fields": fields}); + if let Some(action) = action { + params["action"] = json!(action); + } + let reply = b.call("ui.dialog.set", params)?; + match then { + None => Ok(json!({"dialog": reply, "result": Value::Null})), + // The app answered the dialog in the same request. + Some(_) if reply.get("dialog").is_some() => Ok(reply), + // An app that predates `action` replied with the dialog alone and did not answer it. + Some(method) => { + let result = b.call(method, json!({}))?; + Ok(json!({"dialog": reply, "result": result})) + } + } } pub(super) fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs new file mode 100644 index 000000000..e15244580 --- /dev/null +++ b/crates/mcp/src/ui_catalog.rs @@ -0,0 +1,1672 @@ +//! Desktop UI command catalogue for headless MCP discovery (`ui: true`). +//! Generated from `crates/ui-egui/src/menus/ui_commands.rs` by `cargo xtask ui-commands`. +//! Do not edit by hand; re-run the xtask when that table changes. + +/// `(id, label, shortcut, params doc)` — same shape as the desktop `UI_COMMANDS` table. +pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ("addon.pathtools.cornerTool", "Corner Tool", "", "{} choose the Corner Tool and show the Anchor Inspector panel with its radius and kind"), + ( + "addon.pathtools.sharedEdgeTool", + "Shared Edge Merge Tool", + "", + "{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together", + ), + ( + "addon.arrange.zoomSelection", + "Zoom to Selection", + "", + "{} zoom and centre the active view so the selection's visual bounds fill the window, with a margin (as view.fitArtboard fits the artboard); off with nothing selected", + ), + ( + "app.language", + "Interface Language", + "", + "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)", + ), + ( + "file.open", + "Open…", + "Cmd+O", + "{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file", + ), + ( + "file.openWith", + "Open With", + "", + "{importer: \"plugin.\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → with a path, document.open's result; a picked file is read off the UI thread on the desktop, {background: true, status}, and opens once the importer returns (file.cancelOpen stops it) | null while the picker is open", + ), + ( + "file.save", + "Save", + "Cmd+S", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + ), + ( + "file.place", + "Place…", + "Cmd+Shift+P", + "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)", + ), + ("file.openRecent1", "Open Recent File 1", "", "{}"), + ("file.openRecent2", "Open Recent File 2", "", "{}"), + ("file.openRecent3", "Open Recent File 3", "", "{}"), + ("file.openRecent4", "Open Recent File 4", "", "{}"), + ("file.openRecent5", "Open Recent File 5", "", "{}"), + ("file.openRecent6", "Open Recent File 6", "", "{}"), + ("file.openRecent7", "Open Recent File 7", "", "{}"), + ("file.openRecent8", "Open Recent File 8", "", "{}"), + ("file.openRecent9", "Open Recent File 9", "", "{}"), + ("file.openRecent10", "Open Recent File 10", "", "{}"), + ("view.goto1", "Saved View 1", "", "{} go to the 1. saved view"), + ("view.goto2", "Saved View 2", "", "{} go to the 2. saved view"), + ("view.goto3", "Saved View 3", "", "{} go to the 3. saved view"), + ("view.goto4", "Saved View 4", "", "{} go to the 4. saved view"), + ("view.goto5", "Saved View 5", "", "{} go to the 5. saved view"), + ("view.goto6", "Saved View 6", "", "{} go to the 6. saved view"), + ("view.goto7", "Saved View 7", "", "{} go to the 7. saved view"), + ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), + ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), + ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), + ("view.goto11", "Saved View 11", "", "{} go to the 11. saved view"), + ("view.goto12", "Saved View 12", "", "{} go to the 12. saved view"), + ("view.goto13", "Saved View 13", "", "{} go to the 13. saved view"), + ("view.goto14", "Saved View 14", "", "{} go to the 14. saved view"), + ("view.goto15", "Saved View 15", "", "{} go to the 15. saved view"), + ("view.goto16", "Saved View 16", "", "{} go to the 16. saved view"), + ("view.goto17", "Saved View 17", "", "{} go to the 17. saved view"), + ("view.goto18", "Saved View 18", "", "{} go to the 18. saved view"), + ("view.goto19", "Saved View 19", "", "{} go to the 19. saved view"), + ("view.goto20", "Saved View 20", "", "{} go to the 20. saved view"), + ("view.goto21", "Saved View 21", "", "{} go to the 21. saved view"), + ("view.goto22", "Saved View 22", "", "{} go to the 22. saved view"), + ("view.goto23", "Saved View 23", "", "{} go to the 23. saved view"), + ("view.goto24", "Saved View 24", "", "{} go to the 24. saved view"), + ("view.goto25", "Saved View 25", "", "{} go to the 25. saved view"), + ("select.recall1", "Saved Selection 1", "", "{} select the 1. saved selection"), + ("select.recall2", "Saved Selection 2", "", "{} select the 2. saved selection"), + ("select.recall3", "Saved Selection 3", "", "{} select the 3. saved selection"), + ("select.recall4", "Saved Selection 4", "", "{} select the 4. saved selection"), + ("select.recall5", "Saved Selection 5", "", "{} select the 5. saved selection"), + ("select.recall6", "Saved Selection 6", "", "{} select the 6. saved selection"), + ("select.recall7", "Saved Selection 7", "", "{} select the 7. saved selection"), + ("select.recall8", "Saved Selection 8", "", "{} select the 8. saved selection"), + ("select.recall9", "Saved Selection 9", "", "{} select the 9. saved selection"), + ("select.recall10", "Saved Selection 10", "", "{} select the 10. saved selection"), + ("select.recall11", "Saved Selection 11", "", "{} select the 11. saved selection"), + ("select.recall12", "Saved Selection 12", "", "{} select the 12. saved selection"), + ("select.recall13", "Saved Selection 13", "", "{} select the 13. saved selection"), + ("select.recall14", "Saved Selection 14", "", "{} select the 14. saved selection"), + ("select.recall15", "Saved Selection 15", "", "{} select the 15. saved selection"), + ("select.recall16", "Saved Selection 16", "", "{} select the 16. saved selection"), + ("select.recall17", "Saved Selection 17", "", "{} select the 17. saved selection"), + ("select.recall18", "Saved Selection 18", "", "{} select the 18. saved selection"), + ("select.recall19", "Saved Selection 19", "", "{} select the 19. saved selection"), + ("select.recall20", "Saved Selection 20", "", "{} select the 20. saved selection"), + ("select.recall21", "Saved Selection 21", "", "{} select the 21. saved selection"), + ("select.recall22", "Saved Selection 22", "", "{} select the 22. saved selection"), + ("select.recall23", "Saved Selection 23", "", "{} select the 23. saved selection"), + ("select.recall24", "Saved Selection 24", "", "{} select the 24. saved selection"), + ("select.recall25", "Saved Selection 25", "", "{} select the 25. saved selection"), + ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), + ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), + ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), + ("type.recentFont4", "Recent Font 4", "", "{} apply the 4. most recently used font"), + ("type.recentFont5", "Recent Font 5", "", "{} apply the 5. most recently used font"), + ("type.recentFont6", "Recent Font 6", "", "{} apply the 6. most recently used font"), + ("type.recentFont7", "Recent Font 7", "", "{} apply the 7. most recently used font"), + ("type.recentFont8", "Recent Font 8", "", "{} apply the 8. most recently used font"), + ("type.recentFont9", "Recent Font 9", "", "{} apply the 9. most recently used font"), + ("type.recentFont10", "Recent Font 10", "", "{} apply the 10. most recently used font"), + ("type.recentFont11", "Recent Font 11", "", "{} apply the 11. most recently used font"), + ("type.recentFont12", "Recent Font 12", "", "{} apply the 12. most recently used font"), + ("type.recentFont13", "Recent Font 13", "", "{} apply the 13. most recently used font"), + ("type.recentFont14", "Recent Font 14", "", "{} apply the 14. most recently used font"), + ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), + ("file.clearRecent", "Clear Recent Files", "", "{}"), + ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), + ( + "ui.missingFontsDialog", + "Missing Fonts Dialog", + "", + "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web", + ), + ( + "ui.findFontsInFolder", + "Find Fonts in Folder…", + "", + "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", + ), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first; under an automation read root, only the files inside it"), + ( + "file.export.svg", + "Export As SVG…", + "", + "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", + ), + ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), + ( + "file.exportForScreens", + "Export for Screens…", + "Cmd+Alt+E", + "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", + ), + ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), + ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ( + "file.newWithoutDialog", + "New Without Dialog", + "Cmd+Alt+N", + "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}", + ), + ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), + ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), + ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), + ( + "edit.autoSpellCheck", + "Auto Spell Check", + "", + "{} toggle a red underline under the words of the document's type its dictionaries don't know (text.spelling.check lists them), kept up to date as the type changes; type with effects isn't underlined → whether Auto Spell Check is on", + ), + ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), + ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), + ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), + ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), + ( + "shortcuts.preset", + "Keyboard Shortcut Set", + "", + "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", + ), + ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), + ( + "shortcuts.exportText", + "Export Keyboard Shortcuts as Text…", + "", + "{path?} save the current set's tools and menu commands with their shortcuts as a UTF-8 text file (Keyboard Shortcuts › Export Text…): the set's name, then a Tools and a Menu Commands section in the dialog's order, one `nameshortcut` line per row, empty after the tab when it has none; without `path`, pick a file", + ), + ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), + ( + "ui.shortcutImportDialog", + "Import Shortcut Set…", + "", + "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too", + ), + ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), + ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), + ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), + ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), + ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ( + "view.snapToGlyph", + "Snap to Glyph", + "", + "{} toggle: drawing snaps to live-text glyph bounds, and on a single line of type to its baseline, x-height, character widths and glyph anchor points", + ), + ( + "view.preciseCursors", + "Use Precise Cursors", + "", + "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on", + ), + ( + "imageTrace.preview", + "Image Trace Preview", + "", + "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state", + ), + ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), + ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), + ( + "view.livePaintGaps", + "Show Live Paint Gaps", + "", + "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on", + ), + ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), + ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), + ( + "type.alignCenter", + "Align center", + "Cmd+Shift+C", + "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred", + ), + ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), + ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), + ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), + ( + "type.applyBullets", + "Apply Bullets", + "", + "{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off", + ), + ( + "type.applyNumbers", + "Apply Numbers", + "", + "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", + ), + ( + "ui.bulletsAndNumbering", + "Bullets and Numbering…", + "", + "{tab?: \"bullets\"|\"numbers\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab", + ), + ( + "type.convertListToText", + "Convert to Text", + "", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + ), + ( + "align.horizontalLeft", + "Horizontal Align Left", + "", + "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalCenter", + "Horizontal Align Center", + "", + "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalRight", + "Horizontal Align Right", + "", + "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalVerticalCenter", + "Horizontal & Vertical Align Center", + "", + "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalTop", + "Vertical Align Top", + "", + "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalCenter", + "Vertical Align Center", + "", + "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalBottom", + "Vertical Align Bottom", + "", + "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.distributeVerticalTop", + "Vertical Distribute Top", + "", + "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalCenter", + "Vertical Distribute Center", + "", + "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalBottom", + "Vertical Distribute Bottom", + "", + "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalLeft", + "Horizontal Distribute Left", + "", + "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalCenter", + "Horizontal Distribute Center", + "", + "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalRight", + "Horizontal Distribute Right", + "", + "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalSpace", + "Vertical Distribute Space", + "", + "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalSpace", + "Horizontal Distribute Space", + "", + "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "type.bold", + "Bold", + "", + "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B", + ), + ( + "type.italic", + "Italic", + "", + "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I", + ), + ( + "type.highlightFontName", + "Highlight Font Name", + "Cmd+Alt+Shift+F", + "{} show the Character panel if it is hidden and open its font family menu, whose search field takes the keyboard focus: type to search, Enter applies the highlighted font → {field: \"fontName\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first", + ), + ( + "type.highlightKerning", + "Highlight Kerning", + "Cmd+Alt+K", + "{} show the Character panel if it is hidden and give its Kerning field the keyboard focus with its value selected: type a value and press Enter → {field: \"kerning\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first", + ), + ( + "type.highlightTracking", + "Highlight Tracking", + "", + "{} show the Character panel if it is hidden and give its Tracking field the keyboard focus with its value selected: type a value and press Enter → {field: \"tracking\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first", + ), + ( + "type.underline", + "Underline", + "Cmd+Shift+U", + "{} the Character panel's Underline button: underline the selected type (or the Type tool's selection), or remove the underline when the text the Character panel shows is underlined (text.setFormat {underline}) → underlined", + ), + ( + "type.lineComposer", + "Toggle Line Composer", + "Cmd+Alt+Shift+C", + "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer (text.setFormat {composer}) → \"singleLine\" | \"everyLine\"", + ), + ( + "type.hyphenate", + "Hyphenate", + "Cmd+Alt+Shift+H", + "{} the Paragraph panel's Hyphenate checkbox: hyphenation off for the selected type's paragraphs (or those the Type tool's selection touches) when they all hyphenate, else on (text.setFormat {hyphenate}) → hyphenate", + ), + ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), + ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), + ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), + ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), + ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), + ( + "view.actualSize", + "Actual Size", + "Cmd+1", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + ), + ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), + ( + "view.goToArtboard", + "Go to Artboard", + "", + "{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}", + ), + ( + "view.firstArtboard", + "First Artboard", + "", + "{} the status bar navigator's First Artboard button: view.goToArtboard {index: \"first\"} → {index}", + ), + ( + "view.previousArtboard", + "Previous Artboard", + "", + "{} the navigator's Previous Artboard button: view.goToArtboard {index: \"previous\"} → {index}", + ), + ("view.nextArtboard", "Next Artboard", "", "{} the navigator's Next Artboard button: view.goToArtboard {index: \"next\"} → {index}"), + ("view.lastArtboard", "Last Artboard", "", "{} the navigator's Last Artboard button: view.goToArtboard {index: \"last\"} → {index}"), + ("view.edges", "Hide Edges", "Cmd+H", "{}"), + ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), + ( + "view.template", + "Hide Template", + "Cmd+Shift+W", + "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown", + ), + ("view.rulers", "Show Rulers", "Cmd+R", "{}"), + ( + "view.videoRulers", + "Show Video Rulers", + "", + "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show", + ), + ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), + ("view.guides", "Hide Guides", "Cmd+;", "{}"), + ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), + ("view.grid", "Show Grid", "Cmd+'", "{}"), + ("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"), + ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), + ("view.presentation", "Presentation Mode", "Shift+F", "{}"), + ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), + ( + "view.rotate", + "Rotate View", + "", + "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]", + ), + ( + "view.rotateToSelection", + "Rotate View to Selection", + "", + "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}", + ), + ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), + ("window.control", "Control", "", "{}"), + ("window.helpBar", "Help Bar", "", "{} toggle the tool-help strip independently of the status bar; returns the new visibility"), + ( + "window.control.dock", + "Dock Control Bar", + "", + "{edge: \"top\"|\"bottom\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}", + ), + ("window.toolbar", "Tools", "", "{}"), + ( + "window.toolbarColumns", + "Toolbar: Single/Double Column", + "", + "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", + ), + ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ( + "window.toolsBesideCanvas", + "Keep Tools Beside the Canvas", + "", + "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted or null; any other value is an error); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state", + ), + ( + "window.floatTools", + "Float Tool Group", + "", + "{tool: id, floating?: bool, toolbar?: name} float the toolbar group holding `tool` (in the current Basic or Advanced layout; with `toolbar`, in that toolbar made with New Toolbar) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state", + ), + ( + "toolbar.show", + "Toolbar: Show Controls", + "", + "{control?: \"fillStroke\"|\"coloring\"|\"drawMode\"|\"screenMode\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state", + ), + ( + "window.editToolbar", + "Edit Toolbar", + "", + "{open?: bool, toolbar?: name} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar (with `toolbar`, beside that toolbar made with New Toolbar, which shows), as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state", + ), + ( + "toolbar.addTool", + "Toolbar: Add Tool", + "", + "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\", toolbar?: name} put `tool` in the current (Basic or Advanced) toolbar (with `toolbar`, in that toolbar made with New Toolbar), moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)", + ), + ( + "toolbar.removeTool", + "Toolbar: Remove Tool", + "", + "{tool: id, group?: bool, toolbar?: name} take `tool` (with `group`, its whole group) out of the current toolbar (with `toolbar`, out of that toolbar made with New Toolbar; one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots", + ), + ( + "toolbar.reset", + "Toolbar: Reset", + "", + "{toolbar?: name} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes (with `toolbar`, make that toolbar made with New Toolbar blank again); returns its slots", + ), + ( + "window.toolbar.new", + "New Toolbar…", + "", + "{name?: string} make a blank toolbar called `name`, of at most 200 characters (omitted: a dialog asks for the name), floating beside the Tools panel and listed under Window › Toolbars; its ••• button opens the All Tools drawer to fill it (window.editToolbar with `toolbar`); it is kept with the current workspace; returns {toolbar: name}", + ), + ( + "window.toolbar.custom", + "Show Toolbar", + "", + "{name: string, show?: bool} show (true), hide (false) or toggle (omitted) the toolbar made with New Toolbar called `name`, as its item under Window › Toolbars does (its title bar's × hides it); shown after Tab hid the toolbars, it shows alone and the others are turned off; returns the new state", + ), + ( + "window.toolbar.manage", + "Manage Toolbars…", + "", + "{} open the Manage Toolbars dialog, which lists the toolbars made with New Toolbar: a name typed for the selected one renames it, + copies it (none selected: makes a blank toolbar) and the trash can deletes it", + ), + ( + "window.toolbar.rename", + "Rename Toolbar", + "", + "{name: string, to: string} give the toolbar made with New Toolbar called `name` the name `to` (not empty, at most 200 characters, not another toolbar's whatever the case), as typing a name in Manage Toolbars does; returns {toolbar: name}", + ), + ( + "window.toolbar.duplicate", + "Duplicate Toolbar", + "", + "{name?: string} copy the toolbar made with New Toolbar called `name`, with its tools, as \"`name` Copy\" (omitted: make a blank toolbar called \"Toolbar N\"), as Manage Toolbars' + does; the copy shows and is kept with the current workspace; returns {toolbar: name}", + ), + ( + "window.toolbar.delete", + "Delete Toolbar", + "", + "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", + ), + ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ("window.taskBar", "Contextual Task Bar", "", "{}"), + ( + "window.pathfinder.compoundShapes", + "Shape Mode Buttons Make Compound Shapes", + "", + "{} toggles the Compound Shapes add-on's setting: on, the Pathfinder panel's Shape Mode buttons make a compound shape and Alt-click gives the permanent Pathfinder result; off (the default), the other way round. Members of a compound shape selected always take the mode", + ), + ( + "window.taskBar.pin", + "Pin Bar Position", + "", + "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state", + ), + ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), + ( + "window.dock", + "Panels", + "Tab", + "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them", + ), + ( + "window.panels", + "Panels Except Tools and Control", + "Shift+Tab", + "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", + ), + ( + "window.panel", + "Show Panel", + "", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + ), + ( + "window.panel.layout", + "Change Panel Layout", + "", + "{action} serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + "", + "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before", + ), + ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), + ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), + ( + "window.panel.collapse", + "Collapse Panel Group", + "", + "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}", + ), + ( + "window.panel.float", + "Float Panel", + "", + "{panel: id or \"tools\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \"tools\" floats the Tools panel at x, y. Returns {layout, closed, floating}", + ), + ( + "window.panel.dock", + "Dock Panel", + "", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on; with side: right (or End) on the right, between the canvas and the panels, as dropping it there or on the window's right edge does (default: the side it docked at last; left or Start docks it on the left), returning {panel, floating, pos, side}. Returns {layout, closed, floating}", + ), + ( + "window.collapseDock", + "Collapse Panels to Icons", + "", + "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", + ), + ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), + ( + "window.workspace", + "Workspace", + "", + "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", + ), + ( + "window.workspace.reset", + "Reset Essentials", + "", + "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", + ), + ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), + ("window.workspace.manage", "Manage Workspaces…", "", "{}"), + ("window.workspace.delete", "Delete Workspace", "", "{name}"), + ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), + ("window.workspace.list", "List Workspaces", "", "{}"), + ( + "window.newWindow", + "New Window", + "", + "{} open another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own (titled with the view's number, \"Name:2\") or, when the current view floats, in a floating window; it becomes the current window; the Arrange commands treat each view as a window; file.close closes the current view and the last view closes the document; returns {index, view}", + ), + ( + "window.arrange.cascade", + "Cascade", + "", + "{} put every open document in a floating window of its own and stack the windows from the upper left of the document area to its lower right, 30 pt apart, in tab order, each fitted to its window, the current document's in front; on the desktop each is an OS window, on the web and Wayland a window inside the app's window; returns {floating} (document indices)", + ), + ( + "window.arrange.tile", + "Tile", + "", + "{} show every open document and each further view of one (window.newWindow) at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of panes, rounded up), each fitted to its pane; the current window's pane takes the tools, a click in another pane makes its window the current one; the tabs stay; windows floating come back under the tabs first; with fewer than two windows open the view stays as it is; returns {tiled, panes}", + ), + ( + "window.arrange.floatInWindow", + "Float in Window", + "", + "{index?, view?, at?: [x, y]} move the current window, or document `index`'s view `view` (its current view without one; it becomes the current window), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)", + ), + ( + "window.arrange.floatAllInWindows", + "Float All in Windows", + "", + "{} move every document under the tab strip into a floating window of its own, at the next places of a cascade; returns {floating} (document indices)", + ), + ( + "window.arrange.consolidateAllWindows", + "Consolidate All Windows", + "", + "{index?, view?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` (its view `view`, else its current view) goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)", + ), + ( + "window.nextDocument", + "Next Document", + "Cmd+F6", + "{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", + ), + ( + "window.previousDocument", + "Previous Document", + "Cmd+Shift+F6", + "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", + ), + ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ( + "tool.setOption", + "Tool Option", + "", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", + ), + ( + "effect.dialog", + "Effect…", + "", + "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item), aligned?: bool (index and item are rows of the first selected object's stack, which OK edits in each object where it has them, as effect.setParams does)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", + ), + ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), + ( + "ui.recolorDialog", + "Recolor Artwork…", + "", + "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", + ), + ( + "ui.cropImage", + "Crop Image", + "", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + ), + ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), + ( + "pathfinder.repeat", + "Repeat Pathfinder", + "Cmd+4", + "{} repeat the last successful Pathfinder panel operation, direct object.pathfinder.* command or command.batch step on the selection with current Pathfinder Options; an error until one has run", + ), + ( + "file.export.pdf", + "Save as PDF…", + "", + "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}", + ), + ("help.about", "About VectorCraft", "", "{}"), + ("help.checkForUpdates", "Check for Updates…", "", "{}"), + ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), + ( + "help.safeMode", + "Safe Mode…", + "", + "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", + ), + ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), + ( + "ui.swatchOptions", + "Swatch Options…", + "", + "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern", + ), + ( + "ui.newSwatch", + "New Swatch…", + "", + "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone", + ), + ( + "ui.newColorGroup", + "New Color Group…", + "", + "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)", + ), + ( + "ui.colorPicker", + "Color Picker…", + "", + "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); RGB, HSB and hex values use the working RGB space; OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", + ), + ( + "ui.sampleScreenColor", + "Sample Color from Screen", + "", + "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", + ), + ( + "ui.newBrush", + "New Brush…", + "", + "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush", + ), + ( + "ui.brushOptions", + "Brush Options…", + "", + "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options", + ), + ( + "ui.graphicStyleOptions", + "Graphic Style Options…", + "", + "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", + ), + ( + "ui.charStyleOptions", + "Character Style Options", + "", + "{name} open Character Style Options (dialog `charStyleOptions`); OK runs charStyle.edit with name, to and sparse attrs in one undo step; Cancel restores a live preview", + ), + ( + "ui.charStyleOptions.resetPanel", + "Reset Panel", + "", + "{} clear the settings of the section shown, Basic Character Formats, Advanced Character Formats, Character Color or OpenType Features, in the open Character Style Options or New Character Style dialog; available only on those sections", + ), + ( + "ui.newCharStyle", + "New Character Style…", + "", + "{} open New Character Style (dialog `newTextStyle`, field `name`, filled in with the name charStyle.new {} would give the style), what Alt-clicking the Character Styles panel's New button opens; OK runs charStyle.new with the name and the Type tool's selected characters (else the selected type) as they were when the dialog opened; Cancel adds no style", + ), + ( + "ui.newParaStyle", + "New Paragraph Style…", + "", + "{} open New Paragraph Style (dialog `newTextStyle`, field `name`, filled in with the name paraStyle.new {} would give the style), what Alt-clicking the Paragraph Styles panel's New button opens; OK runs paraStyle.new with the name and the paragraph of the Type tool's selection (else the selected type) as it was when the dialog opened; Cancel adds no style", + ), + ( + "ui.symbolOptions", + "Symbol Options…", + "F8", + "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \"New Symbol\"/\"New Symbol N\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}", + ), + ( + "tool.options", + "Tool Options…", + "", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + ), + ( + "ui.colorGuideOptions", + "Color Guide Options…", + "", + "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", + ), + ( + "ui.colorBalanceDialog", + "Adjust Color Balance…", + "", + "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", + ), + ( + "ui.saturateDialog", + "Saturate…", + "", + "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", + ), + ( + "window.swatchLibrary", + "Swatch Library", + "", + "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", + ), + ( + "window.swatchLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel", + ), + ( + "ui.libraryPanel", + "Library Panel Options", + "", + "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", + ), + ( + "ui.libraryPanel.locate", + "Locate Library…", + "", + "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers", + ), + ( + "ui.saveSwatchLibrary", + "Save Swatch Library…", + "", + "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", + ), + ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), + ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), + ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), + ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), + ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), + ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), + ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), + ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), + ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), + ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), + ( + "ui.mergeGraphicStyles", + "Merge Graphic Styles…", + "", + "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", + ), + ( + "ui.layerOptions", + "Options for Selection…", + "", + "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog", + ), + ( + "ui.rename", + "Rename", + "", + "{id?} open the Layers panel's name field on the row of layer, group or object id, as double-clicking the row's name does (the panel is shown and the layers around the row open): Enter renames it with layer.setProps (one undo step), Escape keeps the name. Without id: the highlighted row when a Layers panel row was clicked last, else the one selected object or group, else the one highlighted row, else the current layer. Several rows or objects, or a row the panel's search, filter or Show Layers Only hides, is an error and nothing opens → {id, name}", + ), + ( + "ui.newLayer", + "New Layer Options…", + "Cmd+Alt+L", + "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", + ), + ( + "ui.newArtboard", + "New Artboard Options…", + "", + "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", + ), + ( + "artboard.selectAll", + "Select All Artboards", + "", + "{} the Artboard tool, chosen first when another tool is in use, selects every artboard, and the active artboard stays active; the art's selection stays as it is, and no undo step is recorded → {selected: [index…]} (the active artboard first)", + ), + ( + "ui.layersPanelOptions", + "Panel Options…", + "", + "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", + ), + ( + "ui.navigatorPanelOptions", + "Panel Options…", + "", + "{color?: #rrggbb or a preset name (Light Red, the default, then the layer colors such as \"Light Blue\"), solidDashes?: bool (Draw Dashed Lines as Solid Lines, off by default: the Navigator's thumbnail draws dashed strokes without their dashes; the canvas, printing and exports keep them)} set the Navigator panel's options, the color its view box is drawn in and solid dashes (kept with the UI state) → {color, solidDashes}; with neither, open its Panel Options (dialog `navigatorPanelOptions`, fields color, solidDashes; OK sets them); an error for a value that is not a color or not true or false, which changes nothing", + ), + ( + "ui.layersExpand", + "Expand Layers Panel Rows", + "", + "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", + ), + ( + "ui.layersFilter", + "Filter Layers Panel Rows", + "", + "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", + ), + ( + "ui.tileEdgeColor", + "Tile Edge Color…", + "", + "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing", + ), + ( + "ui.flattenTransparencyDialog", + "Flatten Transparency…", + "", + "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step", + ), + ( + "ui.flattenerPresetsDialog", + "Transparency Flattener Presets…", + "", + "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export", + ), + ( + "ui.imageTracePresetDialog", + "Save as New Preset…", + "", + "{params?: tracing settings (default: the Default preset's), rename?: a saved preset's name} the Image Trace panel's Manage Presets: open Save as New Preset… for those settings, or with `rename`, Rename… for that saved preset (dialog `imageTracePreset`, field `name`: a new \"Tracing Preset N\" or the preset's name); OK runs imageTrace.presets.save with the name (as `newName` when renaming), and the panel shows the preset it made from its settings", + ), + ( + "ui.flattenerPreview", + "Flattener Preview", + "", + "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", + ), + ( + "ui.copyScriptConsole", + "Copy Script Console", + "", + "{first?, last?: numbers of lines the Script Console holds, both or neither} copy those lines of the Script Console to the clipboard as the panel shows them, each with its run's number and script name; without them every line, after a line counting the lines it dropped and cut when it dropped or cut any, which n does not count (Script Console panel: Copy Selected, Copy All; script.console gives the lines) → {lines: n}", + ), + ( + "window.graphicStyleLibrary", + "Graphic Style Library", + "", + "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", + ), + ( + "window.graphicStyleLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel", + ), + ( + "ui.saveGraphicStyleLibrary", + "Save Graphic Style Library…", + "", + "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", + ), + ( + "ui.loadCharStyles", + "Load Character Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character styles to the active one (engine: charStyle.load), the Character Styles panel menu's Load Character Styles…", + ), + ( + "ui.loadParaStyles", + "Load Paragraph Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's paragraph styles to the active one (engine: paraStyle.load), the Paragraph Styles panel menu's Load Paragraph Styles…", + ), + ( + "ui.loadAllStyles", + "Load All Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character and paragraph styles to the active one (engine: textStyle.loadAll), Load All Styles… in either panel's menu", + ), + ( + "window.userGraphicStyleLibrary1", + "User Graphic Style Library 1", + "", + "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)", + ), + ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), + ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), + ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), + ("window.userGraphicStyleLibrary5", "User Graphic Style Library 5", "", "{} open the 5. User Defined graphic style library"), + ("window.userGraphicStyleLibrary6", "User Graphic Style Library 6", "", "{} open the 6. User Defined graphic style library"), + ("window.userGraphicStyleLibrary7", "User Graphic Style Library 7", "", "{} open the 7. User Defined graphic style library"), + ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), + ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), + ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), + ( + "window.symbolLibrary", + "Symbol Library", + "", + "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}", + ), + ( + "window.symbolLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveSymbolLibrary", + "Save Symbols…", + "", + "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary", + ), + ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), + ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), + ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), + ("window.userSymbolLibrary4", "User Symbol Library 4", "", "{} open the 4. User Defined symbol library"), + ("window.userSymbolLibrary5", "User Symbol Library 5", "", "{} open the 5. User Defined symbol library"), + ("window.userSymbolLibrary6", "User Symbol Library 6", "", "{} open the 6. User Defined symbol library"), + ("window.userSymbolLibrary7", "User Symbol Library 7", "", "{} open the 7. User Defined symbol library"), + ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), + ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), + ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), + ( + "window.brushLibrary", + "Brush Library", + "", + "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}", + ), + ( + "window.brushLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveBrushLibrary", + "Save Brushes…", + "", + "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary", + ), + ("window.userBrushLibrary1", "User Brush Library 1", "", "{} open the 1. User Defined brush library (brush.libraries, category user)"), + ("window.userBrushLibrary2", "User Brush Library 2", "", "{} open the 2. User Defined brush library"), + ("window.userBrushLibrary3", "User Brush Library 3", "", "{} open the 3. User Defined brush library"), + ("window.userBrushLibrary4", "User Brush Library 4", "", "{} open the 4. User Defined brush library"), + ("window.userBrushLibrary5", "User Brush Library 5", "", "{} open the 5. User Defined brush library"), + ("window.userBrushLibrary6", "User Brush Library 6", "", "{} open the 6. User Defined brush library"), + ("window.userBrushLibrary7", "User Brush Library 7", "", "{} open the 7. User Defined brush library"), + ("window.userBrushLibrary8", "User Brush Library 8", "", "{} open the 8. User Defined brush library"), + ("window.userBrushLibrary9", "User Brush Library 9", "", "{} open the 9. User Defined brush library"), + ("window.userBrushLibrary10", "User Brush Library 10", "", "{} open the 10. User Defined brush library"), + ( + "ui.expandDialog", + "Expand…", + "", + "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step; dimmed, and an error, while the selection has an appearance effect.expandAppearance expands (object.expand.info's appearance)", + ), + ( + "attributes.openUrl", + "Browser", + "", + "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}", + ), + ( + "ui.javascriptFilesDialog", + "Link JavaScript Files…", + "", + "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected", + ), + ( + "ui.svgFilterDialog", + "Apply SVG Filter…", + "", + "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file", + ), + ( + "ui.importSvgFilter", + "Import SVG Filter…", + "", + "{} pick an SVG file and add its filters to the active document's SVG filters (svgFilter.import) → svgFilter.import's result", + ), + ( + "ui.spotColors", + "Spot Colors…", + "", + "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", + ), + ( + "ui.menuDialog", + "Menu Dialog", + "", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|artboard.rename|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, renameArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", + ), + ( + "ui.widthPointEdit", + "Width Point Edit…", + "", + "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point", + ), + ( + "ui.corners", + "Corners…", + "", + "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape", + ), + ( + "ui.simplifyAuto", + "Auto-Simplify", + "", + "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", + ), + ( + "ui.smoothAuto", + "Auto-Smooth", + "", + "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + ), + ( + "ui.simplifyMoreOptions", + "More Options", + "", + "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open", + ), + ( + "ui.dialogReset", + "Reset", + "", + "{} the open dialog's Reset, which its Cancel button reads while Alt (Option on macOS) is held (Flare Tool Options, dialog `flareOptions`): its fields go back to their defaults and the dialog stays open, so OK applies them → the dialog; an error when no dialog is open or the open one has no Reset", + ), + ( + "ui.justification", + "Justification…", + "", + "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected", + ), + ( + "ui.hyphenation", + "Hyphenation…", + "", + "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected", + ), + ( + "ui.tateChuYokoSettings", + "Tate-chu-yoko Settings…", + "", + "{} open Tate-chu-yoko Settings (the Character panel menu's Tate-chu-yoko Settings…, with Show East Asian Options) on the characters the Character panel acts on, filled in with the values they share (dialog `tateChuYokoSettings`: tcyUpDown, tcyLeftRight (pt, -1296..1296)): OK runs text.setFormat (text.setRangeStyle while the Type tool edits text) with the values that changed, as one undo step; an error with no type selected", + ), + ( + "ui.colorGuideLimit", + "Limit Color Guide to Library", + "", + "{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}", + ), + ( + "ui.colorPanel", + "Color Panel Options", + "", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + ), + ( + "ui.savePdfDialog", + "Save PDF Dialog", + "", + "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", + ), + ( + "file.exportAs", + "Export As…", + "", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", + ), + ( + "ui.fileInfoDialog", + "File Info Dialog", + "", + "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too", + ), + ( + "ui.rasterEffectsSettingsDialog", + "Document Raster Effects Settings Dialog", + "", + "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", + ), + ( + "ui.effectGalleryDialog", + "Effect Gallery Dialog", + "", + "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too", + ), + ( + "ui.pdfPresetsDialog", + "PDF Presets…", + "", + "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export", + ), + ( + "ui.pdfPresetDialog", + "PDF Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", + ), + ("file.openRecent11", "Open Recent File 11", "", "{}"), + ("file.openRecent12", "Open Recent File 12", "", "{}"), + ("file.openRecent13", "Open Recent File 13", "", "{}"), + ("file.openRecent14", "Open Recent File 14", "", "{}"), + ("file.openRecent15", "Open Recent File 15", "", "{}"), + ("file.openRecent16", "Open Recent File 16", "", "{}"), + ("file.openRecent17", "Open Recent File 17", "", "{}"), + ("file.openRecent18", "Open Recent File 18", "", "{}"), + ("file.openRecent19", "Open Recent File 19", "", "{}"), + ("file.openRecent20", "Open Recent File 20", "", "{}"), + ("file.openRecent21", "Open Recent File 21", "", "{}"), + ("file.openRecent22", "Open Recent File 22", "", "{}"), + ("file.openRecent23", "Open Recent File 23", "", "{}"), + ("file.openRecent24", "Open Recent File 24", "", "{}"), + ("file.openRecent25", "Open Recent File 25", "", "{}"), + ("file.openRecent26", "Open Recent File 26", "", "{}"), + ("file.openRecent27", "Open Recent File 27", "", "{}"), + ("file.openRecent28", "Open Recent File 28", "", "{}"), + ("file.openRecent29", "Open Recent File 29", "", "{}"), + ("file.openRecent30", "Open Recent File 30", "", "{}"), + ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), + ( + "file.cancelExport", + "Cancel Export", + "", + "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}", + ), + ( + "file.exportInBackground", + "Continue in Background", + "", + "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}", + ), + ( + "file.cancelOpen", + "Cancel Opening", + "", + "{} stop opening every file being read in the background (its progress window's Cancel): the files don't open → {cancelled: n}", + ), + ( + "file.openInBackground", + "Continue in Background", + "", + "{} close the progress window of the files being opened; they go on being read, shown in the status bar → {hidden: n}", + ), + ( + "ui.dxfOptionsDialog", + "DXF Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + ), + ( + "links.editOriginal", + "Edit Original", + "", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", + ), + ( + "links.reveal", + "Show in Folder", + "", + "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", + ), + ( + "ui.placementOptionsDialog", + "Placement Options…", + "", + "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", + ), + ( + "ui.relinkDialog", + "Relink…", + "", + "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected", + ), + ( + "ui.findLinksInFolder", + "Find Linked Files in Folder…", + "", + "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)", + ), + ( + "ui.packageDialog", + "Package…", + "Cmd+Alt+Shift+P", + "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage", + ), + ( + "ui.action.rename", + "Rename Action", + "", + "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}", + ), + ( + "ui.action.stepsIncluded", + "Include Action Steps", + "", + "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}", + ), + ( + "ui.action.new", + "New Action", + "", + "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}", + ), + ( + "ui.action.options", + "Action Options", + "", + "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}", + ), + ( + "ui.action.play", + "Play Action", + "", + "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays", + ), + ( + "ui.action.playbackOptions", + "Playback Options", + "", + "{speed?: accelerated|step|pause, seconds?: 1-60} set how the Actions panel plays actions: accelerated (the default) plays every step at once, step plays one step at a time and draws the window after each, and pause waits seconds (a whole number, 1 to 60, VectorCraft's own range) between steps. A param left out keeps its value; the setting is kept with the UI preferences. Any other speed or seconds is an error and changes nothing. The panel menu's Playback Options… (dialog `actionPlayback` with speed and seconds) runs this on OK → {speed, seconds}", + ), + ( + "ui.action.record", + "Begin Recording", + "", + "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}", + ), + ( + "ui.action.stop", + "Stop Playing/Recording", + "", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + ), + ( + "ui.action.insertStop", + "Insert Stop", + "", + "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), + ( + "ui.action.continue", + "Continue Action", + "", + "{} play on from the stop the Actions panel's playing action waits at, on the document active now, and close the stop's message box (dialog `actionStopMessage`): the next step runs at once, or at Accelerated the steps up to the next stop as one undo step. The panel's Play Current Selection runs this while an action waits. With no action waiting at a stop it is an error → {results, pending, atStop?}", + ), + ( + "ui.action.deleteStep", + "Delete Action Step", + "", + "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", + ), + ( + "ui.batchDialog", + "Batch…", + "", + "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides", + ), + ( + "ui.actionSet.save", + "Save Actions…", + "", + "{set?: index, id?, path?} write the Actions panel's set at set (0 is the top set; default: the current set) with its actions to an action set file (.vcactions) at path, else a picked file named after the set (the web downloads it). id holds the panel's own set id, which its menu passes: the set with this id is saved wherever it is now, and set is not read. A set over 8 MiB as a file, with more than 1,000 actions or with an action of more than 10,000 steps is not written; the panel menu's Save Actions… → {path}", + ), + ( + "ui.actionSet.load", + "Load Actions…", + "", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + ), + ( + "ui.actionSet.delete", + "Delete Action Sets", + "", + "{sets?: [index], ids?: [id]} delete the Actions panel's sets at these indexes (0 is the top set), with their actions (default: the current set). ids holds the panel's own set ids, which its question passes: the sets with these ids are deleted wherever they are now, sets is not read, and when one of them no longer exists nothing is deleted. The panel's Delete Selection on selected sets (Shift-click adds a range) asks first, and OK runs this. A recording into a deleted set stops and keeps nothing. An index past the last set is an error → {deleted: n}", + ), + ( + "ui.actionSet.duplicate", + "Duplicate Action or Action Set", + "", + "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}", + ), + ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), + ( + "docInfo.save", + "Save Document Info…", + "", + "{path?, selectionOnly?, category?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it); with category (a document.info category id) only that category, else every category. The panel menu's Save… passes the category the panel shows and its Selection Only → {path}", + ), + ( + "ui.epsOptionsDialog", + "EPS Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", + ), + ( + "file.saveForWeb", + "Save for Web (Legacy)…", + "Cmd+Alt+Shift+S", + "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}", + ), + ( + "file.saveForWeb.browser", + "Preview in Browser", + "", + "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", + ), + ( + "file.exportSelection", + "Export Selection…", + "", + "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", + ), + ( + "css.copy", + "Copy Selected Style", + "", + "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}", + ), + ( + "css.exportFile", + "Export CSS…", + "", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + ), + ( + "file.print", + "Print…", + "Cmd+P", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", + ), + ( + "print.printers", + "Printers", + "", + "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", + ), + ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), + ( + "ui.printPresetsDialog", + "Print Presets…", + "", + "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export", + ), + ( + "ui.printPresetDialog", + "Print Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", + ), + ( + "plugin.dialog", + "Plug-in…", + "", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + ), + ( + "ui.installPlugin", + "Install Plug-in…", + "", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + ), + ( + "plugin.cancelRuns", + "Cancel Plug-in Run", + "", + "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", + ), + ( + "plugin.hideRuns", + "Continue in Background", + "", + "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", + ), + ( + "imageTrace.cancelRuns", + "Cancel Image Trace", + "", + "{} stop every Image Trace running in the background (its progress window's Cancel): it stops at its next colour and changes nothing → {cancelled: n}", + ), + ( + "imageTrace.hideRuns", + "Continue in Background", + "", + "{} close the progress window of the Image Traces running in the background; they go on, shown in the status bar → {hidden: n}", + ), + ( + "ui.runScript", + "Other Script…", + "Cmd+F12", + "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)", + ), + ( + "ui.runInstalledScript", + "Run Script", + "", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + ), + ( + "ui.checkScript", + "Check Script…", + "", + "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)", + ), + ( + "ui.perspectiveGridDialog", + "Define Perspective Grid…", + "", + "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", + ), + ( + "ui.perspectivePresetsDialog", + "Perspective Grid Presets…", + "", + "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", + ), + ( + "ui.savePerspectivePreset", + "Save Grid as Preset…", + "", + "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", + ), + ( + "ui.perspectiveUserPreset1.1", + "One Point Perspective Preset 1", + "", + "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.2", + "One Point Perspective Preset 2", + "", + "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.3", + "One Point Perspective Preset 3", + "", + "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.4", + "One Point Perspective Preset 4", + "", + "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.5", + "One Point Perspective Preset 5", + "", + "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.1", + "Two Point Perspective Preset 1", + "", + "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.2", + "Two Point Perspective Preset 2", + "", + "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.3", + "Two Point Perspective Preset 3", + "", + "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.4", + "Two Point Perspective Preset 4", + "", + "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.5", + "Two Point Perspective Preset 5", + "", + "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.1", + "Three Point Perspective Preset 1", + "", + "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.2", + "Three Point Perspective Preset 2", + "", + "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.3", + "Three Point Perspective Preset 3", + "", + "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.4", + "Three Point Perspective Preset 4", + "", + "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.5", + "Three Point Perspective Preset 5", + "", + "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.blendOptions", + "Blend Options…", + "", + "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", + ), + ( + "ui.livePaintGapOptions", + "Gap Options…", + "", + "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing, and with preview on a #rrggbbaa color is not previewed: the groups keep their own color and the gaps draw in black", + ), + ( + "ui.perspectivePlane", + "Perspective Plane Options…", + "", + "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", + ), +]; diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 87398c1f0..343b42209 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -176,7 +176,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no record and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } if !force && !n.visible { @@ -237,6 +238,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 80f0deffd..a82503391 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use kurbo::{CubicBez, ParamCurve, PathSeg, Point, Rect, Vec2}; +use kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Rect, Vec2}; use vectorcraft_geom::path::measured_arclen; use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; @@ -84,11 +84,15 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { PathData::new(out) } -/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. +/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. A closed subpath +/// whose refit is one segment, or lines that enclose no area, is refitted in two parts, cut at the +/// point farthest from its start, or in three when two still give that. When three parts still give +/// no area, straight lines follow the subpath within the tolerance, and without straight lines it +/// stays as it is, as does one of a single segment; one that is all in line keeps the refit. fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let mut segs = subpath_segs(sp); let n = segs.len(); - if n == 0 { + if n == 0 || sp.closed && n < 2 { return Some(sp.clone()); } if sp.closed { @@ -101,7 +105,63 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { // along its chord, and `no_worse` can return the original subpath, so the start can keep a kink. let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None }; let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg)); - let result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); + let mut result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); + let in_line = |pts: &[Point]| { + let Some(&a) = pts.first() else { return true }; + let far = pts.iter().map(|&p| p - a).max_by(|u, v| u.hypot2().total_cmp(&v.hypot2())).unwrap_or_default(); + pts.iter().all(|&p| far.cross(p - a).abs() <= 1e-6 * far.hypot()) + }; + let flat = |r: &[Seg]| r.len() < 2 || r.iter().all(|s| s.line) && in_line(&r.iter().map(|s| s.c.p0).collect::>()); + if sp.closed && flat(&result) { + // Each segment in two, and the loop cut where they meet: first at the point farthest from its + // start, then also at the point farthest from the line between those two. + let halves: Vec = segs + .iter() + .flat_map(|s| { + let (a, b) = s.c.subdivide(); + [Seg { c: a, line: s.line }, Seg { c: b, line: s.line }] + }) + .collect(); + let m = halves.len(); + let at = |i: usize| halves[i].c.p0; + let farthest = |from: &dyn Fn(Point) -> f64, skip: usize| (1..m).filter(|&i| i != skip).max_by(|&a, &b| from(at(a)).total_cmp(&from(at(b)))); + let p0 = at(0); + let j = farthest(&|p| p.distance(p0), 0).unwrap_or(m / 2); + let chord = at(j) - p0; + let off_chord = |p: Point| if chord.hypot2() > 0.0 { chord.cross(p - p0).abs() } else { p.distance(p0) }; + let k = farthest(&off_chord, j).unwrap_or(m / 2); + let refit = |cuts: &[usize]| -> Vec { + cuts.windows(2).flat_map(|w| simplify_run_within(&halves[w[0]..w[1]], opts, MAX_RUN_SAMPLES / 3, None)).collect() + }; + result = refit(&[0, j, m]); + if flat(&result) && k != j { + result = refit(&[0, j.min(k), j.max(k), m]); + } + let ctrl: Vec = segs.iter().flat_map(|s| [s.c.p0, s.c.p1, s.c.p2]).collect(); + if flat(&result) && !in_line(&ctrl) { + // Each segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 in all, until every + // piece is within the tolerance of the line between its ends and those lines enclose an + // area: with straight lines they are the result, and without them the loop stays as it is. + // A loop whose points are all in line keeps the lines of the refit. + let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance); + for k in 1..=10u32 { + let parts = 1u32 << k; + if segs.len().saturating_mul(parts as usize) > 1 << 16 { + break; + } + let at = |i: u32| f64::from(i) / f64::from(parts); + let pieces: Vec = segs.iter().flat_map(|s| (0..parts).map(move |i| s.c.subsegment(at(i)..at(i + 1)))).collect(); + let lines: Vec = pieces.iter().map(|c| Seg::line(c.p0, c.p3)).collect(); + if pieces.iter().all(near) && !flat(&lines) { + if !opts.straight_lines { + return Some(sp.clone()); + } + result = lines; + break; + } + } + } + } let s = segs_to_subpath(&result, sp.closed)?; Some(if opts.straight_lines { s } else { no_worse(sp, s) }) } diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 587da3344..9a6fb5713 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -1,7 +1,7 @@ use std::f64::consts::PI; use kurbo::{ParamCurve, Point, Rect, Shape as _}; -use vectorcraft_geom::{AnchorKind, FillRule, PathData, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, FillRule, PathData, SubPath}; use vectorcraft_pathops::*; const NZ: FillRule = FillRule::NonZero; @@ -408,6 +408,77 @@ fn simplify_straight_lines_option() { assert!(max_deviation(&c, &s) <= 1.0 + 1e-6); } +/// A closed subpath whose refit is one curve stays a closed subpath that encloses an area, in a +/// compound path and on its own, with curves and with Convert to Straight Lines. +#[test] +fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() { + let small = |cx: f64, cy: f64| circle(cx, cy, 2.0).subpaths.remove(0); + let square = rect(0.0, 0.0, 1000.0, 1000.0).subpaths.remove(0); + let compound = PathData::new(vec![square, small(500.0, 500.0)]); + let lone = PathData::single(small(0.0, 0.0)); + // A straight edge and a curve that bows 2.25 pt from it. + let bow = PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 3.0), Point::new(0.0, 0.0)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 3.0)), + ], + true, + )); + // A straight edge and an S curve whose midpoints all lie on that edge. + let s_curve = |h: f64, x: f64| { + PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(0.0, h)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(x, -h), Point::new(100.0, 0.0)), + ], + true, + )) + }; + let (s_low, s_high) = (s_curve(3.0, 0.0), s_curve(13.5, 100.0)); + for straight_lines in [false, true] { + for tolerance in [4.0, 10.0, 32.0] { + let opts = SimplifyOptions { tolerance, straight_lines, ..Default::default() }; + for path in [&compound, &lone, &bow, &s_low] { + let s = simplify_with(path, &opts); + assert_eq!(s.subpaths.len(), path.subpaths.len(), "{tolerance} pt, straight lines {straight_lines}: {s:?}"); + assert!(s.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 2 && sp.area().abs() > 1.0), "{s:?}"); + assert!(max_deviation(path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(path, &s)); + let lines = s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i))); + assert!(lines || !straight_lines, "{tolerance} pt: {s:?}"); + } + } + } + // Lobes on both sides of the edge, 3.9 pt from it, stay within the tolerance. + for straight_lines in [false, true] { + let s = simplify_with(&s_high, &SimplifyOptions { tolerance: 4.0, straight_lines, ..Default::default() }); + assert!(max_deviation(&s_high, &s) <= 4.0 + 1e-6, "straight lines {straight_lines}: {}", max_deviation(&s_high, &s)); + } + // Curves whose handles lie on the line between their anchors become lines. + let thin = PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(30.0, 0.0)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(70.0, 0.0), Point::new(80.0, 0.0)), + ], + true, + )); + let s = simplify_with(&thin, &SimplifyOptions { tolerance: 4.0, straight_lines: true, ..Default::default() }); + assert!(s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i))), "{s:?}"); + // Anchors in line on a closed path are still removed. + let in_line = PathData::single(SubPath::new([0.0, 25.0, 50.0, 100.0].map(|x| Anchor::corner(Point::new(x, 0.0))).to_vec(), true)); + let s = simplify_with(&in_line, &SimplifyOptions { tolerance: 4.0, ..Default::default() }); + assert!(s.subpaths[0].anchors.len() < 4, "{s:?}"); + let many = PathData::single(SubPath::new((0..5000).map(|i| Anchor::corner(Point::new(f64::from(i), 0.0))).collect(), true)); + for tolerance in [4.0, f64::INFINITY, f64::NAN] { + for path in [&in_line, &thin, &many] { + simplify_with(path, &SimplifyOptions { tolerance, straight_lines: true, ..Default::default() }); + } + } + // A long, thin rectangle keeps its corners. + let long = rect(0.0, 0.0, 1e9, 0.5); + let s = simplify_with(&long, &SimplifyOptions { tolerance: 0.1, straight_lines: true, ..Default::default() }); + assert!(max_deviation(&long, &s) <= 0.1 + 1e-6, "{s:?}"); +} + /// The Simplify slider's levels: 1 pt at 50, doubled for every 10 levels down and halved for every /// 10 up; Auto-Simplify takes the lowest level whose tolerance is at most 1/400 of the size, and /// level 100 for a size under 12.5 pt. diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 66ff8b6e3..a5a3b8386 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -52,3 +52,9 @@ vectorcraft-text = { workspace = true, features = ["test-fonts"] } serde_json = { workspace = true } # `draw_text`, to write test files with live text as other writers do. krilla = { workspace = true, features = ["simple-text"] } + +# An independent raster oracle for gradient placement in PDF forms. These are test-only macOS +# bindings; the exporter stays portable Rust and does not depend on an OS PDF renderer. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFData"] } +objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGBitmapContext", "CGColorSpace", "CGContext", "CGDataProvider", "CGImage", "CGPDFDocument", "CGPDFPage"] } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 47184d520..d8316e94e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -9,12 +9,11 @@ use craft_pdf::write::{self, Metadata}; use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; use krilla::color::{cmyk, luma, rgb}; use krilla::configure::{Archival, PdfVersion}; -use krilla::geom::{Path, PathBuilder, Size, Transform}; +use krilla::geom::{Path, PathBuilder, Size}; use krilla::image::Image; use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; -use krilla::surface::Surface; use krilla::text::{Glyph, GlyphId, KrillaGlyph}; use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; @@ -28,6 +27,7 @@ use crate::forms::Mark; use crate::lab_spot::{find, rfind}; use crate::marks::PageBoxes; use crate::output::ColorOut; +use crate::surface::Surface; use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard}; mod entry; @@ -40,7 +40,7 @@ mod text; mod writer; pub(crate) use entry::{RgbInCmyk, leaving_out_undecodable}; -pub use entry::{export, export_with_report, page_areas}; +pub use entry::{check_page_sizes, export, export_with_report, page_areas}; #[cfg(test)] use exporter::{MAX_COORD, writable, xf}; #[cfg(test)] @@ -131,7 +131,7 @@ mod tests { fn writable_bounds_values_the_transform_and_values_through_it() { let mut pdf = krilla::Document::new(); let mut page = pdf.start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap())); - let mut s = page.surface(); + let mut s = Surface::page(page.surface(), 100.0); let r = Rect::new(0.0, 0.0, 10.0, 10.0); assert!(writable(&s, Affine::IDENTITY, r)); assert!(!writable(&s, Affine::IDENTITY, Rect::new(-2_147_483_649.0, 0.0, 10.0, 10.0))); diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index d131ece8f..fc4848521 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -1,5 +1,6 @@ use super::writer::{Sheet, Writer, date_time}; -use super::{Affine, Document, ExportReport, Exporter, HashSet, PageBoxes, PdfError, PdfOptions, Rect, write}; +use super::{Affine, Document, ExportReport, Exporter, HashSet, PageBoxes, PdfError, PdfOptions, Rect, krilla, write}; +use crate::{Compatibility, PdfSettings, Standard}; /// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { @@ -49,6 +50,8 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); let pages = pages(doc, opts)?; + let raised = for_long_pages(set, &pages)?; + let set = raised.as_ref().unwrap_or(set); let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); // The time of writing (none on the web) dates the editing data and the page information. @@ -59,6 +62,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). let mut warnings = set.warnings(); + if raised.is_some() { + warnings.push( + "PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into(), + ); + } let native = opts.native.as_deref().filter(|_| set.preserve_editing); if let Some(native) = native { w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time))); @@ -78,21 +86,21 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha ex.overprint = set.advanced.overprint == crate::Overprint::Preserve; let bleed = set.bleed_of(doc); let marks = set.marks.printer_marks(); - // In PDF 1.6 and later files that aren't PDF/X files, a page with a side longer than - // [`MAX_PAGE_SIDE`] is written in larger units; other files have every page in points. - let scales = set.compatibility.has_user_unit() && !set.standard.is_pdfx(); + let scales = writes_user_units(set); // PDF/X files have no annotations over the art (ISO 15930: only printer's mark and trap // network annotations, outside the bleed box): objects' URLs aren't written as links there. let links = !set.standard.is_pdfx(); let mut linked = false; for layer in doc.layers.iter().filter(|_| !links) { - layer.walk(&mut |n| linked |= n.url().is_some()); + layer.walk(&mut |n| linked |= !n.is_ruler_guide() && n.url().is_some()); } if linked { warnings.push(format!("{} files have no links: the objects' URLs aren't clickable", set.standard.label())); } let mut units = Vec::with_capacity(pages.len()); let total = pages.len() as u64; + let long: Vec<(usize, f64)> = + pages.iter().map(|(i, b)| (*i, b.media.width().max(b.media.height()))).filter(|(_, side)| *side > MAX_PDF14_REAL).collect(); for (written, (i, boxes)) in pages.into_iter().enumerate() { // The page count and the page being written, for a client that asked for progress. vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; @@ -104,6 +112,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // `unit` points. let unit = if scales { user_unit(m.width(), m.height()) } else { 1.0 }; units.push(unit); + if m.width().max(m.height()) > MAX_PAGE_SIDE { + warnings.push(long_page_warning(set, i, m.width(), m.height(), unit, scales)); + } let shift = Affine::translate((-m.x0, -m.y0)); let view = if unit > 1.0 { Affine::scale(1.0 / unit) * shift } else { shift }; let sheet = Sheet { @@ -127,7 +138,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible); w.absorb(ex); - let (bytes, more) = w.finish(left_out, display_doc_title)?; + let (bytes, more) = match w.finish(left_out, display_doc_title) { + Ok(done) => done, + Err(e) => return Err(writer_error(doc, opts, left_out, &long, e)), + }; let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted, invisible)?; // The writer gives the file its creation date as its modification date too. let bytes = match opts.modified.filter(|m| Some(*m) != created_at) { @@ -138,6 +152,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let bytes = crate::post::rights(bytes, &doc.metadata)?; warnings.extend(more); let bytes = crate::post::user_units(bytes, &units)?; + // A PDF 1.5 file written as PDF 1.6 keeps the encryption PDF 1.5 has (128-bit RC4). let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; warnings.dedup(); Ok(ExportReport { bytes, warnings }) @@ -168,12 +183,154 @@ pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result, PdfErr /// gives: 14,400 (200 inches in units of 1/72 inch). pub(crate) const MAX_PAGE_SIDE: f64 = 14_400.0; +/// The smallest side of a page in units of its default user space that ISO 32000-1 (Annex C.2) +/// gives: 3. +const MIN_PAGE_SIDE: f64 = 3.0; + +/// The largest real number the writer puts in a PDF 1.3 or 1.4 file: past it, krilla fails the +/// file with `Limit(TooLargeFloat)`. +const MAX_PDF14_REAL: f64 = 32_767.0; + +/// Pages with a side longer than [`MAX_PAGE_SIDE`] are written with a `UserUnit`: in PDF 1.6 and +/// later files that aren't PDF/X files, unless `long_pages_in_points` is on. Other files have +/// every page in points. +fn writes_user_units(set: &PdfSettings) -> bool { + set.compatibility.has_user_unit() && !set.standard.is_pdfx() && !set.long_pages_in_points +} + /// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most /// [`MAX_PAGE_SIDE`], else the longer side divided by it, rounded up to the next thousandth, so -/// that both sides are at most [`MAX_PAGE_SIDE`] units. +/// that both sides are at most [`MAX_PAGE_SIDE`] units. A short side of [`MIN_PAGE_SIDE`] points +/// or more stays at least [`MIN_PAGE_SIDE`] units: the unit is at most the short side divided by +/// it, rounded down to the next thousandth, and the long side is then longer than +/// [`MAX_PAGE_SIDE`] units. fn user_unit(w: f64, h: f64) -> f64 { - let side = w.abs().max(h.abs()); - if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 } + let (side, short) = (w.abs().max(h.abs()), w.abs().min(h.abs())); + if !(side.is_finite() && side > MAX_PAGE_SIDE) { + return 1.0; + } + let unit = (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0; + if short >= MIN_PAGE_SIDE { unit.min((short / MIN_PAGE_SIDE * 1000.0).floor() / 1000.0).max(1.0) } else { unit } +} + +/// Check the page sizes of `doc` exported with `opts` before anything is drawn: the error +/// [`export`] gives for a page that the file's PDF version or standard can't hold. +pub fn check_page_sizes(doc: &Document, opts: &PdfOptions) -> Result<(), PdfError> { + for_long_pages(&opts.settings, &pages(doc, opts)?).map(|_| ()) +} + +/// The settings `set` is written with when one of `pages` has a side longer than +/// [`MAX_PAGE_SIDE`]: a PDF 1.5 file that isn't a PDF/X file is written as PDF 1.6, which has a +/// page's `UserUnit`, unless `long_pages_in_points` is on (`None`: `set` as it is). Other files +/// without a `UserUnit` have the page in points. The writer puts a whole number as an integer and fails a PDF 1.3 or 1.4 file that has +/// a real number past [`MAX_PDF14_REAL`]: a page whose side is past it and not a whole number of +/// points is refused here, before anything is drawn ([`page_too_long`]). +fn for_long_pages(set: &PdfSettings, pages: &[(usize, PageBoxes)]) -> Result, PdfError> { + if !pages.iter().any(|(_, b)| b.media.width().max(b.media.height()) > MAX_PAGE_SIDE) { + return Ok(None); + } + let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx() && !set.long_pages_in_points) + .then(|| PdfSettings { compatibility: Compatibility::Pdf16, ..set.clone() }); + // The writer's test for a whole number, on the `f32` it writes. + let real = |side: f64| side > MAX_PDF14_REAL && (side as f32) as i32 as f32 != side as f32; + if let Some((i, b)) = pages.iter().find(|(_, b)| real(b.media.width()) || real(b.media.height())) { + page_too_long(set, *i, b.media.width().max(b.media.height()))?; + } + Ok(raised) +} + +/// The error for the page of artboard `artboard` (0-based), `side` points long, whose size or art +/// needs a real number past [`MAX_PDF14_REAL`], in a PDF 1.3 or 1.4 file written with `set`; `Ok` +/// in other files. +fn page_too_long(set: &PdfSettings, artboard: usize, side: f64) -> Result<(), PdfError> { + if !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14) { + return Ok(()); + } + // PDF/X-1a and PDF/X-3 files are always PDF 1.3. + let label = if matches!(set.standard, Standard::PdfX1a | Standard::PdfX3) { set.standard.label() } else { set.compatibility.label() }; + let (max, n, side) = (thousands(MAX_PDF14_REAL), artboard + 1, format!("{:.0}", side.ceil())); + Err(PdfError::BadSetting(format!( + "{label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard" + ))) +} + +/// The writer's `error` for `doc` exported with `opts`, or, when it failed on a real number past +/// [`MAX_PDF14_REAL`] in a PDF 1.3 or 1.4 file, the error for the page of `long` (artboards and +/// sides past it) that needs one. With several such pages, the first [`MAX_PROBES`] are each +/// written alone, without the editing data and the thumbnails, to find it; past them, the first +/// page is named. +fn writer_error(doc: &Document, opts: &PdfOptions, left_out: &mut HashSet, long: &[(usize, f64)], error: PdfError) -> PdfError { + const MAX_PROBES: usize = 8; + let too_large = format!("{:?}", krilla::error::KrillaError::Limit(krilla::error::LimitError::TooLargeFloat)); + if !matches!(&error, PdfError::Write(e) if *e == too_large) { + return error; + } + let failing = match long { + [] => return error, + [one] => *one, + [first, ..] => long + .iter() + .take(MAX_PROBES) + .copied() + .find(|(i, _)| { + let alone = PdfOptions { + settings: opts.settings.clone(), + artboards: Some(vec![*i]), + title: opts.title.clone(), + display_doc_title: opts.display_doc_title, + created: opts.created, + modified: opts.modified, + native: None, + private_data: None, + thumbnails: vec![], + }; + matches!(export_with_report_inner(doc, &alone, left_out), Err(PdfError::BadSetting(_))) + }) + .unwrap_or(*first), + }; + page_too_long(&opts.settings, failing.0, failing.1).err().unwrap_or(error) +} + +/// `v` (a whole number) with commas between groups of three digits: 32,767. +fn thousands(v: f64) -> String { + let digits = format!("{v:.0}"); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (k, c) in digits.chars().enumerate() { + if k > 0 && (digits.len() - k) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} + +/// A page `w` × `h` points large in points, rounded up, and in meters: 56693 × 2835 pt (20.00 × +/// 1.00 m). +fn page_size(w: f64, h: f64) -> String { + const METERS_PER_POINT: f64 = 0.0254 / 72.0; + format!("{:.0} × {:.0} pt ({:.2} × {:.2} m)", w.ceil(), h.ceil(), w * METERS_PER_POINT, h * METERS_PER_POINT) +} + +/// The warning for the page of artboard `artboard` (0-based), `w` × `h` points large with a side +/// longer than [`MAX_PAGE_SIDE`], written in units of `unit` points; `scales`: the file has +/// `UserUnit`s. +fn long_page_warning(set: &PdfSettings, artboard: usize, w: f64, h: f64, unit: f64, scales: bool) -> String { + let n = artboard + 1; + let size = page_size(w, h); + if scales && w.max(h) / unit > MAX_PAGE_SIDE { + format!( + "the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units" + ) + } else if unit > 1.0 { + format!( + "the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller" + ) + } else { + let label = if set.standard.is_pdfx() || set.standard == Standard::PdfA2b { set.standard.label() } else { set.compatibility.label() }; + format!( + "the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in" + ) + } } /// The RGB colours and non-CMYK images a CMYK document carries, which the export separates into diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index 2fea9a568..d3d5f8e7f 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -3,9 +3,11 @@ use vectorcraft_geom::Point; use super::entry::RgbInCmyk; use super::{ Affine, Arc, BezPath, BlendMode, Color, Compatibility, Document, Exporter, Fill, FillRule, HashMap, HashSet, Node, NormalizedF32, Path, - PathBuilder, PathEl, Rect, Shape, Standard, Surface, Transform, cmyk, krilla, luma, rgb, + PathBuilder, PathEl, Rect, Shape, Standard, Surface, cmyk, krilla, luma, rgb, }; +pub(super) use crate::surface::xf; + /// How deep inline graphics nest before they are left out. pub(super) const MAX_INLINE_DEPTH: u32 = 4; @@ -96,11 +98,6 @@ pub(super) const MAX_GRADIENT_PX: f64 = 4096.0; /// with overflow checks it panics on -2,147,483,648. pub(super) const MAX_COORD: f64 = 1e9; -pub(super) fn xf(a: Affine) -> Transform { - let c = a.as_coeffs(); - Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) -} - /// The current transform of surface `s` in the content stream it draws into, without that stream's /// root transform (a page's y flip). pub(super) fn ctm(s: &Surface) -> Affine { @@ -203,9 +200,12 @@ pub(super) fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) { /// group of that opacity that, unlike the writer's opacity groups, is not isolated. /// The cover is white, so a reader that takes every soft mask as a luminosity mask shows the same. pub(super) fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask { + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut ms = sb.surface(); + let mut ms = Surface::stream(sb.surface()); + ms.push_transform(&transform); cover(&mut ms, page, 255, alpha); + ms.pop(); ms.finish(); krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha) } diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index dbf4b0d87..fde13f4d3 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -22,8 +22,10 @@ impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> Option { + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut ms = sb.surface(); + let mut ms = Surface::stream(sb.surface()); + ms.push_transform(&transform); // An opaque backdrop when it matters: white outside the art (no clip), or black for the // inverting Difference pass below to turn white. if !m.clip || m.invert { @@ -39,6 +41,8 @@ impl Exporter<'_> { self.image_left_out = image_left_out.map(|outer| outer || incomplete); (self.knockout, self.overprint) = (knockout, overprint); if incomplete { + ms.pop(); + ms.finish(); return None; } if m.invert { @@ -46,6 +50,7 @@ impl Exporter<'_> { cover(&mut ms, page, 255, 1.0); ms.pop(); } + ms.pop(); ms.finish(); Some(krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)) } @@ -96,21 +101,27 @@ impl Exporter<'_> { s.opacity = 1.0; s }; + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut ms = sb.surface(); + let mut ms = Surface::stream(sb.surface()); + ms.push_transform(&transform); cover(&mut ms, page, 255, 1.0); let alpha = { + let transform = ms.group_transform(); let mut ab = ms.stream_builder(); - let mut als = ab.surface(); + let mut als = Surface::stream(ab.surface()); + als.push_transform(&transform); let overprint = std::mem::take(&mut self.overprint); self.measured_node(&mut als, &shape, page, false, Some(measured)); self.overprint = overprint; + als.pop(); als.finish(); krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) }; ms.push_mask(alpha); cover(&mut ms, page, 0, 1.0); ms.pop(); + ms.pop(); ms.finish(); krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) } @@ -307,6 +318,8 @@ impl Exporter<'_> { self.node(s, c, page, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.knockout = enclosing; for _ in 0..pushes { diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 4750bef85..9e4578bdc 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -351,7 +351,7 @@ impl Exporter<'_> { let cell = Rect::new(0.0, 0.0, pw, ph); let tiles = def.instances_in(Affine::IDENTITY, cell); let mut sb = s.stream_builder(); - let mut cs = sb.surface(); + let mut cs = Surface::stream(sb.surface()); let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b)); let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten(); if let Some(c) = &clip { diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index d7e652f2e..027025ad2 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -2,7 +2,7 @@ use super::entry::RgbInCmyk; use super::exporter::{constant_mask, cover, to_path, writable, xf}; use super::{ Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect, - Shape, Size, Standard, Vec2, find, krilla, rfind, write, + Shape, Size, Standard, Surface, Vec2, find, krilla, rfind, write, }; /// A PDF being written, shared by export and print: the file's configuration and metadata, then @@ -125,7 +125,7 @@ impl Writer { let size = Size::from_wh(w.max(least) as f32, h.max(least) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?; let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32); let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed))); - let mut s = page.surface(); + let mut s = Surface::page(page.surface(), size.height()); let paper = Rect::new(0.0, 0.0, w, h); if sheet.negative { cover(&mut s, paper, 255, 1.0); diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 4a1d06147..b771e88b7 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -13,7 +13,6 @@ use std::io::Read; use craft_pdf::write::backend as krilla; use krilla::geom::PathBuilder; use krilla::graphic::Graphic; -use krilla::surface::Surface; use std::sync::Arc; use vectorcraft_doc::{Document, Node, NodeKind}; @@ -21,6 +20,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans}; use crate::output::{deflate, text_string}; use crate::patch::{Patch, Xref}; +use crate::surface::{Surface, affine}; use crate::{PdfError, PdfSettings, Standard}; /// What a marked form is. @@ -87,6 +87,9 @@ pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface)) /// form's drawing space), rounded outwards to whole units: [`finish`] gives the form a box that /// covers the paper and the art. pub(crate) fn layer_form(s: &mut Surface, i: usize, paper: vectorcraft_geom::Rect, draw: impl FnOnce(&mut Surface)) { + // The art and its box move into page space together; keeping the old local paper here + // would clip layers on translated artboards or give the importer a spurious clipping group. + let paper = affine(s.group_transform()).transform_rect_bbox(paper); let corner = |x: f64, y: f64| (x as f32, y as f32); marked(s, Mark::Layer(i), [corner(paper.x0.floor(), paper.y0.floor()), corner(paper.x1.ceil(), paper.y1.ceil())], draw); } @@ -94,8 +97,9 @@ pub(crate) fn layer_form(s: &mut Surface, i: usize, paper: vectorcraft_geom::Rec /// Draw `draw` as a form of its own, marked `mark`: an empty clip from the mark's first point /// through `rest`. fn marked(s: &mut Surface, mark: Mark, rest: [(f32, f32); 2], draw: impl FnOnce(&mut Surface)) { + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut fs = sb.surface(); + let mut fs = Surface::stream(sb.surface()); let (x, y) = mark.at(); let mut pb = PathBuilder::new(); pb.move_to(x, y); @@ -108,10 +112,14 @@ fn marked(s: &mut Surface, mark: Mark, rest: [(f32, f32); 2], draw: impl FnOnce( fs.push_clip_path(&p, &krilla::paint::FillRule::NonZero); fs.pop(); } + fs.push_transform(&transform); draw(&mut fs); + fs.pop(); fs.finish(); let stream = sb.finish(); + s.push_transform(&transform.invert().unwrap_or_default()); s.draw_graphic(Graphic::new(stream, false)); + s.pop(); } // ---------- finishing the written file ---------- diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index b052ff7c3..61acbfc8e 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -865,6 +865,9 @@ impl<'p> Builder<'p> { let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph)); m * kurbo::Point::new(vx, 880.0) }); + // A horizontal line is gathered in visual order and put back in logical order as a whole: + // a ligature's characters (lam-alef, lam-lam-heh) go in visual order too (#6072). + let text = if top.is_none() { visual_glyph_text(text) } else { text }; let place = |line: &mut TextLine| match top { Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text), None => line.push(&look, at, opacity, advance, &text), @@ -894,3 +897,16 @@ impl<'p> Builder<'p> { true } } + +/// The characters of one glyph in the order they are drawn: a PDF names a ligature's characters +/// in logical order (lam then alef for لا), while a horizontal line of type is gathered glyph by +/// glyph in visual order and only then put back in logical order. Right-to-left characters are +/// turned round here so that putting the line back restores them. +fn visual_glyph_text(text: String) -> String { + if text.chars().nth(1).is_none() { + return text; + } + let Some((order, _)) = vectorcraft_text::logical_order(&text) else { return text }; + let chars: Vec = text.chars().collect(); + order.iter().filter_map(|&i| chars.get(i)).collect() +} diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index 687fcb156..38f22689a 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -375,6 +375,7 @@ fn read_opened( show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); let page_layer = |b: &mut Builder<'_>, art: Vec>, shown: bool| { let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) }; diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index a90d6a33b..b7163c0e5 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -633,7 +633,7 @@ impl TextLine { let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum(); let size = self.at.size; if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 { - let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size); + let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size, spacing_em(&t, size)); t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces); } } @@ -675,7 +675,7 @@ impl TextLine { // left in the file's order. let total: usize = t.runs.iter().map(|r| r.text.len()).sum(); let gaps: Vec<(usize, f64)> = if t.para.direction.is_none() { gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] }; - let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size); + let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size, spacing_em(&t, size)); t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces); // Tracked: laid out again for its bounds. bounds = vectorcraft_text::layout(db, &t).bounds; @@ -701,16 +701,19 @@ fn pens(laid: &TextLayout, chars: &[usize]) -> Vec> { } /// `t`'s runs with kerning `kern` and each character's tracking from `own` (points, one per -/// character, in thousandths of each run's em), characters alike kept in one run. +/// character, in thousandths of each run's em as its Horizontal Scale draws it), characters alike +/// kept in one run. fn restyled(t: &TextObject, kern: Option, own: &[f64]) -> Vec { let mut out: Vec = Vec::with_capacity(t.runs.len()); // The run of `t` the last character came from. let mut from = None; let mut k = 0; for (i, r) in t.runs.iter().enumerate() { - let size = r.style.size.max(1e-3); + // A PDF scales its character spacing by its horizontal scaling (ISO 32000-1 §9.4.4), as + // layout scales tracking ([`vectorcraft_text::spacing_scale`]). + let em = (r.style.size * vectorcraft_text::spacing_scale(&r.style, t.vertical)).max(1e-3); for c in r.text.chars() { - let tracking = own.get(k).map_or(0.0, |e| (e / size * 1000.0).clamp(-1000.0, 10000.0)); + let tracking = own.get(k).map_or(0.0, |e| (e / em * 1000.0).clamp(-1000.0, 10000.0)); k += 1; // Hundredths of a thousandth of an em: no drift along a line, no noise in the panels. let tracking = (tracking * 100.0).round() / 100.0; @@ -971,8 +974,9 @@ pub(crate) fn median(mut v: Vec) -> Option { /// as `laid`, `extra` points longer, as the file set it: the same after every character, except /// the spaces read from gaps or drawn between glyphs (`gaps`: byte offset, gap in points) where /// that would put the glyphs after them more than a tenth of an em off (a barcode's digits, set in -/// groups apart). Those take their own tracking, given with their byte offsets. -fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64) -> (f64, Vec<(usize, f64)>) { +/// groups apart). Those take their own tracking, given with their byte offsets. `em`: the em the +/// tracking is thousandths of, in points ([`spacing_em`]). +fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64, em: f64) -> (f64, Vec<(usize, f64)>) { let pairs = chars.saturating_sub(1).max(1) as f64; // The advance of the first glyph laid out at each byte offset. let space: HashMap = laid.glyphs.iter().rev().map(|g| (g.byte, g.advance)).collect(); @@ -995,8 +999,15 @@ fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, s tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::()) / rest } else { 0.0 }; } own.sort_by_key(|(b, _)| *b); - let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 10000.0).round(); - (em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect()) + let thousandths = |pts: f64| (pts / em.max(1e-3) * 1000.0).clamp(-1000.0, 10000.0).round(); + (thousandths(tracking), own.into_iter().map(|(b, need)| (b, thousandths(need - tracking))).collect()) +} + +/// The em the tracking of type `t` set `size` points is thousandths of, in points: the size as its +/// first run's Horizontal Scale draws it in horizontal type ([`vectorcraft_text::spacing_scale`]), +/// as a PDF scales its character spacing by its horizontal scaling (ISO 32000-1 §9.4.4). +fn spacing_em(t: &TextObject, size: f64) -> f64 { + size * t.runs.first().map_or(1.0, |r| vectorcraft_text::spacing_scale(&r.style, t.vertical)) } /// `runs` with tracking `tracking`, and each space at a byte offset of `spaces` (in order) a run diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ca7e44ae7..234f4b8a8 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -70,11 +70,12 @@ mod presets; mod print; mod settings; mod streams; +mod surface; mod syntax; pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with}; pub use encrypt::Encryption; -pub use export::{export, export_with_report, page_areas}; +pub use export::{check_page_sizes, export, export_with_report, page_areas}; pub use file_info::{FileInfo, file_info}; #[cfg(any(test, feature = "read-count"))] pub use import::imports_of; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 17f37f90b..b4b25baeb 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -236,6 +236,10 @@ pub struct PdfSettings { /// Keep the layers whose Print option is off (they are left out otherwise, unless /// `create_layers` is on). pub include_non_printing: bool, + /// Write a page with a side longer than 14,400 pt (200 in) at its size in points, without a + /// `UserUnit`, at every PDF version (off: PDF 1.6 and later files that aren't PDF/X files + /// write it with a `UserUnit`, and PDF 1.5 files are written as PDF 1.6 for it). + pub long_pages_in_points: bool, pub compression: CompressionSettings, pub marks: MarkSettings, pub bleed: BleedSettings, diff --git a/crates/pdf/src/surface.rs b/crates/pdf/src/surface.rs new file mode 100644 index 000000000..4229781cd --- /dev/null +++ b/crates/pdf/src/surface.rs @@ -0,0 +1,140 @@ +//! Keep form XObjects in page space. Quartz resolves shading patterns inside a form against the +//! page's initial space, whereas other readers also apply the transform at the form's invocation. +//! Moving that transform inside the form makes both interpretations agree (including gradients +//! in opacity groups, masks, layers and overprinting art), without rasterizing the art. +//! +//! krilla's page surface has an implicit y flip which `ctm()` does not include. Remember that +//! root separately; a form's surface has an identity root. A group still counts as one push/pop +//! for the drawing code, even though moving its transform takes three krilla pushes. + +use std::ops::{Deref, DerefMut}; + +use craft_pdf::write::backend as krilla; +use krilla::blend::BlendMode; +use krilla::geom::{Path, Transform}; +use krilla::mask::Mask; +use krilla::num::NormalizedF32; +use krilla::paint::FillRule; +use vectorcraft_geom::Affine; + +pub(crate) fn xf(a: Affine) -> Transform { + let c = a.as_coeffs(); + Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) +} + +pub(crate) fn affine(t: Transform) -> Affine { + Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from)) +} + +struct Push { + count: usize, + root: Affine, +} + +/// A drawing surface whose form groups keep gradients and masks in the same page space. +pub(crate) struct Surface<'a> { + inner: krilla::surface::Surface<'a>, + root: Affine, + pushes: Vec, +} + +impl<'a> Surface<'a> { + /// Wrap a page while recording the writer's implicit Y flip outside its reported CTM. + pub fn page(inner: krilla::surface::Surface<'a>, height: f32) -> Self { + Self { inner, root: Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height.into()]), pushes: vec![] } + } + + /// Wrap a form or pattern stream, which starts without a page's implicit Y flip. + pub fn stream(inner: krilla::surface::Surface<'a>) -> Self { + Self { inner, root: Affine::IDENTITY, pushes: vec![] } + } + + /// Current drawing space → page space, for both a form's art and its mask. A singular + /// enclosing transform draws no area: keep that form local rather than trying to invert it. + pub fn group_transform(&self) -> Transform { + let t = xf(self.root * affine(self.inner.ctm())); + if t.invert().is_some() { t } else { Transform::identity() } + } + + /// Move the enclosing transform inside a form and record its three pushes as one group. + fn group(&mut self, push: impl FnOnce(&mut krilla::surface::Surface)) { + let t = self.group_transform(); + self.inner.push_transform(&t.invert().unwrap_or_default()); + push(&mut self.inner); + self.inner.push_transform(&t); + self.pushes.push(Push { count: 3, root: self.root }); + self.root = Affine::IDENTITY; + } + + /// Apply opacity in page space when it creates a form; full opacity is a plain push. + pub fn push_opacity(&mut self, opacity: NormalizedF32) { + if opacity == NormalizedF32::ONE { + // krilla does not create a form for full opacity. + self.inner.push_opacity(opacity); + self.plain_push(); + } else { + self.group(|s| s.push_opacity(opacity)); + } + } + + pub fn push_isolated(&mut self) { + self.group(|s| s.push_isolated()); + } + + /// The mask's stream is already in [`Self::group_transform`]'s space, like the group's art. + pub fn push_mask(&mut self, mask: Mask) { + self.group(|s| s.push_mask(mask)); + } + + fn plain_push(&mut self) { + self.pushes.push(Push { count: 1, root: self.root }); + } + + pub fn push_transform(&mut self, transform: &Transform) { + self.inner.push_transform(transform); + self.plain_push(); + } + + pub fn push_clip_path(&mut self, path: &Path, rule: &FillRule) { + self.inner.push_clip_path(path, rule); + self.plain_push(); + } + + pub fn push_blend_mode(&mut self, mode: BlendMode) { + self.inner.push_blend_mode(mode); + self.plain_push(); + } + + /// Restore the enclosing coordinate space, undoing every writer push for the last group. + /// An unmatched pop is logged rather than forwarded to the writer's stack. + pub fn pop(&mut self) { + let Some(push) = self.pushes.pop() else { + log::error!("PDF surface pop without a matching push"); + return; + }; + for _ in 0..push.count { + self.inner.pop(); + } + self.root = push.root; + } + + pub fn finish(self) { + self.inner.finish(); + } +} + +// Drawing operations use krilla directly; every push/pop above keeps our coordinate state in +// step. Stream builders are wrapped with `Surface::stream` when their surfaces are created. +impl<'a> Deref for Surface<'a> { + type Target = krilla::surface::Surface<'a>; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Surface<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 365b65389..af744437d 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -131,6 +131,7 @@ fn one_page_per_artboard_with_sizes() { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); d.artboards.push(Artboard { id: 3, @@ -142,6 +143,7 @@ fn one_page_per_artboard_with_sizes() { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); let bytes = export(&d, &PdfOptions::default()).unwrap(); let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap(); @@ -162,6 +164,7 @@ fn artboard_range_and_bad_index() { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); let bytes = export(&d, &PdfOptions { artboards: Some(vec![1]), ..Default::default() }).unwrap(); let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap(); @@ -893,6 +896,7 @@ fn import_max_pages_and_multi_page() { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK)); add(&mut d, rect_node(Rect::new(310.0, 10.0, 350.0, 50.0), Color::BLACK)); diff --git a/crates/pdf/src/tests/live_text.rs b/crates/pdf/src/tests/live_text.rs index 4cb4417bc..bddeb758d 100644 --- a/crates/pdf/src/tests/live_text.rs +++ b/crates/pdf/src/tests/live_text.rs @@ -477,3 +477,43 @@ fn hebrew_comes_back_in_logical_order() { assert_eq!(shown(&t[0]), shown(&original), "{text}"); } } + +/// The text objects a PDF of `text` set in `face` imports as. +fn imported_texts(face: &vectorcraft_text::FontFace, text: &str) -> Vec { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let font = Font::new(face.file_data().to_vec().into(), face.face_index()).unwrap(); + s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, text, false, TextDirection::Auto); + s.finish(); + page.finish(); + let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap(); + texts(&r.document).iter().map(|t| t.plain_text()).collect() +} + +#[test] +fn arabic_ligatures_keep_their_letters_in_order() { + // A lam-alef ligature is one glyph whose text is lam then alef: it must not be turned round + // with the line it sits in (#6072). + let db = vectorcraft_text::FontDb::global(); + let preferred = ["DejaVu Sans", "Arial", "Times New Roman", "Noto Sans Arabic", "Noto Naskh Arabic"].map(String::from); + // Whether `face` draws lam-alef as one glyph, the ligature the fix is about. + let ligates = |face: &vectorcraft_text::FontFace| { + let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..Default::default() }; + let t = TextObject::point(kurbo::Point::ORIGIN, "لا", style); + vectorcraft_text::layout(db, &t).glyphs.len() == 1 + }; + // An installed Arabic font with a lam-alef ligature whose glyphs come back as live type. + let Some(face) = preferred + .iter() + .chain(&db.families()) + .filter_map(|f| db.face(f, "Regular")) + .filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}') && ligates(f)) + .find(|f| !imported_texts(f, "بب").is_empty()) + else { + return; // No such font on this host. + }; + for text in ["لا", "بلال", "السلام عليكم", "لا إله إلا الله"] { + assert_eq!(imported_texts(&face, text), [text], "in {}", face.family); + } +} diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index e4df914a1..c829ed8d5 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -133,13 +133,364 @@ fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() assert!(near(back.artboards[0].rect, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{:?}", back.artboards[0].rect); let red = painted(&back, |p| rgb(p, [255, 0, 0])); assert!(red.len() == 1 && near(red[0], Rect::new(-5.0, 10.0, 40.0, 50.0)), "{red:?}"); - // PDF 1.5 and PDF/X-4 files have the page in points, past 14,400. - for v in [json!({"compatibility": "1.5"}), json!({"standard": "pdfX4", "compatibility": "1.6"})] { + // PDF/X-4 files have the page in points, past 14,400. + for v in [json!({"standard": "pdfX4", "compatibility": "1.5"}), json!({"standard": "pdfX4", "compatibility": "1.6"})] { let pages = units_and_media(&pdf(&d, v.clone())); assert_eq!(pages[0], (1.0, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{v}"); } } +/// 20 m in points. +const TWENTY_M: f64 = 20.0 / 0.0254 * 72.0; + +/// [`doc`] with its artboard `w` × `h` points large. +fn sized(w: f64, h: f64) -> Document { + let mut d = doc(); + d.artboards[0].rect = Rect::new(0.0, 0.0, w, h); + d +} + +/// The export of `d` with `set`. +fn report(d: &Document, set: PdfSettings) -> Result { + export_with_report(d, &PdfOptions { settings: set, created: Some(1_791_200_000), ..PdfOptions::uncompressed() }) +} + +/// The numbers that follow `key` in the file's bytes (`/MediaBox [0 0 w h]`, `/UserUnit u`), read +/// as text, without the PDF reader: none when the file has no `key`. +fn raw_numbers(bytes: &[u8], key: &str, count: usize) -> Vec { + let text = String::from_utf8_lossy(bytes); + let Some(at) = text.find(key) else { return vec![] }; + let words = text[at + key.len()..].split(|c: char| c.is_whitespace() || matches!(c, '[' | ']' | '/' | '>')).filter(|w| !w.is_empty()); + words.take(count).map(|w| w.parse().unwrap_or(f64::NAN)).collect() +} + +/// The page's width and height in meters as the file's bytes give them: its media box times its +/// `UserUnit` (1 without one). +fn raw_meters(bytes: &[u8]) -> (f64, f64) { + let media = raw_numbers(bytes, "/MediaBox", 4); + let unit = raw_numbers(bytes, "/UserUnit", 1).first().copied().unwrap_or(1.0); + let m = |v: f64| v * unit / 72.0 * 0.0254; + (m(media[2] - media[0]), m(media[3] - media[1])) +} + +/// The warnings about pages longer than 14,400 pt. +fn long_page_warnings(r: &ExportReport) -> Vec<&String> { + r.warnings.iter().filter(|w| w.contains("page limit of 14,400 pt")).collect() +} + +/// A 20 m page at PDF 1.7 (the default) is written with a `UserUnit` of 3.938, reads back at 20 m +/// from the file's bytes, through the PDF reader and through the import, and the export says how +/// it is written. +#[test] +fn a_20_m_page_keeps_its_size_with_a_user_unit_and_the_export_says_so() { + let d = sized(TWENTY_M, TWENTY_M / 20.0); + let r = report(&d, PdfSettings::default()).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.7")); + assert_eq!( + long_page_warnings(&r), + [ + "the page of artboard 1 is 56693 × 2835 pt (20.00 × 1.00 m), longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of 3.938, so apps that ignore UserUnit show it that many times smaller" + ], + "{:?}", + r.warnings + ); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938]); + let (w, h) = raw_meters(&r.bytes); + assert!((w - 20.0).abs() < 0.001 && (h - 1.0).abs() < 0.001, "{w} × {h} m"); + let pages = units_and_media(&r.bytes); + assert_eq!(pages[0].0, 3.938); + assert!(pages[0].1.width() <= 14_400.0, "{pages:?}"); + let page = &info(&r.bytes, None).unwrap().pages[0]; + assert!((page.width - TWENTY_M).abs() < 0.01, "{page:?}"); + let back = import(&r.bytes).unwrap(); + assert!((back.artboards[0].rect.width() - TWENTY_M).abs() < 0.01, "{:?}", back.artboards[0].rect); +} + +/// Every built-in preset and every PDF version keeps a 20 m page at its size, or refuses it with +/// the reason: PDF 1.3 and 1.4 files (PDF/X-1a and PDF/X-3 among them) have no `UserUnit` and +/// hold no real number past 32,767, PDF 1.5 is written as PDF 1.6 to have one, and PDF/X-4 pages +/// at PDF 1.5 or 1.6 stay in points with a warning. +#[test] +fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { + let d = sized(TWENTY_M, TWENTY_M / 20.0); + let refused = |e: PdfError, label: &str| { + assert_eq!( + e.to_string(), + format!( + "invalid PDF setting: {label} files hold real numbers up to 32,767, and the page of artboard 1, 56693 pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard" + ) + ); + }; + for preset in crate::builtin_presets() { + let name = preset.name.as_str(); + match report(&d, preset.settings.clone()) { + Err(e) if matches!(name, "PDF/X-1a:2001" | "PDF/X-3:2002") => refused(e, name), + Err(e) => panic!("{name}: {e}"), + Ok(r) if name == "PDF/X-4:2010" => { + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{name}"); + assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{name}"); + let w = long_page_warnings(&r); + let points = "PDF/X-4:2010 pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in"; + assert!(w.len() == 1 && w[0].ends_with(points), "{w:?}"); + } + Ok(r) => { + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938], "{name}"); + assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{name}"); + assert_eq!(long_page_warnings(&r).len(), 1, "{name}: {:?}", r.warnings); + } + } + } + for c in Compatibility::ALL.iter().copied() { + let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + match (c, report(&d, set)) { + (Compatibility::Pdf13 | Compatibility::Pdf14, Err(e)) => refused(e, c.label()), + (Compatibility::Pdf13 | Compatibility::Pdf14, Ok(_)) => panic!("{} is refused", c.label()), + (_, Err(e)) => panic!("{}: {e}", c.label()), + (_, Ok(r)) => { + let header = if c == Compatibility::Pdf15 { "1.6" } else { c.id() }; + assert!(r.bytes.starts_with(format!("%PDF-{header}").as_bytes()), "{}", c.label()); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938], "{}", c.label()); + assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{}", c.label()); + let raised = r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); + assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.warnings); + } + } + } + // PDF/X-4 and PDF/A-2b files at PDF 1.4 have no `UserUnit` either. + let at_14 = |standard| report(&d, PdfSettings { standard, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }); + refused(at_14(Standard::PdfX4).err().unwrap(), "PDF 1.4"); + refused(at_14(Standard::PdfA2b).err().unwrap(), "PDF 1.4"); +} + +/// A page from 14,400 to 32,767 pt long is written in points, with the warning, in PDF 1.3 and 1.4 +/// files, PDF/X-1a, PDF/X-3, PDF/X-4 and PDF/A-2b files among them, as before the warning. Past +/// 32,767 pt a page of whole points is written too, as the writer puts whole numbers as integers; +/// a side that isn't a whole number, or art that needs a real number past 32,767, gets the error. +#[test] +fn pdf_1_4_writes_a_page_up_to_32767_pt_in_points() { + let presets = crate::builtin_presets(); + let preset = |name: &str| presets.iter().find(|p| p.name == name).unwrap().settings.clone(); + let sets = [ + ("PDF 1.3", PdfSettings { compatibility: Compatibility::Pdf13, ..PdfSettings::default() }), + ("PDF 1.4", PdfSettings { compatibility: Compatibility::Pdf14, ..PdfSettings::default() }), + ("PDF/X-1a:2001", preset("PDF/X-1a:2001")), + ("PDF/X-3:2002", preset("PDF/X-3:2002")), + ("PDF/X-4:2010", PdfSettings { standard: Standard::PdfX4, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }), + ("PDF/A-2b", PdfSettings { standard: Standard::PdfA2b, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }), + ]; + for (label, set) in &sets { + for side in [17_000.0, 32_767.0, 40_000.0] { + let r = report(&sized(side, 300.0), set.clone()).unwrap_or_else(|e| panic!("{label} at {side}: {e}")); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{label}"); + assert_eq!(raw_numbers(&r.bytes, "/MediaBox", 4), [0.0, 0.0, side, 300.0], "{label}"); + let w = long_page_warnings(&r); + let points = format!("{label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in"); + assert!(w.len() == 1 && w[0].ends_with(&points), "{label}: {w:?}"); + } + // For PDF/X-4 and PDF/A-2b files the error names the PDF version. + let label = if matches!(*label, "PDF/X-4:2010" | "PDF/A-2b") { "PDF 1.4" } else { label }; + let refused = |e: PdfError, side: &str| { + let refusal = format!("{label} files hold real numbers up to 32,767, and the page of artboard 1, {side} pt long, needs larger ones"); + assert!(e.to_string().contains(&refusal), "{e}"); + }; + refused(report(&sized(32_767.5, 300.0), set.clone()).err().unwrap(), "32768"); + // Art at a coordinate that isn't a whole number past 32,767. + let mut d = sized(40_000.0, 300.0); + let layer = d.default_layer().unwrap(); + let id = d.alloc_id(); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.insert(Some(layer), 0, Node::path(id, shapes::rectangle(Rect::new(35_000.5, 10.0, 35_100.25, 50.0)), red)).unwrap(); + refused(report(&d, set.clone()).err().unwrap(), "40000"); + } +} + +/// With several pages past 32,767 pt in a PDF 1.4 file, the error names the page whose art needs a +/// real number past 32,767, not the first long page. +#[test] +fn the_error_names_the_long_page_whose_art_needs_a_larger_real() { + let mut d = sized(40_000.0, 300.0); + let mut second = d.artboards[0].clone(); + second.rect = Rect::new(0.0, 10_000.0, 40_000.0, 10_300.0); + d.artboards.push(second); + let layer = d.default_layer().unwrap(); + let id = d.alloc_id(); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.insert(Some(layer), 0, Node::path(id, shapes::rectangle(Rect::new(35_000.5, 10_010.0, 35_100.25, 10_050.0)), red)).unwrap(); + let set = PdfSettings { compatibility: Compatibility::Pdf14, ..PdfSettings::default() }; + let e = report(&d, set).err().unwrap(); + assert!(e.to_string().contains("the page of artboard 2, 40000 pt long, needs larger ones"), "{e}"); +} + +/// A long page whose short side would be under 3 units at the `UserUnit` its long side needs +/// (ISO 32000-1, Annex C.2; PDF/A-2b's 6.1.13) is written with a smaller unit that keeps the +/// short side at 3 units, and a warning; 100 m × 4 mm is one. A short side under 3 pt is under 3 +/// units at any unit, and keeps the unit its long side needs. +#[test] +fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { + const MM: f64 = 72.0 / 25.4; + for set in [PdfSettings::default(), PdfSettings { standard: Standard::PdfA2b, ..PdfSettings::default() }] { + let r = report(&sized(TWENTY_M * 5.0, 4.0 * MM), set).unwrap(); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.779]); + let media = raw_numbers(&r.bytes, "/MediaBox", 4); + assert!(media[3] - media[1] >= 3.0, "{media:?}"); + assert!((media[2] - media[0] - TWENTY_M * 5.0 / 3.779).abs() < 0.01, "{media:?}"); + assert_eq!( + r.warnings.iter().filter(|w| w.contains("too long and narrow")).collect::>(), + [ + "the page of artboard 1 is 283465 × 12 pt (100.00 × 0.00 m), too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of 3.779, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units" + ] + ); + assert!(long_page_warnings(&r).is_empty(), "{:?}", r.warnings); + } + // A short side of 3 units or more at the long side's unit keeps that unit. + let r = report(&sized(TWENTY_M * 5.0, 60.0), PdfSettings::default()).unwrap(); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); + let media = raw_numbers(&r.bytes, "/MediaBox", 4); + assert!(media[3] - media[1] >= 3.0, "{media:?}"); + let r = report(&sized(TWENTY_M * 5.0, 2.0), PdfSettings::default()).unwrap(); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); +} + +/// A PDF 1.5 file written as PDF 1.6 for a long page keeps PDF 1.5's encryption, 128-bit RC4, as +/// the Save PDF dialog shows it. +#[test] +fn a_pdf_1_5_file_written_as_1_6_keeps_rc4_encryption() { + let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..PdfSettings::default() }; + set.security.open_password = "secret".into(); + let has = |bytes: &[u8], s: &[u8]| bytes.windows(s.len()).any(|w| w == s); + let r = report(&sized(TWENTY_M, 300.0), set).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.6")); + assert!(has(&r.bytes, b"/CFM/V2") && !has(&r.bytes, b"AESV2"), "the file is encrypted with RC4"); +} + +/// The warning gives a page's size rounded up, as the error does: a side of 14,400.4 pt is 14401. +#[test] +fn the_long_page_warning_rounds_the_size_up() { + let r = report(&sized(14_400.4, 300.0), PdfSettings::default()).unwrap(); + assert!(long_page_warnings(&r)[0].contains("is 14401 × 300 pt"), "{:?}", r.warnings); +} + +/// A page of exactly 14,400 pt is written in points with no warning, at any PDF version; one +/// point longer gets a `UserUnit` of 1.001 and the warning, and stays in points at PDF 1.4. +#[test] +fn a_page_of_14400_pt_stays_in_points_and_one_point_more_does_not() { + let at = |side: f64, c: Compatibility| report(&sized(side, 300.0), PdfSettings { compatibility: c, ..PdfSettings::default() }); + for c in [Compatibility::Pdf14, Compatibility::Pdf15, Compatibility::Pdf17] { + let r = at(14_400.0, c).unwrap(); + assert!(long_page_warnings(&r).is_empty() && !r.warnings.iter().any(|w| w.contains("UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); + assert_eq!(raw_numbers(&r.bytes, "/MediaBox", 4), [0.0, 0.0, 14_400.0, 300.0], "{}", c.label()); + } + let r = at(14_401.0, Compatibility::Pdf17).unwrap(); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [1.001]); + assert_eq!(long_page_warnings(&r).len(), 1, "{:?}", r.warnings); + assert!(long_page_warnings(&r)[0].contains("14401 × 300 pt (5.08 × 0.11 m)"), "{:?}", r.warnings); + let r = at(14_401.0, Compatibility::Pdf14).unwrap(); + assert_eq!(raw_numbers(&r.bytes, "/MediaBox", 4), [0.0, 0.0, 14_401.0, 300.0]); + assert_eq!(long_page_warnings(&r).len(), 1, "{:?}", r.warnings); +} + +/// A page of 14,400 pt or less is written as before in every built-in preset and every PDF +/// version: the header names the version asked for, the media box is in points, and there is no +/// `UserUnit` and no page-size warning. A PDF 1.5 file with a password keeps 128-bit RC4. +#[test] +fn a_page_within_14400_pt_is_written_as_before_at_every_version() { + let d = sized(14_400.0, 14_400.0); + let mut sets: Vec = crate::builtin_presets().into_iter().map(|p| p.settings).collect(); + for c in [Compatibility::Pdf13, Compatibility::Pdf14, Compatibility::Pdf15, Compatibility::Pdf16, Compatibility::Pdf17, Compatibility::Pdf20] { + sets.push(PdfSettings { compatibility: c, ..PdfSettings::default() }); + } + for set in sets { + let label = format!("{} {}", set.standard.label(), set.compatibility.label()); + let r = report(&d, set.clone()).unwrap_or_else(|e| panic!("{label}: {e}")); + // PDF/X-1a and PDF/X-3 files are always PDF 1.3. + let pdf13 = set.compatibility == Compatibility::Pdf13 || matches!(set.standard, Standard::PdfX1a | Standard::PdfX3); + let header = if pdf13 { "%PDF-1.3".to_string() } else { format!("%{}", set.compatibility.label().replace(' ', "-")) }; + assert!(r.bytes.starts_with(header.as_bytes()), "{label}: {:?}", String::from_utf8_lossy(&r.bytes[..8])); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{label}"); + assert_eq!(raw_numbers(&r.bytes, "/MediaBox", 4), [0.0, 0.0, 14_400.0, 14_400.0], "{label}"); + assert!(!r.warnings.iter().any(|w| w.contains("14,400") || w.contains("UserUnit")), "{label}: {:?}", r.warnings); + } + let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..PdfSettings::default() }; + set.security.open_password = "secret".into(); + let r = report(&d, set).unwrap(); + let has = |s: &[u8]| r.bytes.windows(s.len()).any(|w| w == s); + assert!(r.bytes.starts_with(b"%PDF-1.5") && has(b"/CFM/V2") && !has(b"AESV2")); +} + +/// 10 m in points. +const TEN_M: f64 = 10.0 / 0.0254 * 72.0; + +/// With `long_pages_in_points`, a 10 m page at PDF 1.7 is written at its size in points, with no +/// `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the page keeps +/// its `UserUnit`. +#[test] +fn long_pages_in_points_writes_a_10_m_page_in_points() { + let d = sized(TEN_M, TEN_M / 10.0); + let r = report(&d, PdfSettings { long_pages_in_points: true, ..PdfSettings::default() }).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.7")); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new()); + let media = raw_numbers(&r.bytes, "/MediaBox", 4); + assert!((media[2] - media[0] - 28_346.46).abs() < 0.01 && (media[3] - media[1] - 2_834.65).abs() < 0.01, "{media:?}"); + assert_eq!( + long_page_warnings(&r), + [ + "the page of artboard 1 is 28347 × 2835 pt (10.00 × 1.00 m), longer than PDF's page limit of 14,400 pt (200 in): PDF 1.7 pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in" + ], + "{:?}", + r.warnings + ); + let page = &info(&r.bytes, None).unwrap().pages[0]; + assert!((page.width - TEN_M).abs() < 0.01 && (page.height - TEN_M / 10.0).abs() < 0.01, "{page:?}"); + let back = import(&r.bytes).unwrap(); + let rect = back.artboards[0].rect; + assert!((rect.width() - TEN_M).abs() < 0.01 && (rect.height() - TEN_M / 10.0).abs() < 0.01, "{rect:?}"); + // Off (the default), the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. + let off = report(&d, PdfSettings::default()).unwrap(); + assert_eq!(raw_numbers(&off.bytes, "/UserUnit", 1), [1.969]); + assert!( + long_page_warnings(&off)[0].ends_with("it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller") + ); + assert!(!settings(json!({})).long_pages_in_points, "off by default"); +} + +/// With `long_pages_in_points`, every PDF version writes a 10 m page in points: PDF 1.5 stays +/// PDF 1.5, and versions that write it in points anyway (PDF 1.3 and 1.4, PDF/X-4) give the same +/// file as without it. +#[test] +fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_in_points_already() { + let d = sized(TEN_M, TEN_M / 10.0); + let with = |set: &PdfSettings| PdfSettings { long_pages_in_points: true, ..set.clone() }; + for c in Compatibility::ALL.iter().copied() { + let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let r = report(&d, with(&set)).unwrap(); + assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); + assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label()); + assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings); + if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) { + assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label()); + } + } + let x4 = crate::builtin_presets().into_iter().find(|p| p.name == "PDF/X-4:2010").unwrap().settings; + assert_eq!(report(&d, with(&x4)).unwrap().bytes, report(&d, x4).unwrap().bytes); +} + +/// A page of 14,400 pt or less gives the same file with `long_pages_in_points` on or off, at every +/// PDF version. +#[test] +fn long_pages_in_points_leaves_a_page_within_14400_pt_as_it_is() { + let d = sized(14_400.0, 300.0); + for c in Compatibility::ALL.iter().copied() { + let off = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; + let (off, on) = (report(&d, off).unwrap(), report(&d, on).unwrap()); + assert_eq!(on.bytes, off.bytes, "{}", c.label()); + assert_eq!(on.warnings, off.warnings, "{}", c.label()); + } +} + #[test] fn art_in_the_bleed_is_kept_and_clipped_to_the_bleed_box() { let d = doc(); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index daf2bab07..2f59563e7 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -33,6 +33,7 @@ fn artboard(id: u32, x: f64) -> Artboard { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, } } diff --git a/crates/pdf/src/tests/text_placement.rs b/crates/pdf/src/tests/text_placement.rs index 45bf5e409..f275def4d 100644 --- a/crates/pdf/src/tests/text_placement.rs +++ b/crates/pdf/src/tests/text_placement.rs @@ -286,6 +286,21 @@ fn spacing_and_scaling_keep_each_glyph_where_the_file_put_it() { } } +/// Character spacing in horizontally scaled type: a PDF scales `Tc` by its horizontal scaling +/// (ISO 32000-1 §9.4.4), as layout scales tracking by the Horizontal Scale +/// (`vectorcraft_text::spacing_scale`), so the type's tracking is the file's `Tc` in thousandths of +/// its size: 1.5 pt at 16 pt is 93.75, set 80% wide. +#[test] +fn character_spacing_in_horizontally_scaled_type_is_the_files_tc() { + let bytes = page("BT /F1 16 Tf 1.5 Tc 80 Tz 20 50 Td (Spaced and narrow) Tj ET", ""); + let t = texts(&open_as(&bytes, TextAs::Text)); + assert_eq!(t.len(), 1); + assert!(t[0].runs.iter().all(|r| (r.style.h_scale - 80.0).abs() < 1e-6), "{:?}", t[0].runs); + let tracking: Vec = t[0].runs.iter().map(|r| r.style.tracking).collect(); + assert!(tracking.iter().all(|v| (v - 93.75).abs() < 0.5), "{tracking:?}"); + assert!(displacement(&bytes) < 0.02); +} + /// Word spacing stays on the spaces, as tracking of their own; evenly spaced letters keep one /// tracking value for the line, and a file that didn't kern opens with kerning off. #[test] diff --git a/crates/pdf/src/tests/webview.rs b/crates/pdf/src/tests/webview.rs index 9a4659ddc..a40db425e 100644 --- a/crates/pdf/src/tests/webview.rs +++ b/crates/pdf/src/tests/webview.rs @@ -31,6 +31,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); } let mut png = vec![]; diff --git a/crates/pdf/tests/gradient_forms.rs b/crates/pdf/tests/gradient_forms.rs new file mode 100644 index 000000000..9f06c9b84 --- /dev/null +++ b/crates/pdf/tests/gradient_forms.rs @@ -0,0 +1,215 @@ +//! Gradients in forms must follow the art, including on pages with a nonzero origin. Quartz +//! exposed a failure the import/render round trip did not: it resolved a form's shading pattern +//! in page space, leaving off-centre gradients painted entirely in their last stop's colour. +//! These fixtures are original, generated geometry; no user artwork or external files are used. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, ColorMode, Document, Knockout, Node, OpacityMask}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_pdf::{PdfOptions, export, import}; +use vectorcraft_testkit::raster::{assert_similar, render_artboard}; + +#[derive(Clone, Copy, Debug)] +enum Form { + Page, + Opacity, + Isolated, + Nested, + PassThrough, + Mask, + Knockout, + Layer, + Overprint, +} + +const FORMS: [Form; 9] = + [Form::Page, Form::Opacity, Form::Isolated, Form::Nested, Form::PassThrough, Form::Mask, Form::Knockout, Form::Layer, Form::Overprint]; + +fn rect(d: &mut Document, bounds: Rect, paint: Paint) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0)) +} + +fn group(d: &mut Document, n: Node) -> Node { + Node::group(d.alloc_id(), vec![Arc::new(n)]) +} + +/// Generate off-centre gradient art in each form type, moving the art and artboard together. +fn fixture(form: Form, kind: GradientKind, mode: ColorMode, origin: Point) -> (Document, PdfOptions) { + let mut d = Document::new_with_mode(120.0, 180.0, mode); + let color = |k| if mode == ColorMode::Cmyk { Color::cmyk(0.0, 0.0, 0.0, k) } else { Color::gray(k) }; + let mut gradient = GradientPaint::new(Gradient { + kind, + stops: vec![GradientStop::new(0.0, color(1.0)), GradientStop::new(0.6, color(0.4)), GradientStop::new(1.0, color(0.0))], + ..Default::default() + }); + let (start, end) = match kind { + GradientKind::Radial => (Point::new(32.0, 35.0), Point::new(60.0, 35.0)), + _ => (Point::new(10.0, 10.0), Point::new(70.0, 70.0)), + }; + gradient.geom = Some(GradientGeom { start, end, aspect: 0.8, focal: None }); + let mut n = rect(&mut d, Rect::new(10.0, 10.0, 70.0, 70.0), Paint::Gradient(Box::new(gradient))); + let mut opts = PdfOptions::default(); + match form { + Form::Page => d.page_isolate = true, + Form::Opacity => n.opacity = 0.6, + Form::Isolated => { + n = group(&mut d, n); + n.isolate = true; + } + Form::Nested => { + n.opacity = 0.75; + n = group(&mut d, n); + n.opacity = 0.8; + } + Form::PassThrough => { + n.blend = BlendMode::Multiply; + n = group(&mut d, n); + n.opacity = 0.6; + } + Form::Mask => { + let mask = rect(&mut d, Rect::new(10.0, 10.0, 44.0, 70.0), Paint::solid(Color::WHITE)); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); + } + Form::Knockout => { + let top = rect(&mut d, Rect::new(50.0, 50.0, 80.0, 80.0), Paint::solid(Color::WHITE)); + n = Node::group(d.alloc_id(), vec![Arc::new(n), Arc::new(top)]); + n.knockout = Knockout::On; + } + Form::Layer => opts.settings.create_layers = true, + Form::Overprint => n.appearance.fill_mut().unwrap().overprint = true, + } + n.transform(Affine::translate(origin.to_vec2()), false); + d.artboards[0].rect = d.artboards[0].rect + origin.to_vec2(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + (d, opts) +} + +/// Round trips preserve gradient placement for the form types the importer supports. +#[test] +fn gradients_in_forms_keep_their_position_when_read_back() { + // The importer has a pre-existing mismatch on the knockout's synthesized nested masks; + // Quartz below checks its actual PDF output, along with the other forms. + for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Knockout)) { + for kind in [GradientKind::Linear, GradientKind::Radial] { + for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] { + let (d, opts) = fixture(form, kind, ColorMode::Rgb, origin); + let back = import(&export(&d, &opts).unwrap()).unwrap(); + assert_similar(&render_artboard(&back), &render_artboard(&d), 5.0, 0.01); + } + } + } +} + +/// Layer form bounds stay page-sized after translation, without extra imported clipping groups. +#[test] +fn translated_layer_forms_cover_the_paper_in_their_new_space() { + // The layer-box repair added upstream must move with the gradient fix's form transform. + // A local-space paper box would otherwise introduce a clipping group on import. + for origin in [Point::ORIGIN, Point::new(125.0, -80.0), Point::new(-250.0, 450.0)] { + let (d, mut opts) = fixture(Form::Layer, GradientKind::Radial, ColorMode::Rgb, origin); + opts.settings.compression.compress_text = false; + let bytes = export(&d, &opts).unwrap(); + let text = String::from_utf8_lossy(&bytes); + let boxes: Vec> = text + .split("/Subtype/Form") + .skip(1) + .map(|form| { + form.split("/BBox[").nth(1).unwrap().split(']').next().unwrap().split_ascii_whitespace().map(|v| v.parse().unwrap()).collect() + }) + .collect(); + assert_eq!(boxes, [vec![0.0, 0.0, 120.0, 180.0]], "{origin:?}: the layer covers exactly the page"); + let back = import(&bytes).unwrap(); + let children = back.layers[0].children().unwrap(); + assert_eq!(children.len(), 1); + assert!(children[0].path_data().is_some(), "{origin:?}: no clipping group wraps the layer's art"); + } +} + +// Test the renderer that exhibited the fault, not just our own importer (which accepted the +// old files). OS bindings are confined to this integration-test module. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod quartz { + use objc2_core_foundation::CFData; + use objc2_core_graphics::{CGBitmapContextCreate, CGColorSpace, CGContext, CGDataProvider, CGImageAlphaInfo, CGPDFDocument}; + use vectorcraft_testkit::raster::Image; + + use super::*; + + /// Render the first PDF page through Quartz as an oracle independent of our importer. + fn render(bytes: &[u8]) -> Image { + let data = CFData::from_bytes(bytes); + let provider = CGDataProvider::with_cf_data(Some(&data)).unwrap(); + let doc = CGPDFDocument::with_provider(Some(&provider)).unwrap(); + let page = CGPDFDocument::page(Some(&doc), 1).unwrap(); + let space = CGColorSpace::new_device_rgb().unwrap(); + let (width, height) = (120, 180); + let mut rgba = vec![255u8; width * height * 4]; + // SAFETY: the buffer holds every RGBA row, stays allocated and is not accessed until the + // context is dropped. All inputs are the small, generated fixtures above. + let context = unsafe { + CGBitmapContextCreate(rgba.as_mut_ptr().cast(), width, height, 8, width * 4, Some(&space), CGImageAlphaInfo::PremultipliedLast.0) + } + .unwrap(); + CGContext::draw_pdf_page(Some(&context), Some(&page)); + drop(context); + Image { width: width as u32, height: height as u32, rgba } + } + + /// Quartz places RGB and CMYK radial centres, edges and masks with their translated art. + #[test] + fn quartz_keeps_off_centre_radials_in_transparency_and_layer_forms() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for form in FORMS { + for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] { + let (d, opts) = fixture(form, GradientKind::Radial, mode, origin); + let img = render(&export(&d, &opts).unwrap()); + let centre = img.over_white(32, 35)[0]; + let edge = img.over_white(32, 55)[0]; + assert!(centre < 150 && edge.saturating_sub(centre) > 40, "{mode:?} {form:?} {origin:?}: centre {centre}, edge {edge}"); + assert_eq!(img.over_white(90, 100), [255; 3], "art stays in its bounds"); + if matches!(form, Form::Mask) { + assert_eq!(img.over_white(46, 35), [255; 3], "the mask moves with the gradient"); + } + } + } + } + } + + /// Quartz retains the linear ramp's direction inside forms on a translated artboard. + #[test] + fn quartz_keeps_linear_gradient_directions_in_forms() { + for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Mask | Form::Knockout)) { + let (d, opts) = fixture(form, GradientKind::Linear, ColorMode::Rgb, Point::new(-30.0, 45.0)); + let img = render(&export(&d, &opts).unwrap()); + let (start, end) = (img.over_white(20, 20)[0], img.over_white(60, 60)[0]); + assert!(end.saturating_sub(start) > 60, "{form:?}: start {start}, end {end}"); + } + } + + /// A gradient's stop-opacity mask remains aligned with its colour through nested forms. + #[test] + fn quartz_keeps_gradient_stop_opacity_aligned_inside_nested_forms() { + let (mut d, opts) = fixture(Form::Nested, GradientKind::Radial, ColorMode::Rgb, Point::ORIGIN); + let mut leaves = vec![]; + d.walk(|n| { + if n.path_data().is_some() { + leaves.push(n.id); + } + }); + for id in leaves { + let fill = d.node_mut(id).unwrap().appearance.fill_mut().unwrap(); + let Paint::Gradient(g) = &mut fill.paint else { panic!("gradient fixture") }; + for stop in &mut g.gradient.stops { + stop.opacity = 0.6 * (1.0 - stop.offset); + } + } + let img = render(&export(&d, &opts).unwrap()); + let (centre, edge) = (img.over_white(32, 35)[0], img.over_white(32, 55)[0]); + assert!(centre > 145 && centre < 205 && edge > 240, "centre {centre}, edge {edge}"); + } +} diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 7c18f2c06..ef21a57ef 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -22,6 +22,10 @@ craft-vectorcraft-format = { workspace = true } sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } wasmi = { workspace = true } +# The monotonic clock of run deadlines and the first-view budget: `std::time::Instant` on the +# desktop, the page's `performance.now()` in the web build. It is MIT OR Apache-2.0 and already in +# the build through eframe. +web-time = "1" [dev-dependencies] wat = { workspace = true } diff --git a/crates/plugins/src/deferred.rs b/crates/plugins/src/deferred.rs new file mode 100644 index 000000000..1645dd55e --- /dev/null +++ b/crates/plugins/src/deferred.rs @@ -0,0 +1,727 @@ +//! Live-effect plug-ins while a document opens: the plug-in effects drawn for its first view share +//! a time budget of [`OPENING_BUDGET_MS`], and a stage that is not run for want of time, or that +//! gives no result, is deferred: it draws its input art and is listed with the reason +//! ([`Deferrals::list`]) until it runs again. +//! +//! One [`Deferrals`] belongs to each open document. Drawing that document on screen runs inside +//! [`Deferrals::view`], which applies its budget and its deferred stages to the plug-in effects +//! evaluated on that thread ([`crate::effect`]): +//! +//! - until [`Deferrals::first_view_done`], every run takes the lesser of its per-call limit and +//! what is left of the budget, no run starts once the budget is spent, and a result that comes +//! back after the budget ran out is not used; +//! - afterwards a deferred stage is not run again while its input and parameters stay the same; a +//! stage whose input or parameters changed runs under the per-call limit; +//! - [`Deferrals::refresh`] runs deferred stages again with a fresh budget. +//! +//! Work outside a view (exports, commands, drawing without a screen) runs every stage under the +//! per-call limit. When such a run gives a result for a stage some document has deferred, +//! [`revision`] changes, so that drawings made with the deferred stage are drawn again. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +/// The time the plug-in effects drawn for a document's first view share, in milliseconds. +pub const OPENING_BUDGET_MS: u64 = 2_000; + +/// Why a stage is deferred. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Reason { + /// It was not run, or its result came too late, because the budget was spent. + Budget, + /// It ran to its per-call time limit. + Timeout, + /// Its plug-in isn't installed as a live effect. + Missing, + /// It trapped, panicked, returned art that was refused, or its parameters were refused. + Failed, + /// Safe Mode is on: no plug-in runs. + SafeMode, +} + +impl Reason { + /// The reason as `effect.deferredStatus` names it. + pub fn name(self) -> &'static str { + match self { + Reason::Budget => "budget", + Reason::Timeout => "timeout", + Reason::Missing => "missing", + Reason::Failed => "failed", + Reason::SafeMode => "safeMode", + } + } +} + +/// Where a plug-in effect sits: the object, the index of the effect in its list, and the fill or +/// stroke whose list that is (`None`: the object's own effects). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Stage { + pub object: u64, + pub effect: usize, + pub item: Option, +} + +/// A deferred stage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Deferred { + pub stage: Stage, + /// The plug-in's id. + pub plugin: String, + pub reason: Reason, + /// The input and parameters the stage was deferred with (the effect cache's key), 0 when the + /// plug-in was not looked at (Safe Mode, a missing plug-in). + pub(crate) key: u64, +} + +/// What [`Deferrals::refresh`] did with the stages it ran again. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Refreshed { + /// Stages that gave a result. + pub refreshed: usize, + /// Stages left deferred for want of time, or with input the refresh did not run. + pub still_deferred: usize, + /// Stages that ran out of their per-call time, failed or had no plug-in again. + pub failed: usize, +} + +/// Milliseconds on a monotonic clock, `None` from a clock that cannot be read. +pub type Clock = Arc Option + Send + Sync>; + +/// Milliseconds since the first reading in this process, from `web_time::Instant`: the system's +/// monotonic clock on the desktop and the page's `performance.now()` in the web build. +fn monotonic() -> Option { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + let start = START.get_or_init(web_time::Instant::now); + Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Phase { + /// Runs share a budget that started at `started` (`None`: no run yet). `first`: the first + /// view, which reports what it deferred when it ends. + Budgeted { started: Option, first: bool }, + /// Deferred stages are left as they are while their input stays the same. + Open, +} + +struct State { + phase: Phase, + deferred: Vec, + /// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it. + report: Option, + /// The refresh running, if one is ([`Deferrals::refresh`]). + refresh: Option, + clock: Clock, +} + +/// A [`Deferrals::refresh`] while it runs. It applies only to the thread that runs it: drawings +/// of the document on other threads go on as if no refresh ran. +struct Refresh { + thread: std::thread::ThreadId, + /// When its budget started (`None`: no run yet). + started: Option, + /// The objects whose stages run again; the others' stay deferred. + ids: Vec, + /// The cache keys whose cached failures have been run again. + retried: Vec, + /// The stages of `ids` that were drawn. + visited: Vec, + /// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again. + touched: Vec<(Stage, u64)>, +} + +impl State { + /// The refresh running on this thread. + fn refresh_here(&mut self) -> Option<&mut Refresh> { + let here = std::thread::current().id(); + self.refresh.as_mut().filter(|r| r.thread == here) + } + + /// The phase that applies on this thread: a refresh running here has a budget of its own. + fn phase_here(&mut self) -> Phase { + match self.refresh_here() { + Some(r) => Phase::Budgeted { started: r.started, first: false }, + None => self.phase, + } + } + + /// Is a refresh running here, and does it run the stages of `object`? + fn refreshes(&mut self, object: u64) -> Option { + self.refresh_here().map(|r| r.ids.contains(&object)) + } +} + +impl Drop for State { + fn drop(&mut self) { + for d in &self.deferred { + unmark(d.key); + } + } +} + +/// The deferred stages and the opening budget of one open document. Clones share them. +#[derive(Clone)] +pub struct Deferrals(Arc>); + +impl Default for Deferrals { + fn default() -> Self { + Self::with_clock(Arc::new(monotonic)) + } +} + +impl std::fmt::Debug for Deferrals { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Deferrals").field("deferred", &self.list()).finish() + } +} + +/// What a budgeted run may take. +pub(crate) enum Allowance { + /// No budget applies: the per-call limit. + Unlimited, + /// At most this many milliseconds, which end the budget at `until`. + Until { ms: u64, until: u64 }, + /// The budget is spent: the stage is deferred without running. + Spent, +} + +impl Deferrals { + /// A document's deferrals before its first view, timed by the monotonic clock. + pub fn new() -> Self { + Self::default() + } + + /// [`Deferrals::new`] timed by `clock` (tests give a clock of their own). + pub fn with_clock(clock: Clock) -> Self { + Self(Arc::new(Mutex::new(State { + phase: Phase::Budgeted { started: None, first: true }, + deferred: Vec::new(), + report: None, + refresh: None, + clock, + }))) + } + + fn state(&self) -> std::sync::MutexGuard<'_, State> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The deferred stages, by object and effect, each once (a stage drawn with several inputs, + /// such as the paths of a group, is listed with the first one's reason). + pub fn list(&self) -> Vec { + let mut out = self.state().deferred.clone(); + out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect)); + out.dedup_by_key(|d| d.stage); + out + } + + /// Is the first view still being drawn ([`Deferrals::first_view_done`] not called yet)? + pub fn opening(&self) -> bool { + matches!(self.state().phase, Phase::Budgeted { first: true, .. }) + } + + /// Runs `f` with these deferrals applied to the plug-in effects evaluated on this thread. + pub fn view(&self, f: impl FnOnce() -> T) -> T { + let previous = VIEW.with(|v| v.borrow_mut().replace(self.clone())); + let _ = PASSES.try_with(|p| p.borrow_mut().push(Pass::default())); + let _restore = Restore { previous, current: self.clone() }; + f() + } + + /// One drawing inside [`Deferrals::view`] has ended: a stage that ran after the first view, + /// whether it gave a result or was deferred again, keeps only the records of the inputs that + /// drawing left deferred. The records of its other inputs were made with input it no longer + /// has. + fn settle(&self, pass: Pass) { + if pass.ran.is_empty() { + return; + } + self.state().deferred.retain(|d| { + let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key)); + if !keep { + unmark(d.key); + } + keep + }); + } + + /// The first view has been drawn: later drawings leave deferred stages deferred, and + /// [`Deferrals::take_report`] gives how many there are. Does nothing after the first call. + pub fn first_view_done(&self) { + let mut s = self.state(); + if let Phase::Budgeted { first: true, .. } = s.phase { + s.phase = Phase::Open; + s.report = Some(s.stages().len()); + } + } + + /// How many stages the first view left deferred, once, after it was drawn. + pub fn take_report(&self) -> Option { + self.state().report.take() + } + + /// Runs the deferred stages of the objects in `objects` (all of them when `None`) again with a + /// fresh budget: `run` is given those objects, draws them inside [`Deferrals::view`] on this + /// thread, and returns the objects it drew whole, every part of them reached. A stage the + /// drawing does not reach (that of a hidden object, say) stays deferred as it was, and so does + /// a stage input it does not reach, unless the object was drawn whole: the input is then one + /// the stage no longer has. The refresh and its budget apply only to this thread. + pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec) -> Refreshed { + let (targets, ids) = { + let mut s = self.state(); + let mut targets: Vec = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect(); + targets.sort_by_key(|t| (t.object, t.item, t.effect)); + targets.dedup(); + let mut ids: Vec = targets.iter().map(|t| t.object).collect(); + ids.dedup(); + let thread = std::thread::current().id(); + let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new()); + s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched }); + (targets, ids) + }; + // The refresh ends when `run` returns, also when it unwinds. + struct Reopen<'a>(&'a Deferrals); + impl Drop for Reopen<'_> { + fn drop(&mut self) { + self.0.state().refresh = None; + } + } + let reopen = Reopen(self); + let whole = run(&ids); + let mut s = self.state(); + let touched = match s.refresh.take() { + Some(r) => { + s.deferred.retain(|d| { + let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key)); + if gone { + unmark(d.key); + } + !gone + }); + r.touched + } + None => Vec::new(), + }; + drop(s); + drop(reopen); + // A stage fails again when an input it ran with this time is deferred for a reason other + // than time; one with records left that this refresh did not run is still deferred. + let s = self.state(); + let mut out = Refreshed::default(); + for t in &targets { + let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable(); + if left.peek().is_none() { + out.refreshed += 1; + } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) { + out.failed += 1; + } else { + out.still_deferred += 1; + } + } + drop(s); + if out.refreshed > 0 { + REVISION.fetch_add(1, Ordering::Relaxed); + } + out + } + + /// Records `stage` with cache key `key` (of plug-in `plugin`) as deferred for `reason`, in + /// place of what was recorded for that stage and key. After the first view, when the drawing + /// ends, the stage's records of inputs that drawing did not leave deferred are dropped + /// ([`Deferrals::settle`]). + pub(crate) fn defer(&self, stage: Stage, plugin: &str, reason: Reason, key: u64) { + seen(stage, key); + let mut s = self.state(); + s.remove(stage, key); + mark(key); + s.deferred.push(Deferred { stage, plugin: plugin.to_string(), reason, key }); + s.ran_here(stage, key); + } + + /// `stage` gave a result for cache key `key`, from a run or from the cache: it is no longer + /// deferred with that input. After the first view, when the drawing ends, the stage's records + /// of inputs that drawing did not leave deferred are dropped as well ([`Deferrals::settle`]). + pub(crate) fn ran(&self, stage: Stage, key: u64) { + let mut s = self.state(); + s.remove(stage, key); + s.ran_here(stage, key); + } + + /// Should `stage`, with cache key `key`, be left deferred without running? After the first + /// view, a stage deferred with the same input and parameters is, and so is one of an object a + /// refresh does not run. + pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool { + let mut s = self.state(); + let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false); + let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key); + drop(s); + if held { + seen(stage, key); + } + held + } + + /// `stage` is being drawn: a refresh running here that runs its object's stages notes that it + /// reached the stage. + pub(crate) fn visit(&self, stage: Stage) { + let mut s = self.state(); + if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) { + r.visited.push(stage); + } + } + + /// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is + /// false, such as those of effects or objects removed from the document. + pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) { + self.state().deferred.retain(|d| { + let k = keep(d.stage, &d.plugin); + if !k { + unmark(d.key); + } + k + }); + } + + /// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key + /// while a refresh running here runs the stages of `stage`'s object. + pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool { + let mut s = self.state(); + let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false }; + r.retried.push(key); + true + } + + /// What a run starting now may take of the budget: the budget starts with the first run. + /// Without a clock, the first view runs nothing and a refresh runs every stage under its + /// per-call limit. + pub(crate) fn allowance(&self) -> Allowance { + let mut s = self.state(); + let Phase::Budgeted { started, first } = s.phase_here() else { return Allowance::Unlimited }; + let Some(now) = (s.clock)() else { return if first { Allowance::Spent } else { Allowance::Unlimited } }; + let start = started.unwrap_or(now); + match s.refresh_here() { + Some(r) => r.started = Some(start), + None => s.phase = Phase::Budgeted { started: Some(start), first }, + } + let until = start.saturating_add(OPENING_BUDGET_MS); + match until.checked_sub(now).filter(|ms| *ms > 0) { + Some(ms) => Allowance::Until { ms, until }, + None => Allowance::Spent, + } + } + + /// Has the clock passed `until` (a result that came back now is too late)? Without a clock, + /// yes. + pub(crate) fn passed(&self, until: u64) -> bool { + let clock = self.state().clock.clone(); + clock().is_none_or(|now| now > until) + } +} + +impl State { + /// Removes what is recorded for `stage` with cache key `key`. A record without a key (the + /// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0 + /// removes every record of the stage. + fn remove(&mut self, stage: Stage, key: u64) { + self.deferred.retain(|d| { + let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0); + if !keep { + unmark(d.key); + } + keep + }); + } + + /// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this + /// thread: a refresh running here notes the input ([`Deferrals::refresh`]), and after the first + /// view [`Deferrals::settle`] looks at the stage when that drawing ends. + fn ran_here(&mut self, stage: Stage, key: u64) { + if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) { + r.touched.push((stage, key)); + } + if self.phase_here() == Phase::Open { + let _ = PASSES.try_with(|p| { + if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) { + pass.ran.push(stage); + } + }); + } + } + + /// The deferred stages, each once. + fn stages(&self) -> Vec { + let mut out: Vec = self.deferred.iter().map(|d| d.stage).collect(); + out.sort_by_key(|t| (t.object, t.item, t.effect)); + out.dedup(); + out + } +} + +thread_local! { + static VIEW: RefCell> = const { RefCell::new(None) }; + /// One entry for each [`Deferrals::view`] running on this thread, innermost last. + static PASSES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// What one drawing inside a view did: the stages that ran after the first view (with a result or +/// deferred again), and the stage inputs it left deferred. +#[derive(Default)] +struct Pass { + ran: Vec, + seen: Vec<(Stage, u64)>, +} + +/// The drawing on this thread left `stage` deferred with cache key `key`. +fn seen(stage: Stage, key: u64) { + let _ = PASSES.try_with(|p| { + if let Some(pass) = p.borrow_mut().last_mut() { + pass.seen.push((stage, key)); + } + }); +} + +/// Ends the drawing and puts the previous view back, also when the work inside unwinds. +struct Restore { + previous: Option, + current: Deferrals, +} + +impl Drop for Restore { + fn drop(&mut self) { + if let Some(pass) = PASSES.try_with(|p| p.borrow_mut().pop()).ok().flatten() { + self.current.settle(pass); + } + let previous = self.previous.take(); + let _ = VIEW.try_with(|v| *v.borrow_mut() = previous); + } +} + +/// The deferrals of the view being drawn on this thread ([`Deferrals::view`]). +pub fn current() -> Option { + VIEW.try_with(|v| v.borrow().clone()).ok().flatten() +} + +/// Changes when deferred stages give results: [`Deferrals::refresh`] ran some, or a run outside a +/// view gave a result for a stage some document has deferred. +pub fn revision() -> u64 { + REVISION.load(Ordering::Relaxed) +} + +static REVISION: AtomicU64 = AtomicU64::new(0); + +/// The cache keys some document's deferred stages were deferred with, and how many stages hold each. +static MARKED: Mutex>> = Mutex::new(None); + +fn mark(key: u64) { + if key != 0 { + *MARKED.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).entry(key).or_default() += 1; + } +} + +fn unmark(key: u64) { + let mut marked = MARKED.lock().unwrap_or_else(|e| e.into_inner()); + let Some(m) = marked.as_mut() else { return }; + if let Some(n) = m.get_mut(&key) { + *n -= 1; + if *n == 0 { + m.remove(&key); + } + } +} + +/// A run gave a result for cache key `key`: when some document deferred a stage with that key, +/// [`revision`] changes. +pub(crate) fn resolved(key: u64) { + let marked = MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key)); + if marked { + REVISION.fetch_add(1, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A clock that reads `step` milliseconds later each time it is read. + fn stepping(step: u64) -> Clock { + let t = Arc::new(AtomicU64::new(0)); + Arc::new(move || Some(t.fetch_add(step, Ordering::Relaxed))) + } + + #[test] + fn the_budget_starts_with_the_first_run_and_ends_after_it() { + let d = Deferrals::with_clock(stepping(700)); + assert!(matches!(d.allowance(), Allowance::Until { ms: 2_000, until: 2_000 })); + assert!(matches!(d.allowance(), Allowance::Until { ms: 1_300, until: 2_000 })); + assert!(matches!(d.allowance(), Allowance::Until { ms: 600, until: 2_000 })); + assert!(matches!(d.allowance(), Allowance::Spent)); + d.first_view_done(); + assert!(matches!(d.allowance(), Allowance::Unlimited)); + } + + #[test] + fn without_a_clock_the_first_view_runs_nothing_and_a_refresh_everything() { + let d = Deferrals::with_clock(Arc::new(|| None)); + assert!(matches!(d.allowance(), Allowance::Spent)); + assert!(d.passed(u64::MAX)); + d.first_view_done(); + d.defer(Stage::default(), "a", Reason::Budget, 31); + d.refresh(None, |_| { + assert!(matches!(d.allowance(), Allowance::Unlimited)); + vec![] + }); + } + + /// The default clock, which the web build also reads, gives the first view its budget. + #[test] + fn the_default_clock_gives_the_first_view_a_budget() { + let d = Deferrals::new(); + assert!(matches!(d.allowance(), Allowance::Until { ms: OPENING_BUDGET_MS, .. })); + assert!(!d.passed(u64::MAX)); + } + + /// A stage drawn with several inputs (the paths of a group) holds one record for each, and is + /// listed and counted once. + #[test] + fn a_stage_is_listed_and_counted_once() { + let d = Deferrals::with_clock(stepping(1)); + let s = Stage { object: 7, effect: 1, item: None }; + d.defer(s, "a", Reason::Budget, 11); + d.defer(s, "a", Reason::Budget, 12); + d.defer(s, "a", Reason::Timeout, 12); + d.defer(Stage { item: Some(0), ..s }, "a", Reason::Failed, 13); + assert_eq!(d.list().len(), 2); + assert!(!d.holds(s, 12), "the first view runs deferred stages"); + d.first_view_done(); + assert!(d.holds(s, 11) && d.holds(s, 12)); + assert!(!d.holds(s, 14), "other input"); + assert_eq!(d.take_report(), Some(2)); + assert_eq!(d.take_report(), None); + d.first_view_done(); + assert_eq!(d.take_report(), None, "the first view ends once"); + d.ran(s, 11); + assert_eq!(d.list().len(), 2, "the stage is still deferred with its other input"); + d.ran(s, 12); + assert_eq!(d.list().len(), 1); + d.defer(s, "a", Reason::Missing, 0); + d.ran(s, 15); + assert_eq!(d.list().len(), 1, "a record without a key goes with any result"); + } + + /// A refresh runs the deferred stages of the objects asked for with a fresh budget, and counts + /// what became of them; the stages its drawing did not reach stay as they were. Another + /// thread draws the document as if no refresh ran. + #[test] + fn a_refresh_counts_what_became_of_each_stage() { + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let st = |object, effect| Stage { object, effect, item: None }; + let stages = [ + (st(1, 0), Reason::Budget), + (st(1, 1), Reason::Budget), + (st(1, 2), Reason::Budget), + (st(1, 3), Reason::Timeout), + (st(2, 0), Reason::Budget), + (st(3, 0), Reason::Failed), + ]; + for (stage, reason) in stages { + d.defer(stage, "a", reason, 40 + stage.object * 4 + stage.effect as u64); + } + let seen = std::cell::RefCell::new(vec![]); + let counts = d.refresh(Some(&[1, 3, 9]), |ids| { + seen.borrow_mut().extend_from_slice(ids); + assert!(matches!(d.allowance(), Allowance::Until { .. }), "a fresh budget"); + assert!(d.holds(st(2, 0), 48), "an object not asked for stays deferred"); + d.visit(st(2, 0)); + d.visit(st(1, 0)); + d.visit(st(1, 1)); + d.defer(st(1, 1), "a", Reason::Budget, 45); + std::thread::scope(|scope| { + let other = scope.spawn(|| (matches!(d.allowance(), Allowance::Unlimited), d.holds(st(1, 2), 46), d.retries(st(3, 0), 52))); + assert_eq!(other.join().unwrap(), (true, true, false), "another thread"); + }); + d.visit(st(1, 1)); + assert_eq!(d.list().iter().filter(|x| x.stage == st(1, 1)).count(), 1, "a stage is reached once"); + d.visit(st(3, 0)); + d.defer(st(3, 0), "a", Reason::Failed, 52); + ids.to_vec() + }); + assert_eq!(*seen.borrow(), [1, 3]); + // st(1, 3) timed out before and was not reached: still deferred, not failed again. + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 3, failed: 1 }); + assert!(matches!(d.allowance(), Allowance::Unlimited), "the budget ends with the refresh"); + let left = d.list().iter().map(|x| x.stage).collect::>(); + assert_eq!(left, [st(1, 1), st(1, 2), st(1, 3), st(2, 0), st(3, 0)]); + + // An object the drawing did not reach whole keeps the inputs it did not reach: a reached + // stage of it gives a result only for the inputs that ran. + d.defer(st(1, 1), "a", Reason::Budget, 70); + let counts = d.refresh(Some(&[1]), |_| { + d.visit(st(1, 1)); + d.ran(st(1, 1), 45); + vec![] + }); + assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 3, failed: 0 }); + let keys = |stage| d.state().deferred.iter().filter(|x| x.stage == stage).map(|x| x.key).collect::>(); + assert_eq!(keys(st(1, 1)), [70]); + let counts = d.refresh(Some(&[1]), |_| { + d.visit(st(1, 1)); + vec![1] + }); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 0 }, "drawn whole: an input it no longer has"); + assert_eq!(keys(st(1, 1)), Vec::::new()); + d.retain(|stage, _| stage.object != 1); + assert_eq!(d.list().iter().map(|x| x.stage).collect::>(), [st(2, 0), st(3, 0)]); + } + + /// After the first view, a stage drawn with several inputs (the paths of a group) whose one + /// changed input runs keeps the records of the inputs the same drawing left deferred, and drops + /// the record of the input it no longer has. + #[test] + fn a_stage_that_runs_keeps_the_inputs_its_drawing_left_deferred() { + let d = Deferrals::with_clock(stepping(1)); + let s = Stage { object: 4, effect: 0, item: None }; + let other = Stage { object: 5, ..s }; + for key in [61, 62] { + d.defer(s, "a", Reason::Budget, key); + } + d.defer(other, "a", Reason::Budget, 63); + d.first_view_done(); + d.view(|| { + assert!(d.holds(s, 61), "an unchanged path"); + assert!(!d.holds(s, 64), "the changed path"); + d.ran(s, 64); + }); + let keys = d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::>(); + assert_eq!(keys, [(4, 61), (5, 63)]); + + // A changed input that fails again replaces the record of the input it no longer has. + d.view(|| { + assert!(!d.holds(other, 65)); + d.defer(other, "a", Reason::Failed, 65); + }); + let left = d.state().deferred.iter().map(|x| (x.stage.object, x.key, x.reason)).collect::>(); + assert_eq!(left, [(4, 61, Reason::Budget), (5, 65, Reason::Failed)]); + } + + #[test] + fn a_result_for_a_deferred_key_changes_the_revision() { + const KEY: u64 = 0xdef0_0001; + let marked = || MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&KEY)); + let d = Deferrals::with_clock(stepping(1)); + assert!(!marked()); + d.defer(Stage::default(), "a", Reason::Budget, KEY); + let other = d.clone(); + other.defer(Stage { object: 2, ..Stage::default() }, "a", Reason::Budget, KEY); + assert!(marked()); + let before = revision(); + resolved(KEY); + assert!(revision() > before); + other.ran(Stage::default(), KEY); + assert!(marked(), "another stage holds the key"); + drop((d, other)); + assert!(!marked(), "the document closed"); + } +} diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 01cedbcc0..f06d17e24 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -12,7 +12,8 @@ //! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when //! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and //! reported by [`last_error`]. The swatches and patterns a painted result names are checked -//! against the document each time it is used. +//! against the document each time it is used. While a document is drawn on screen, its first view +//! shares a time budget and stages without a result are deferred ([`crate::deferred`]). use std::collections::HashMap; use std::hash::{Hash, Hasher}; @@ -22,6 +23,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, AppearanceItem, Document}; use vectorcraft_geom::{FillRule, PathData, Rect}; +use crate::deferred::{Allowance, Reason, Stage}; use crate::objects::{decode, output_geometry, paint_lists}; use crate::painted::{self, Piece, Scope}; use crate::{Error, Kind, Limits, Result, registry}; @@ -69,6 +71,9 @@ pub struct Paints<'a> { /// The index in `appearance.items` of the fill or stroke whose own effects hold this one; /// `None`: one of the object's effects. pub item: Option, + /// The object and the effect's place in its list, which a deferred stage is listed by + /// ([`crate::deferred`]); `None`: not known, listed as object 0. + pub at: Option, } /// [`Paints`] as the plug-in is given them: the input object's `fills` and `strokes`, and @@ -151,7 +156,8 @@ impl Out { #[derive(Default)] struct Cache { - entries: HashMap)>, + /// Each result, or why the run gave none ([`Reason::Timeout`] or [`Reason::Failed`]). + entries: HashMap)>, /// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]). anchors: usize, } @@ -294,9 +300,10 @@ pub fn paints_output(id: &str) -> bool { /// For an effect that returns painted pieces, the geometry is every piece's path in order, and /// `None` when the swatches and patterns they name are refused as [`apply_painted`] refuses them. pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints, doc: Option<&Document>) -> Option { + let at = paints.at; let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?; if let Out::Pieces(pieces) = &out { - references_found(id, &plugin, pieces, doc)?; + references_found(id, &plugin, pieces, doc, at)?; } Some(out.geometry()) } @@ -318,9 +325,10 @@ pub fn apply_painted( paints: Paints, doc: Option<&Document>, ) -> Option>> { + let at = paints.at; let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?; let Out::Pieces(pieces) = out else { return None }; - references_found(id, &plugin, &pieces, doc)?; + references_found(id, &plugin, &pieces, doc, at)?; Some(match doc { Some(d) => painted::with_swatch_colors(pieces, d), None => pieces, @@ -329,11 +337,17 @@ pub fn apply_painted( /// `Some` when every swatch and pattern `pieces` name is in `doc` ([`painted::check_references`]), /// recording the outcome for [`last_error`] when there is a document. The document's swatches and -/// patterns can change while a result stays cached, so each use checks them. -fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> { +/// patterns can change while a result stays cached, so each use checks them. Inside a document's +/// view, a refusal defers stage `at` as failed. +fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>, at: Option) -> Option<()> { let checked = painted::check_references(pieces, doc); if doc.is_some() { note(id, plugin, Origin::References, checked.as_ref().map(|_| ())); + if checked.is_err() + && let Some(v) = crate::deferred::current() + { + v.defer(at.unwrap_or_default(), id, Reason::Failed, 0); + } } checked.ok() } @@ -341,12 +355,36 @@ fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Opt /// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what /// it gave. The launch marker names the plug-in during its first run in this process, and until /// every run that started while it ran has ended too ([`crate::safe_mode::loading`]). +/// +/// Inside a document's view ([`crate::deferred`]), a stage without a result is recorded as +/// deferred with the reason, and one with a result is no longer deferred. During the view's +/// budget a run takes at most what is left of it, none starts once it is spent, and a result +/// that comes back after it ran out is not used; neither is cached or reported by +/// [`last_error`]. After the first view, a stage deferred with the same input is not run again. fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc, Out)> { + let view = crate::deferred::current(); + let stage = paints.at.unwrap_or_default(); + if let Some(v) = &view { + v.visit(stage); + } + let defer = |reason, key| { + if let Some(v) = &view { + v.defer(stage, id, reason, key); + } + }; if crate::safe_mode::active() { + defer(Reason::SafeMode, 0); return None; } - let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?; - let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?); + let Some(plugin) = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect) else { + defer(Reason::Missing, 0); + return None; + }; + let Ok(resolved) = plugin.manifest().resolve_params(params) else { + defer(Reason::Failed, 0); + return None; + }; + let resolved = Value::Object(resolved); let given = paints.given(); let key = Key { serial: plugin.serial(), @@ -357,14 +395,44 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR path: path.clone(), }; let h = key.hash64(); - if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h)) - && *k == key - { - return out.clone().map(|o| (plugin, o)); + let cached = CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|c| c.entries.get(&h)) + .filter(|(k, _)| *k == key) + .map(|(_, out)| out.clone()); + match cached { + // A result from the cache counts as a run that gave it. + Some(Ok(o)) => { + if let Some(v) = &view { + v.ran(stage, h); + } + return Some((plugin, o)); + } + // A refresh runs a stage that failed again. + Some(Err(reason)) if !view.as_ref().is_some_and(|v| v.retries(stage, h)) => { + defer(reason, h); + return None; + } + _ => {} } + let allowance = match &view { + Some(v) if v.holds(stage, h) => return None, + Some(v) => v.allowance(), + None => Allowance::Unlimited, + }; + let (wall_time_ms, until) = match allowance { + Allowance::Unlimited => (WALL_TIME_MS, None), + Allowance::Until { ms, until } => (ms.min(WALL_TIME_MS), Some(until)), + Allowance::Spent => { + defer(Reason::Budget, h); + return None; + } + }; let scope = paints.scope(); let guarded = begin_run(&plugin); - let r = run(&plugin, resolved, path, bounds, rule, given, scope); + let r = run(&plugin, resolved, path, (bounds, rule), given, scope, wall_time_ms); let cancelled = matches!(r, Err(Error::Cancelled)); if guarded { end_run(plugin.serial(), cancelled); @@ -373,11 +441,28 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR if cancelled { return None; } + let timed_out = matches!(&r, Err(Error::Limit(l)) if l == crate::host::TIME_BUDGET); + // A run the budget cut short, or whose result came after the budget ran out, says nothing + // about the plug-in either: the stage waits for a later run. + let late = until.is_some_and(|u| view.as_ref().is_some_and(|v| v.passed(u))); + if (timed_out && wall_time_ms < WALL_TIME_MS) || late { + defer(Reason::Budget, h); + return None; + } note(id, &plugin, Origin::Run, r.as_ref().map(|_| ())); - let out = r.ok(); - let size = weight(&key, out.as_ref()); + let out = r.map_err(|_| if timed_out { Reason::Timeout } else { Reason::Failed }); + match &out { + Ok(_) => { + if let Some(v) = &view { + v.ran(stage, h); + } + crate::deferred::resolved(h); + } + Err(reason) => defer(*reason, h), + } + let size = weight(&key, out.as_ref().ok()); if size > CACHE_ANCHORS / 4 { - return out.map(|o| (plugin, o)); + return out.ok().map(|o| (plugin, o)); } let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); let cache = cache.get_or_insert_with(Cache::default); @@ -385,15 +470,24 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR *cache = Cache::default(); } if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) { - cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref())); + cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref().ok())); } cache.anchors += size; - out.map(|o| (plugin, o)) + out.ok().map(|o| (plugin, o)) } -/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect -/// that declares `paintOutput` its pieces, checked for an effect in `scope`. -fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result { +/// One run of `plugin` on `path` (reference box and fill rule `(bounds, rule)`), with the object's +/// paints as `given`, stopped after `wall_time_ms`: geometry, or for an effect that declares +/// `paintOutput` its pieces, checked for an effect in `scope`. +fn run( + plugin: &crate::Plugin, + mut params: Value, + path: &PathData, + (bounds, rule): (Rect, FillRule), + given: Given, + scope: Scope, + wall_time_ms: u64, +) -> Result { let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]); params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item}); let rule_name = match rule { @@ -404,7 +498,9 @@ fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]}); let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms)); let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) }; - let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }; + let limits = Limits { wall_time_ms, ..plugin.limits().clone() }; + #[cfg(test)] + tests::RUNS_STARTED.with(|n| n.set(n.get() + 1)); let output = plugin.run_limited(&input, ¶ms, &limits)?; if plugin.manifest().paint_output { return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?))); @@ -420,6 +516,11 @@ mod tests { use super::*; + thread_local! { + /// How many plug-in runs this thread started ([`run`]). + pub(super) static RUNS_STARTED: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + /// Fills and strokes come in paint order, and the item an effect sits on is counted among the /// fills or among the strokes. #[test] @@ -427,7 +528,7 @@ mod tests { let fill = |c| AppearanceItem::Fill(FillLayer::new(Paint::solid(c))); let stroke = |w| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), w)); let ap = Appearance { items: vec![fill(Color::WHITE), stroke(2.0), fill(Color::BLACK), stroke(5.0)], ..Appearance::default() }; - let given = |item| Paints { appearance: Some(&ap), item }.given(); + let given = |item| Paints { appearance: Some(&ap), item, at: None }.given(); let g = given(None); assert_eq!((g.fills.len(), g.strokes.len(), &g.item), (2, 2, &json!("object"))); assert_eq!(g.strokes.iter().map(|s| s["width"].clone()).collect::>(), [json!(2.0), json!(5.0)]); @@ -494,9 +595,141 @@ mod tests { assert!(last_error(ID).unwrap().contains("trapped")); assert!(draw(&short).is_none(), "from the cache, refused again"); assert!(last_error(ID).unwrap().contains("swatch")); + // Inside a document's view, the refused stage is deferred as failed, also from the cache. + let view = crate::deferred::Deferrals::with_clock(Arc::new(|| Some(0))); + let at = Stage { object: 3, effect: 1, item: None }; + let in_view = || { + view.view(|| { + apply_painted( + ID, + &json!({}), + &short, + Rect::new(0.0, 0.0, 1.0, 1.0), + FillRule::NonZero, + Paints { at: Some(at), ..Paints::default() }, + Some(&doc), + ) + }) + }; + assert!(in_view().is_none()); + let listed = || view.list().iter().map(|d| (d.stage, d.reason)).collect::>(); + assert_eq!(listed(), [(at, Reason::Failed)]); + view.first_view_done(); + assert!(in_view().is_none()); + assert_eq!(listed(), [(at, Reason::Failed)], "a later drawing leaves it listed"); registry::remove(ID); } + /// A clock that reads `step` milliseconds later each time it is read. + fn stepping(step: u64) -> crate::deferred::Clock { + let t = Arc::new(std::sync::atomic::AtomicU64::new(0)); + Arc::new(move || Some(t.fetch_add(step, std::sync::atomic::Ordering::Relaxed))) + } + + /// A line of `n` points: a distinct input for each `n`. + fn line(n: usize) -> PathData { + PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::>(), false)) + } + + /// Plug-in `id` drawn on `path` as effect `effect` of object 1: whether it gave a result. + fn drawn(id: &str, path: &PathData, effect: usize) -> bool { + let at = Some(Stage { object: 1, effect, item: None }); + apply(id, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints { at, ..Paints::default() }, None).is_some() + } + + /// The effects of a first view share its budget: once it is spent no run starts, and a result + /// that comes back after it ran out is not used. Later drawings leave the deferred stages + /// deferred; a drawing outside the view (an export) runs them, and the view then uses the + /// result. + #[test] + fn the_first_view_shares_the_budget_and_defers_the_rest() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.budget"; + let src = crate::wat::returning(&crate::wat::manifest(ID, "effect", "{}"), r#"{"objects":[]}"#); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + // Each run takes 900 ms by this clock (it is read before and after each run). + let view = crate::deferred::Deferrals::with_clock(stepping(450)); + let ran = view.view(|| (0..4).map(|i| drawn(ID, &line(i + 2), i)).collect::>()); + assert_eq!(ran, [true, true, false, false], "the third result comes back at 2,250 ms, the fourth doesn't start"); + let listed = view.list().iter().map(|d| (d.stage.effect, d.reason, d.plugin.clone())).collect::>(); + assert_eq!(listed, [(2, Reason::Budget, ID.to_string()), (3, Reason::Budget, ID.to_string())]); + view.first_view_done(); + assert_eq!(view.take_report(), Some(2)); + assert!(!view.view(|| drawn(ID, &line(4), 2)), "a later drawing leaves it deferred"); + assert!(view.view(|| drawn(ID, &line(9), 3)), "an edit to its input runs it"); + assert_eq!(view.list().len(), 1); + assert!(drawn(ID, &line(4), 2), "outside the view it runs"); + assert_eq!(view.list().len(), 1, "until the view draws it again"); + assert!(view.view(|| drawn(ID, &line(9), 2)), "an edit to an input whose result is cached"); + assert!(view.list().is_empty(), "the record of the input it no longer has goes"); + assert!(view.view(|| drawn(ID, &line(4), 2)), "from the cache"); + assert!(view.list().is_empty()); + registry::remove(ID); + } + + /// A stage that fails, or whose plug-in is missing, is deferred with that reason, from a run or + /// from the cache; a failure is cached and reported as before. + #[test] + fn failed_and_missing_stages_are_deferred_with_their_reason() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.deferfailed"; + // Runs to its instruction budget. + let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + let view = crate::deferred::Deferrals::with_clock(stepping(1)); + assert!(!view.view(|| drawn(ID, &line(2), 0))); + assert!(last_error(ID).is_some()); + assert!(!view.view(|| drawn("test.effect.notinstalled", &line(2), 1))); + let reasons = |v: &crate::deferred::Deferrals| v.list().iter().map(|d| d.reason).collect::>(); + assert_eq!(reasons(&view), [Reason::Failed, Reason::Missing]); + let other = crate::deferred::Deferrals::with_clock(stepping(1)); + assert!(!other.view(|| drawn(ID, &line(2), 0))); + assert_eq!(reasons(&other), [Reason::Failed], "from the cache, in another document"); + // A refresh runs a stage whose failure is cached again, once, and later drawings take the + // failure from the cache. + other.first_view_done(); + let runs = || RUNS_STARTED.with(std::cell::Cell::get); + let before = runs(); + let counts = other.refresh(None, |_| { + other.view(|| (drawn(ID, &line(2), 0), drawn(ID, &line(2), 0))); + vec![] + }); + assert_eq!((counts.failed, runs() - before), (1, 1)); + assert!(!other.view(|| drawn(ID, &line(2), 0))); + assert_eq!((reasons(&other), runs() - before), (vec![Reason::Failed], 1)); + registry::remove(ID); + } + + /// A run the budget cuts short is deferred for want of time: it is not cached, and the plug-in + /// reports no error. + #[test] + fn a_run_the_budget_cuts_short_is_deferred_for_time() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.cutshort"; + const FAST: &str = "test.effect.cutshort.fast"; + let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 10_000_000, r#"{"objects":[]}"#); + let limits = Limits { fuel_base: 1_000_000_000, ..Limits::default() }; + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap()); + let fast = crate::wat::returning(&crate::wat::manifest(FAST, "effect", "{}"), r#"{"objects":[]}"#); + registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap()); + // The first run starts the budget at 0 ms and ends at 1,999: the next has 1 ms left, and + // ten million steps take longer. + let reads = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let clock: crate::deferred::Clock = + Arc::new(move || Some(if reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0 { 0 } else { 1_999 })); + let view = crate::deferred::Deferrals::with_clock(clock); + assert!(view.view(|| drawn(FAST, &line(2), 0))); + assert!(!view.view(|| drawn(ID, &line(2), 1))); + assert_eq!(view.list().iter().map(|d| (d.stage.effect, d.reason)).collect::>(), [(1, Reason::Budget)]); + assert_eq!(last_error(ID), None); + assert!(drawn(ID, &line(2), 1), "not cached: outside the view it runs to the end"); + registry::remove(ID); + registry::remove(FAST); + } + /// A first run that is cancelled leaves the next run to be the first: the launch marker names /// the plug-in during that one. #[test] diff --git a/crates/plugins/src/export.rs b/crates/plugins/src/export.rs index b51fcfdb9..76b1316b7 100644 --- a/crates/plugins/src/export.rs +++ b/crates/plugins/src/export.rs @@ -24,8 +24,7 @@ use crate::{Error, Kind, Limits, Plugin, Result, registry}; /// document: 200 times a filter's base budget. pub const FUEL: u64 = 10_000_000_000; -/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock, -/// and the instruction budget alone bounds it there). +/// Wall-clock budget of one export, in milliseconds. pub const WALL_TIME_MS: u64 = 300_000; /// Most warnings an export reports. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index f8235a4fb..b98407cb7 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -1,9 +1,10 @@ //! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on: //! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit //! asked it to stop, and `vc_log` writes a log line (the `progress` permission); `vc_settings_*` -//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]). -//! A module may import one only when its manifest gives `api` 2 and lists the import's permission, -//! and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]): +//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]); +//! `vc_call` runs a command its manifest's `commandAllowlist` names and `vc_call_result` reads that +//! command's result (`commands`, through the [`Bridge`] the run is given). A module may import one +//! only when its manifest gives `api` 2 and lists the import's permission, and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]): //! one it doesn't offer, one with another signature and one the manifest doesn't allow are refused //! by name. //! @@ -13,8 +14,9 @@ //! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], //! [`MAX_LOG_READ`]) are capped. Keys and values must lie inside the module's memory, keys must be //! [`crate::storage::valid_name`]s, values are capped, and so are each run's storage calls -//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Nothing a module passes -//! can panic the host. +//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Command calls are capped +//! per call ([`MAX_CALL_DATA`]) and per run ([`MAX_CALLS`], [`MAX_CALL_BYTES`]). Nothing a module +//! passes can panic the host. use std::sync::Arc; @@ -95,6 +97,72 @@ pub const MAX_STORAGE_CALLS: usize = 10_000; /// pass it returns [`STORAGE_BUDGET`]. pub const MAX_STORAGE_BYTES: usize = 1 << 30; +/// What `vc_call` returns: the command ran, and `vc_call_result` reads its result. +pub const CALL_OK: i32 = 0; +/// What `vc_call` or `vc_call_result` returns: the id, the arguments or the buffer doesn't lie +/// inside the module's memory. +pub const CALL_OUTSIDE_MEMORY: i32 = -1; +/// What `vc_call` returns: the id or the arguments are not UTF-8. +pub const CALL_BAD_UTF8: i32 = -2; +/// What `vc_call` returns: the arguments are not a JSON object. +pub const CALL_BAD_JSON: i32 = -3; +/// What `vc_call` returns: the manifest's `commandAllowlist` doesn't list the command. +pub const CALL_NOT_ALLOWED: i32 = -4; +/// What `vc_call` returns: no command has the id. +pub const CALL_UNKNOWN_COMMAND: i32 = -5; +/// What `vc_call` returns: a plug-in may not call the command, whatever its manifest lists. +pub const CALL_DENIED_COMMAND: i32 = -6; +/// What `vc_call` returns: the command can't run now (no document, nothing selected). +pub const CALL_DISABLED: i32 = -7; +/// What `vc_call` returns: the command ran and failed. +pub const CALL_COMMAND_FAILED: i32 = -8; +/// What `vc_call` returns: the arguments or the result are larger than [`MAX_CALL_DATA`], or the +/// run made [`MAX_CALLS`] calls or moved [`MAX_CALL_BYTES`] with them. +pub const CALL_LIMIT: i32 = -9; +/// What `vc_call_result` returns: no call has a result yet. +pub const CALL_NO_RESULT: i32 = -10; +/// What `vc_call` returns: nothing can be called now (while the module is being loaded, in a run +/// that isn't a filter's run on a document, or after a call in the run failed). In a run, this +/// fails it too. +pub const CALL_NO_CONTEXT: i32 = -11; + +/// Most `vc_call` calls one run makes. +pub const MAX_CALLS: usize = 1_000; + +/// Largest arguments or result of one `vc_call`, in bytes. +pub const MAX_CALL_DATA: usize = 8 << 20; + +/// Most bytes of arguments and results one run's `vc_call` calls move, all together. +pub const MAX_CALL_BYTES: usize = 64 << 20; + +/// Instructions each `vc_call` costs the run, besides one for every 64 bytes of its arguments and +/// result: [`MAX_CALLS`] calls cost a fifth of a filter's usual budget. +pub const CALL_FUEL: u64 = 10_000; + +/// Why a command a plug-in called with `vc_call` didn't run, or failed: the code `vc_call` returns +/// and the reason, which the run's error gives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CallFailure { + pub code: i32, + pub why: String, +} + +/// What runs the commands a plug-in calls: a command id and its arguments (a JSON object) → the +/// command's result, or why it didn't run. +pub type Bridge<'a> = dyn FnMut(&str, &serde_json::Value) -> std::result::Result + 'a; + +/// What `vc_call` pauses the run with, for the host to make the call ([`Run::pending`]). +#[derive(Debug)] +pub(crate) struct CallPending; + +impl std::fmt::Display for CallPending { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("vc_call") + } +} + +impl wasmi::errors::HostError for CallPending {} + /// One host import: its name, its type and the permission it needs. pub struct Import { pub name: &'static str, @@ -134,7 +202,17 @@ const fn storage_import(name: &'static str, params: &'static [ValType], signatur /// A storage call that fails returns [`STORAGE_NOT_FOUND`], [`STORAGE_OUTSIDE_MEMORY`], /// [`STORAGE_BAD_NAME`], [`STORAGE_FULL`], [`STORAGE_UNAVAILABLE`], [`STORAGE_FAILED`] or /// [`STORAGE_BUDGET`]. -pub const IMPORTS: [Import; 9] = [ +/// +/// With `commands`, for a filter whose manifest's `commandAllowlist` names the command: +/// +/// - `vc_call(id, id_len, args, args_len) -> i32`: runs the command with the UTF-8 id of `id_len` +/// bytes at `id` and the arguments at `args` (a UTF-8 JSON object; no bytes: `{}`) on the document +/// the run is for → [`CALL_OK`] or why it didn't run or failed. A failed call fails the run, and +/// nothing the run did stays; +/// - `vc_call_result(out, out_cap) -> i32`: the length of the last successful call's result (JSON), +/// copied to `out` when it fits in `out_cap` (nothing is copied otherwise), or +/// [`CALL_NO_RESULT`]. +pub const IMPORTS: [Import; 11] = [ Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], @@ -156,6 +234,8 @@ pub const IMPORTS: [Import; 9] = [ storage_import("vc_cache_read", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), storage_import("vc_cache_write", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), storage_import("vc_cache_clear", &[I32, I32], "(param i32 i32) (result i32)", Permission::Cache), + storage_import("vc_call", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Commands), + storage_import("vc_call_result", &[I32, I32], "(param i32 i32) (result i32)", Permission::Commands), ]; /// Which of a plug-in's stores a storage import reads or writes. @@ -238,33 +318,27 @@ pub struct Report { pub truncated: bool, } -/// When a run must stop (native: a wall-clock deadline). +/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]). +pub(crate) const TIME_BUDGET: &str = "time budget"; + +/// When a run must stop: a deadline on the monotonic clock (`web_time::Instant`, the page's +/// `performance.now()` in the web build). #[derive(Clone, Copy)] pub(crate) struct Deadline { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant, + at: web_time::Instant, } impl Deadline { pub(crate) fn new(wall_time_ms: u64) -> Self { - #[cfg(target_arch = "wasm32")] - let _ = wall_time_ms; - Self { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms), - } + Self { at: web_time::Instant::now() + std::time::Duration::from_millis(wall_time_ms) } } pub(crate) fn passed(&self) -> bool { - #[cfg(not(target_arch = "wasm32"))] - if std::time::Instant::now() >= self.at { - return true; - } - false + web_time::Instant::now() >= self.at } pub(crate) fn check(&self) -> Result<()> { - if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) } + if self.passed() { Err(Error::Limit(TIME_BUDGET.into())) } else { Ok(()) } } } @@ -291,6 +365,18 @@ pub(crate) struct Run { storage: Option>, storage_calls: usize, storage_bytes: usize, + /// The commands `vc_call` may run (the manifest's `commandAllowlist`), once the module has + /// loaded and when the run has something to run them ([`Run::allow_calls`]); `None` otherwise. + allowed: Option>, + /// How many `vc_call` calls the run made, and the bytes of arguments and results they moved. + calls: usize, + call_bytes: usize, + /// The call `vc_call` paused the run for: its command id, its arguments and their size in bytes. + pub(crate) pending: Option<(String, serde_json::Value, usize)>, + /// The last successful call's result, as JSON text. + pub(crate) call_result: Option>, + /// The run's first failed call, which fails the run. + pub(crate) call_failure: Option, pub(crate) report: Report, } @@ -309,6 +395,12 @@ impl Run { storage: None, storage_calls: 0, storage_bytes: 0, + allowed: None, + calls: 0, + call_bytes: 0, + pending: None, + call_result: None, + call_failure: None, report: Report::default(), } } @@ -319,6 +411,119 @@ impl Run { self } + /// From now on, `vc_call` runs the commands in `allowed`. + pub(crate) fn allow_calls(&mut self, allowed: Vec) { + self.allowed = Some(allowed); + } + + /// Records the run's first failed call, `e`, and returns `code`. + fn fail(&mut self, code: i32, e: Error) -> i32 { + self.call_failure.get_or_insert(e); + code + } + + /// `vc_call`: checks the call to the command at `id` with the arguments at `args` in `memory` + /// and leaves it in [`Run::pending`] for the host to make, or → the code for why it can't be + /// made, which fails the run. The size of the arguments is checked before they are read. After + /// the run's first failed call, every call returns [`CALL_NO_CONTEXT`] and none is made. + fn call_request(&mut self, memory: &[u8], id: (usize, usize), args: (usize, usize)) -> std::result::Result<(), i32> { + self.call_result = None; + // No call is made after the run's first failed call. + if self.call_failure.is_some() { + return Err(CALL_NO_CONTEXT); + } + if self.allowed.is_none() { + let why = "vc_call: no command can be called here (while the module loads, or in a run that can't make calls)"; + return Err(self.fail(CALL_NO_CONTEXT, Error::Abi(why.into()))); + } + let shown = || { + id.0.checked_add(id.1) + .filter(|_| id.1 <= crate::manifest::MAX_ALLOWED_ID) + .and_then(|end| memory.get(id.0..end)) + .map_or_else(|| "?".to_string(), |b| permissions::display_text(&String::from_utf8_lossy(b), permissions::MAX_SHOWN_FIELD)) + }; + if args.1 > MAX_CALL_DATA { + let (shown, len) = (shown(), args.1); + return Err(self.fail(CALL_LIMIT, Error::Limit(format!("command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA})")))); + } + if self.calls >= MAX_CALLS || self.call_bytes.saturating_add(args.1) > MAX_CALL_BYTES { + return Err(self.fail(CALL_LIMIT, Error::Limit(format!("command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run)")))); + } + self.calls += 1; + self.call_bytes += args.1; + let slice = |(at, len): (usize, usize)| at.checked_add(len).and_then(|end| memory.get(at..end)); + let (Some(id_bytes), Some(arg_bytes)) = (slice(id).filter(|_| id.1 <= crate::manifest::MAX_ALLOWED_ID), slice(args)) else { + if id.1 > crate::manifest::MAX_ALLOWED_ID { + let shown = shown(); + return Err(self.fail(CALL_NOT_ALLOWED, Error::Abi(format!("vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it")))); + } + let shown = shown(); + return Err( + self.fail(CALL_OUTSIDE_MEMORY, Error::Abi(format!("vc_call `{shown}`: the id or the arguments lie outside the module's memory"))) + ); + }; + let (Ok(name), Ok(text)) = (std::str::from_utf8(id_bytes), std::str::from_utf8(arg_bytes)) else { + let shown = shown(); + return Err(self.fail(CALL_BAD_UTF8, Error::Abi(format!("vc_call `{shown}`: the id or the arguments are not UTF-8")))); + }; + if !self.allowed.as_ref().is_some_and(|allowed| allowed.iter().any(|a| a == name)) { + let shown = shown(); + return Err(self.fail(CALL_NOT_ALLOWED, Error::Abi(format!("vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it")))); + } + let parsed = if text.is_empty() { Ok(serde_json::Value::Object(serde_json::Map::new())) } else { serde_json::from_str(text) }; + match parsed { + Ok(v @ serde_json::Value::Object(_)) => { + self.pending = Some((name.to_string(), v, args.1)); + Ok(()) + } + _ => { + let shown = shown(); + Err(self.fail(CALL_BAD_JSON, Error::Abi(format!("vc_call `{shown}`: the arguments are not a JSON object")))) + } + } + } + + /// Takes the host's answer to the call in [`Run::pending`], to command `id`: keeps its result for + /// `vc_call_result`, or records the failure → what `vc_call` returns. + pub(crate) fn call_answered(&mut self, id: &str, answer: std::result::Result) -> i32 { + let shown = permissions::display_text(id, permissions::MAX_SHOWN_FIELD); + let value = match answer { + Ok(value) => value, + Err(f) => { + let why = f.why; + return self.fail(f.code, Error::Failed(format!("vc_call `{shown}`: {why}"))); + } + }; + let text = serde_json::to_vec(&value).unwrap_or_default(); + let len = text.len(); + if len > MAX_CALL_DATA { + return self.fail(CALL_LIMIT, Error::Limit(format!("command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA})"))); + } + if self.call_bytes.saturating_add(len) > MAX_CALL_BYTES { + return self.fail(CALL_LIMIT, Error::Limit(format!("command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run)"))); + } + self.call_bytes += len; + self.call_result = Some(text); + CALL_OK + } + + /// `vc_call_result`: copies the last successful call's result into the `out_cap` bytes at `out` + /// when it fits → its length, or [`CALL_NO_RESULT`] or [`CALL_OUTSIDE_MEMORY`]. Neither fails + /// the run: a plug-in asks again with a larger buffer. + fn call_result(&self, memory: &mut [u8], out: (usize, usize)) -> i32 { + let Some(result) = &self.call_result else { return CALL_NO_RESULT }; + if out.1 > 0 && out.0.checked_add(out.1).is_none_or(|end| end > memory.len()) { + return CALL_OUTSIDE_MEMORY; + } + if result.len() <= out.1 + && let Some(dest) = memory.get_mut(out.0..out.0 + result.len()) + { + dest.copy_from_slice(result); + } + // At most MAX_CALL_DATA, which an i32 holds. + i32::try_from(result.len()).unwrap_or(CALL_LIMIT) + } + /// Makes one storage call with `call`, on the plug-in's storage and a [`Meter`] holding the /// run's storage bytes left, and counts the call and the bytes it read and wrote → the code for /// a run that has used up its calls or has no storage, else what `call` returns. @@ -534,6 +739,25 @@ pub(crate) fn linker(engine: &Engine, granted: &[Permission]) -> Linker { host.run.storage_remove(area, data, (offset(key), offset(key_len))) }); } + if granted.contains(&Permission::Commands) { + // A call that can be made pauses the run: the host makes it with what the run was given to + // make calls with ([`crate::Plugin::run_calling`]), and resumes the run with the code. + let _ = linker.func_wrap( + MODULE, + "vc_call", + |mut caller: Caller<'_, Host>, id: i32, id_len: i32, args: i32, args_len: i32| -> std::result::Result { + let Some((data, host)) = memory_and_host(&mut caller) else { return Ok(CALL_OUTSIDE_MEMORY) }; + match host.run.call_request(data, (offset(id), offset(id_len)), (offset(args), offset(args_len))) { + Ok(()) => Err(wasmi::Error::host(CallPending)), + Err(code) => Ok(code), + } + }, + ); + let _ = linker.func_wrap(MODULE, "vc_call_result", |mut caller: Caller<'_, Host>, out: i32, out_cap: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return CALL_OUTSIDE_MEMORY }; + host.run.call_result(data, (offset(out), offset(out_cap))) + }); + } linker } @@ -583,6 +807,15 @@ mod tests { Run::new("test", true, false, Deadline::new(60_000)) } + /// A deadline reads the monotonic clock that the web build also reads: one of 0 ms has + /// passed, and one a day away has not. + #[test] + fn a_deadline_passes_on_the_monotonic_clock() { + assert!(Deadline::new(0).passed()); + assert!(matches!(Deadline::new(0).check(), Err(Error::Limit(l)) if l == TIME_BUDGET)); + assert!(!Deadline::new(86_400_000).passed()); + } + /// Progress is clamped: negative counts are 0 and `done` never passes a known total. #[test] fn progress_is_clamped() { diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index e3f1f76de..c71200187 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -5,10 +5,11 @@ //! //! - **no host imports but those of API level 2** ([`host`]: progress, Cancel and log lines with //! the `progress` permission, and settings and a cache of its own with the `settings` and `cache` -//! permissions, [`storage`]): no WASI, file system, network, clock or randomness. A module that +//! permissions, [`storage`], and the commands its `commandAllowlist` names with the `commands` +//! permission): no WASI, file system, network, clock or randomness. A module that //! imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is -//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a +//! also enforced, so infinite loops end with an error. A filter may ask for a //! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; //! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; //! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only @@ -33,6 +34,7 @@ //! runs through `vc_run` with the command's id in `_context`. #![forbid(unsafe_code)] +pub mod deferred; pub mod dialog; pub mod effect; pub mod export; @@ -52,6 +54,8 @@ pub use dialog::ParamUi; pub use host::{LogLevel, LogLine, Report}; pub use manifest::{Budget, Command, InputKind, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; +#[doc(hidden)] +pub use runtime::{BYTES_COPIED_PER_FUEL, FUEL_SLICE}; /// What the host puts before a plug-in's id to name what it adds: its live effect /// (`plugin.`, [`effect`]) or its file format ([`export`], [`import`]). diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index d548e6444..a16bd8a4f 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -25,6 +25,12 @@ pub const MAX_COMMANDS: usize = 32; /// Longest command id, in characters. pub const MAX_COMMAND_ID: usize = 32; +/// Most commands a manifest's `commandAllowlist` names. +pub const MAX_ALLOWED_COMMANDS: usize = 256; + +/// Longest command id a manifest's `commandAllowlist` names, in bytes. +pub const MAX_ALLOWED_ID: usize = 128; + /// What a plug-in is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -337,6 +343,11 @@ pub struct Manifest { /// The menu commands it adds, in menu order (a filter or a live effect only). #[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")] pub commands: Vec, + /// The VectorCraft commands a run may call with `vc_call` (exact command ids, such as + /// `object.transform`): only a filter with the usual budget whose `permissions` list `commands` + /// lists any ([`Manifest::validate`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_allowlist: Vec, } impl Manifest { @@ -371,6 +382,7 @@ impl Manifest { budget: Budget::Light, paint_output: false, commands: Vec::new(), + command_allowlist: Vec::new(), } } @@ -427,7 +439,40 @@ impl Manifest { } } self.validate_params()?; - self.validate_commands() + self.validate_commands()?; + self.validate_allowlist() + } + + /// `commandAllowlist` lists at most [`MAX_ALLOWED_COMMANDS`] distinct command ids + /// ([`valid_allowed_id`]), and only a filter with the usual budget lists any: a heavy one runs in + /// the background, away from the document its commands would change. A list needs the + /// permission `commands`, and `commands` needs a list. + fn validate_allowlist(&self) -> Result<()> { + let listed = self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::Commands.name())); + if self.command_allowlist.is_empty() == listed { + return Err(Error::Manifest("`commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist`".into())); + } + if self.command_allowlist.is_empty() { + return Ok(()); + } + if self.kind != Kind::Filter || self.budget == Budget::Heavy { + return Err(Error::Manifest("only a filter with the usual budget declares `commandAllowlist`".into())); + } + if self.command_allowlist.len() > MAX_ALLOWED_COMMANDS { + return Err(Error::Manifest(format!("at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist`"))); + } + for (i, id) in self.command_allowlist.iter().enumerate() { + if !valid_allowed_id(id) || self.command_allowlist.get(..i).is_some_and(|before| before.contains(id)) { + let shown = permissions::display_text(id, permissions::MAX_SHOWN_FIELD); + return Err(Error::Manifest(format!("`commandAllowlist` names `{shown}`, which is not a command id or is listed twice"))); + } + } + Ok(()) + } + + /// May a run call command `id` ([`Manifest::command_allowlist`])? + pub fn allows_call(&self, id: &str) -> bool { + self.command_allowlist.iter().any(|a| a == id) } /// Checks each parameter and its dialog layout. @@ -810,6 +855,15 @@ fn params_as_object(p: &[(String, Param)], s: S) -> std::r m.end() } +/// A command id as a `commandAllowlist` names it: 1 to [`MAX_ALLOWED_ID`] bytes from +/// `A-Za-z0-9._-`, starting with a letter, with no empty part between dots. +pub fn valid_allowed_id(id: &str) -> bool { + (1..=MAX_ALLOWED_ID).contains(&id.len()) + && id.bytes().next().is_some_and(|b| b.is_ascii_alphabetic()) + && id.split('.').all(|part| !part.is_empty()) + && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + /// `[A-Za-z0-9._-]{1,64}`. pub fn valid_id(id: &str) -> bool { (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index d0a461962..2238ab41e 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1195,7 +1195,10 @@ impl Plan { if self.top.as_ref().is_some_and(|(p, _)| *p == parent) && let Some((_, nodes)) = self.top.take() { - out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new)); + // On a layer, new objects go below its ruler guides, which stay on top. + let o = out.get_or_insert_with(|| list.to_vec()); + let at = Node::guide_run_start(o); + o.splice(at..at, nodes.into_iter().map(Arc::new)); } out } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index ba4cccae2..c04ffe6a0 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -164,6 +164,21 @@ fn compound_guides_are_not_filter_targets() { assert!(targets.contains(&made[1]), "the plain compound path"); } +/// New objects put on top of a layer go below its ruler guides, which stay on top; a ruler guide +/// is never a filter target. +#[test] +fn new_objects_go_below_a_layers_ruler_guides() { + let (mut d, _, layer) = doc(); + let guide = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let gid = d.insert(Some(layer), usize::MAX, guide).unwrap(); + assert!(!filter_targets(&d, &[layer]).contains(&gid)); + let out = json!({"objects": [{"name": "N", "type": "path", "path": square(0.0)}]}); + let made = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap(); + let ids: Vec = d.children(Some(layer)).unwrap().iter().map(|n| n.id).collect(); + assert_eq!(ids.last(), Some(&gid)); + assert_eq!(ids.get(ids.len() - 2), made.first()); +} + /// `guide` and `locked` make objects guides (a compound path's members too) and lock them, or undo /// both; locked objects and guides are not [`selectable`]. Other values are refused. #[test] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 778b19cee..7380edd56 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -5,9 +5,9 @@ //! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in //! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without //! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of -//! that kind could do before manifests listed them. `progress`, `settings` and `cache`, which let a -//! plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a manifest -//! lists them. +//! that kind could do before manifests listed them. `progress`, `settings`, `cache` and `commands`, +//! which let a plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a +//! manifest lists them. use serde::{Deserialize, Serialize}; @@ -19,7 +19,7 @@ use crate::{Error, Kind, Result}; pub const API_LEVEL: u32 = 2; /// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`, -/// `settings` and `cache` permissions they need. +/// `settings`, `cache` and `commands` permissions they need. pub const PROGRESS_API: u32 = 2; /// The API level that adds a filter's `inputs` ([`crate::InputKind`]). @@ -70,10 +70,14 @@ pub enum Permission { /// Keep a cache of its own, which the user can clear ([`crate::storage`]). #[serde(rename = "cache")] Cache, + /// Run the VectorCraft commands its manifest's `commandAllowlist` names, with `vc_call` + /// ([`crate::host::IMPORTS`]): a filter only. + #[serde(rename = "commands")] + Commands, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 9] = [ +pub const ALL: [Permission; 10] = [ Permission::DocRead, Permission::DocWrite, Permission::State, @@ -83,11 +87,13 @@ pub const ALL: [Permission; 9] = [ Permission::Progress, Permission::Settings, Permission::Cache, + Permission::Commands, ]; /// The permissions of the host imports, which any kind may list and none implies; each needs plug-in -/// API level [`PROGRESS_API`]. -pub const HOST: [Permission; 3] = [Permission::Progress, Permission::Settings, Permission::Cache]; +/// API level [`PROGRESS_API`]. Only a filter with a `commandAllowlist` may list `commands` +/// ([`crate::manifest::Manifest::command_allowlist`]). +pub const HOST: [Permission; 4] = [Permission::Progress, Permission::Settings, Permission::Cache, Permission::Commands]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -102,6 +108,7 @@ impl Permission { Permission::Progress => "progress", Permission::Settings => "settings", Permission::Cache => "cache", + Permission::Commands => "commands", } } @@ -122,6 +129,7 @@ impl Permission { Permission::Progress => "Show its progress, see Cancel and write log lines", Permission::Settings => "Keep settings of its own", Permission::Cache => "Keep a cache of its own, which you can clear", + Permission::Commands => "Run these commands", } } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index e6278fc99..5a2db2ff0 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -1,11 +1,11 @@ //! The wasmi host: loading, instantiating and calling a plug-in under resource limits. use wasmi::{ - Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams, - WasmResults, + Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, Val, + WasmParams, WasmResults, }; -use crate::host::{self, Deadline, Host, Report, Run}; +use crate::host::{self, Bridge, Deadline, Host, Report, Run}; use crate::manifest::{MAX_MANIFEST_BYTES, Manifest}; use crate::permissions::Permission; use crate::{Error, Kind, Result}; @@ -14,7 +14,7 @@ use crate::{Error, Kind, Result}; pub const ABI_VERSION: i32 = 1; /// Fuel handed out per slice; between slices the host checks the deadline. -const FUEL_SLICE: u64 = 20_000_000; +pub const FUEL_SLICE: u64 = 20_000_000; /// wasmi's fuel costs, but compiling costs nothing. wasmi compiles a function when it is first /// called, and charges the run that calls it by the function's size. When the slice a run was in @@ -24,12 +24,15 @@ const FUEL_SLICE: u64 = 20_000_000; /// the next. Copying keeps wasmi's default cost, a unit for every 64 bytes. const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64, fuel_per_bytes_translated: 0, fuel_per_bytes_validated: 0 }; +/// Bytes a bulk memory instruction copies or fills for each unit of fuel. +pub const BYTES_COPIED_PER_FUEL: u32 = FUEL_COSTS.bytes_copied_per_fuel; + /// Instructions a heavy filter's run may use besides [`Limits::fuel_per_byte`] for each byte of /// input ([`crate::Budget::Heavy`]): 20 times a light one's. Its output's size can't add to it: /// the host learns that only once the run has ended. pub const HEAVY_FUEL: u64 = 1_000_000_000; -/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds). +/// Wall-clock budget of a heavy filter's run, in milliseconds. pub const HEAVY_WALL_TIME_MS: u64 = 300_000; /// The limit a run reports when its input and parameters together are past half its memory. @@ -56,8 +59,8 @@ pub struct Limits { pub fuel_per_byte: u64, /// Deepest call nesting. pub max_recursion_depth: usize, - /// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no - /// monotonic clock in `std`, so the fuel budget alone bounds it there. + /// Wall-clock budget for one run, in milliseconds. The web build measures it with the page's + /// `performance.now()`. pub wall_time_ms: u64, /// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and /// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never @@ -132,6 +135,18 @@ impl Live { } } +/// Takes `cost` instructions from a run: from the budget still to hand out (`left`), then from what +/// the store holds. A run that can't pay ends with its instruction budget. +fn charge(live: &mut Live, left: &mut u64, cost: u64) -> Result<()> { + let held = live.store.get_fuel().map_err(wasm_err)?; + if held.saturating_add(*left) < cost { + return Err(Error::Limit("instruction budget".into())); + } + let from_left = cost.min(*left); + *left -= from_left; + live.store.set_fuel(held - (cost - from_left)).map_err(wasm_err) +} + fn wasm_err(e: wasmi::Error) -> Error { if e.as_trap_code() == Some(wasmi::TrapCode::OutOfFuel) { Error::Limit("instruction budget".into()) } else { Error::Trap(e.to_string()) } } @@ -257,6 +272,22 @@ impl Plugin { /// slice size, keeping that fuel, and a run ends with the instruction budget only when the /// store's fuel and the rest of the budget together can't pay for the next instruction. fn call(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { + self.call_with(live, f, params, fuel, deadline, None) + } + + /// [`Plugin::call`], making each command call a `vc_call` pauses it for with `bridge` + /// ([`host::Run::pending`]) and resuming it with the code. Each call costs [`host::CALL_FUEL`] + /// instructions and one for every 64 bytes of its arguments and result; the deadline and the + /// caller's cancellation are checked before and after it. + fn call_with( + &self, + live: &mut Live, + f: &TypedFunc, + params: P, + fuel: u64, + deadline: &Deadline, + mut bridge: Option<&mut Bridge<'_>>, + ) -> Result { let mut left = fuel; let give = left.min(FUEL_SLICE); left -= give; @@ -265,7 +296,22 @@ impl Plugin { loop { match call { TypedResumableCall::Finished(r) => return Ok(r), - TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())), + TypedResumableCall::HostTrap(inv) => { + let pending = live.store.data_mut().run.pending.take(); + let (Some((id, args, size)), Some(bridge)) = (pending, bridge.as_deref_mut()) else { + return Err(Error::Trap("unexpected host trap".into())); + }; + charge(live, &mut left, host::CALL_FUEL.saturating_add(size as u64 / 64))?; + deadline.check()?; + vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; + let answer = bridge(&id, &args); + let code = live.store.data_mut().run.call_answered(&id, answer); + let returned = live.store.data().run.call_result.as_ref().map_or(0, Vec::len); + charge(live, &mut left, returned as u64 / 64)?; + deadline.check()?; + vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; + call = inv.resume(&mut live.store, &[Val::I32(code)]).map_err(wasm_err)?; + } TypedResumableCall::OutOfFuel(inv) => { let need = inv.required_fuel(); let held = live.store.get_fuel().map_err(wasm_err)?; @@ -302,30 +348,61 @@ impl Plugin { /// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's /// doesn't) and `vc_cancelled` sees the scope's Cancel. pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result>, Report) { + self.run_guarded(input, params, limits, None) + } + + /// [`Plugin::run_reporting`] for a filter whose manifest's `commandAllowlist` names commands: + /// each `vc_call` of one of them is made with `bridge`, on the caller's thread, while the run + /// waits. A failed call fails the run, also when the plug-in goes on and returns an output; the + /// error names the command. + pub fn run_calling(&self, input: &[u8], params: &[u8], limits: &Limits, bridge: &mut Bridge<'_>) -> (Result>, Report) { + self.run_guarded(input, params, limits, Some(bridge)) + } + + fn run_guarded(&self, input: &[u8], params: &[u8], limits: &Limits, bridge: Option<&mut Bridge<'_>>) -> (Result>, Report) { let mut report = Report::default(); if let Err(e) = crate::safe_mode::check() { return (Err(e), report); } // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must // still not take the document down (native builds; wasm aborts on panic anyway). - let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report))); + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report, bridge))); (run.map_err(|_| Error::Trap("the runtime panicked".into())).and_then(|r| r), report) } - fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result> { + fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report, bridge: Option<&mut Bridge<'_>>) -> Result> { let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); total.ok_or_else(|| Error::Limit(SELECTION_TOO_LARGE.into()))?; let deadline = Deadline::new(limits.wall_time_ms); let m = &self.manifest; let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); let mut live = self.instantiate(run, &m.permissions(), report)?; - let out = self.run_live(&mut live, input, params, limits, &deadline); - *report = std::mem::take(&mut live.store.data_mut().run.report); - out + // Calls can be made from `vc_run` on, not from the start function. + if bridge.is_some() { + live.store.data_mut().run.allow_calls(m.command_allowlist.clone()); + } + let out = self.run_live(&mut live, input, params, limits, &deadline, bridge); + let state = &mut live.store.data_mut().run; + *report = std::mem::take(&mut state.report); + // A failed call fails the run, whatever the plug-in did after it, unless the run was + // canceled. + match (state.call_failure.take(), out) { + (_, Err(Error::Cancelled)) => Err(Error::Cancelled), + (Some(e), _) => Err(e), + (None, out) => out, + } } /// Hands `input` and `params` to the instance `live` and runs `vc_run` → the output JSON bytes. - fn run_live(&self, live: &mut Live, input: &[u8], params: &[u8], limits: &Limits, deadline: &Deadline) -> Result> { + fn run_live( + &self, + live: &mut Live, + input: &[u8], + params: &[u8], + limits: &Limits, + deadline: &Deadline, + bridge: Option<&mut Bridge<'_>>, + ) -> Result> { let alloc: TypedFunc = live.func("vc_alloc")?; let run: TypedFunc = live.func("vc_run")?; let place = |live: &mut Live, bytes: &[u8]| -> Result { @@ -341,7 +418,7 @@ impl Plugin { let iptr = place(live, input)?; let pptr = place(live, params)?; let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte)); - let packed = self.call(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline)?; + let packed = self.call_with(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline, bridge)?; // A cancelled run, or one past its time budget, changes nothing, also when the plug-in saw // `vc_cancelled` and returned. vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; diff --git a/crates/plugins/tests/call.rs b/crates/plugins/tests/call.rs new file mode 100644 index 000000000..2dd2c7c5e --- /dev/null +++ b/crates/plugins/tests/call.rs @@ -0,0 +1,382 @@ +//! `vc_call` and `vc_call_result`: a filter of API level 2 that lists `commands` calls the commands +//! its manifest's `commandAllowlist` names, through what the host gives the run to make them with. +//! Every misuse is a code the plug-in sees, and fails the run. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use vectorcraft_plugins::host::{ + CALL_BAD_JSON, CALL_BAD_UTF8, CALL_COMMAND_FAILED, CALL_LIMIT, CALL_NO_CONTEXT, CALL_NO_RESULT, CALL_NOT_ALLOWED, CALL_OUTSIDE_MEMORY, + CallFailure, MAX_CALL_DATA, MAX_CALLS, +}; +use vectorcraft_plugins::{Error, Limits, Manifest, Plugin, wat as tpl}; + +const INPUT: &str = r#"{"objects":[]}"#; + +/// The command imports, as `$vc_call` and `$vc_call_result`, and the strings the tests pass: the id +/// "select.all" at 0x2000, "object.delete" at 0x2010, the arguments `{"a":1}` at 0x2100, the byte +/// 0xff at 0x2200 and `[1]` at 0x2300. 0x3000 is free for a result. +const CALLS: &str = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_call_result" (func $vc_call_result (param i32 i32) (result i32))) + (data (i32.const 0x2000) "select.all") + (data (i32.const 0x2010) "object.delete") + (data (i32.const 0x2100) "{\22a\22:1}") + (data (i32.const 0x2200) "\ff") + (data (i32.const 0x2300) "[1]")"#; + +/// A filter `id` that may call `select.all`. +fn manifest(id: &str) -> String { + format!( + r#"{{"id":"{id}","name":"Caller","kind":"filter","api":2,"permissions":["doc.read","doc.write","commands"],"commandAllowlist":["select.all"]}}"# + ) +} + +/// `vc_call` of the id at `id` (`len` bytes) with the arguments at `args` (`args_len` bytes). +fn call(id: &str, len: &str, args: &str, args_len: &str) -> String { + format!("(call $vc_call (i32.const {id}) (i32.const {len}) (i32.const {args}) (i32.const {args_len}))") +} + +/// `select.all` with `{"a":1}`. +fn select_all() -> String { + call("0x2000", "10", "0x2100", "7") +} + +/// Instructions that trap unless `expr` is `want`. +fn expect(expr: &str, want: i32) -> String { + format!("(if (i32.ne {expr} (i32.const {want})) (then unreachable))") +} + +/// A filter that runs `first`, then returns the input. +fn calling(id: &str, first: &str) -> Plugin { + let src = tpl::with_imports(&manifest(id), CALLS, first, INPUT); + Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap() +} + +/// Runs `p` with a bridge that answers every call with `answer` → the run's result and the calls it +/// got. +fn run_with(p: &Plugin, answer: impl Fn(&str) -> Result) -> (vectorcraft_plugins::Result>, Vec<(String, Value)>) { + let mut seen = Vec::new(); + let mut bridge = |id: &str, args: &Value| { + seen.push((id.to_string(), args.clone())); + answer(id) + }; + let (out, _) = p.run_calling(INPUT.as_bytes(), b"{}", p.limits(), &mut bridge); + (out, seen) +} + +#[test] +fn a_call_runs_its_command_and_the_result_is_read_back() { + // The result `{"n":1}` is 7 bytes: a 2-byte buffer gets its length and nothing copied, then a + // larger one gets it, which the run returns as its output. + let run = format!( + "{} {} (if (i32.ne (i32.load8_u (i32.const 0x3000)) (i32.const 0)) (then unreachable)) {} (i64.or (i64.shl (i64.const 7) (i64.const 32)) (i64.const 0x3000))", + expect(&select_all(), 0), + expect("(call $vc_call_result (i32.const 0x3000) (i32.const 2))", 7), + expect("(call $vc_call_result (i32.const 0x3000) (i32.const 64))", 7), + ); + let src = tpl::module(&manifest("test.call.result"), &run, CALLS, 1); + let p = Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap(); + let (out, seen) = run_with(&p, |_| Ok(json!({"n": 1}))); + assert_eq!(out.unwrap(), br#"{"n":1}"#); + assert_eq!(seen, [("select.all".to_string(), json!({"a": 1}))]); + // No bytes of arguments are `{}`; a result buffer outside memory is a code, not a failure. + let p = calling( + "test.call.empty", + &[ + expect("(call $vc_call_result (i32.const 0x3000) (i32.const 64))", CALL_NO_RESULT), + expect(&call("0x2000", "10", "0", "0"), 0), + expect("(call $vc_call_result (i32.const -16) (i32.const 64))", CALL_OUTSIDE_MEMORY), + ] + .concat(), + ); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert_eq!(out.unwrap(), INPUT.as_bytes()); + assert_eq!(seen, [("select.all".to_string(), json!({}))]); +} + +/// Each misuse returns its code, and the run fails with an error that names the call, even when the +/// plug-in goes on and returns an output. The host makes no call for any of them. +#[test] +fn a_call_the_host_refuses_fails_the_run_even_when_the_plugin_goes_on() { + let cases = [ + (call("0x2010", "13", "0", "0"), CALL_NOT_ALLOWED, "vc_call `object.delete`: the manifest's `commandAllowlist` doesn't list it"), + (call("0x2000", "10", "0x2200", "1"), CALL_BAD_UTF8, "vc_call `select.all`: the id or the arguments are not UTF-8"), + (call("0x2000", "10", "0x2300", "3"), CALL_BAD_JSON, "vc_call `select.all`: the arguments are not a JSON object"), + (call("0x2000", "10", "0x2100", "3"), CALL_BAD_JSON, "the arguments are not a JSON object"), + (call("0x2000", "10", "-16", "7"), CALL_OUTSIDE_MEMORY, "vc_call `select.all`: the id or the arguments lie outside the module's memory"), + (call("-4", "10", "0", "0"), CALL_OUTSIDE_MEMORY, "vc_call `?`: the id or the arguments lie outside the module's memory"), + (call("0x2000", "200", "0", "0"), CALL_NOT_ALLOWED, "doesn't list it"), + ]; + for (i, (first, code, says)) in cases.into_iter().enumerate() { + let p = calling(&format!("test.call.refused{i}"), &expect(&first, code)); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + let e = out.expect_err(says).to_string(); + assert!(e.contains(says), "{first}: {e}"); + assert!(seen.is_empty(), "{first}"); + } +} + +/// Arguments longer than [`MAX_CALL_DATA`] are refused from their length alone: these 100 MiB start +/// outside memory, and the code is still the size limit. +#[test] +fn oversized_arguments_are_refused_before_they_are_read() { + let p = calling("test.call.huge", &expect(&call("0x2000", "10", "-16", &(100 << 20).to_string()), CALL_LIMIT)); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + let e = out.unwrap_err(); + assert!(matches!(&e, Error::Limit(why) if why.contains(&format!("104857600 bytes, limit {MAX_CALL_DATA}"))), "{e}"); + assert!(seen.is_empty()); +} + +#[test] +fn a_failed_command_fails_the_run_with_its_reason() { + let p = calling("test.call.failed", &expect(&select_all(), CALL_COMMAND_FAILED)); + let (out, seen) = run_with(&p, |_| Err(CallFailure { code: CALL_COMMAND_FAILED, why: "nothing is selected".into() })); + assert_eq!(out, Err(Error::Failed("vc_call `select.all`: nothing is selected".into()))); + assert_eq!(seen.len(), 1); + // Also when the plug-in traps after it: the call's failure is the one reported. + let p = calling("test.call.failed.trap", &format!("(drop {}) unreachable", select_all())); + let (out, _) = run_with(&p, |_| Err(CallFailure { code: CALL_COMMAND_FAILED, why: "nothing is selected".into() })); + assert_eq!(out, Err(Error::Failed("vc_call `select.all`: nothing is selected".into()))); +} + +/// After the run's first failed call, a plug-in that goes on calling gets [`CALL_NO_CONTEXT`] for +/// each call and the host makes none of them; the run fails with the first call's reason. +#[test] +fn no_call_is_made_after_a_failed_one() { + let rest = [select_all(), select_all(), select_all()].map(|c| expect(&c, CALL_NO_CONTEXT)).concat(); + let p = calling("test.call.after.failed", &format!("{}{rest}", expect(&select_all(), CALL_COMMAND_FAILED))); + let (out, seen) = run_with(&p, |_| Err(CallFailure { code: CALL_COMMAND_FAILED, why: "nothing is selected".into() })); + assert_eq!(out, Err(Error::Failed("vc_call `select.all`: nothing is selected".into()))); + assert_eq!(seen.len(), 1); + // Also after a call the host refused without making it. + let p = calling( + "test.call.after.refused", + &format!("{}{}", expect(&call("0x2010", "13", "0", "0"), CALL_NOT_ALLOWED), expect(&select_all(), CALL_NO_CONTEXT)), + ); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert!(out.unwrap_err().to_string().contains("vc_call `object.delete`: the manifest's `commandAllowlist` doesn't list it")); + assert!(seen.is_empty()); +} + +/// A result larger than [`MAX_CALL_DATA`] fails the run, and so do more than [`MAX_CALLS`] calls. +#[test] +fn results_and_calls_are_capped() { + let p = calling("test.call.big", &expect(&select_all(), CALL_LIMIT)); + let (out, _) = run_with(&p, |_| Ok(Value::String("x".repeat(MAX_CALL_DATA)))); + assert!(matches!(out, Err(Error::Limit(ref why)) if why.starts_with("command call size (vc_call `select.all`")), "{out:?}"); + // A result of exactly MAX_CALL_DATA bytes (the string and its two quotes) is kept. + let p = calling("test.call.edge", &expect(&select_all(), 0)); + let (out, _) = run_with(&p, |_| Ok(Value::String("x".repeat(MAX_CALL_DATA - 2)))); + assert_eq!(out.unwrap(), INPUT.as_bytes()); + let many = format!( + "(local $i i32) (loop $l {} (local.set $i (i32.add (local.get $i) (i32.const 1))) (br_if $l (i32.lt_u (local.get $i) (i32.const {MAX_CALLS})))) {}", + expect(&select_all(), 0), + expect(&select_all(), CALL_LIMIT) + ); + let p = calling("test.call.many", &many); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert!(matches!(out, Err(Error::Limit(ref why)) if why.starts_with("command calls (")), "{out:?}"); + assert_eq!(seen.len(), MAX_CALLS); +} + +/// Each call costs instructions, so a budget too small for the calls ends the run: 256,000 (the +/// base and the input's share) pays for 25 calls of 10,000 and the loop around them. +#[test] +fn calls_cost_instructions() { + let many = format!("(loop $l {} (br $l))", expect(&select_all(), 0)); + let src = tpl::with_imports(&manifest("test.call.fuel"), CALLS, &many, INPUT); + let p = Plugin::load(&wat::parse_str(&src).unwrap(), Limits { fuel_base: 200_000, ..Limits::default() }).unwrap(); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert_eq!(out, Err(Error::Limit("instruction budget".into()))); + assert_eq!(seen.len(), 25); +} + +/// Cancel stops the run at the next call, and what it did is the caller's to undo. +#[test] +fn cancel_stops_a_run_at_its_next_call() { + let p = calling("test.call.cancel", &[expect(&select_all(), 0), expect(&select_all(), 0)].concat()); + let token = vectorcraft_doc::cancel::CancelToken::new(); + let mut calls = 0; + let mut bridge = |_: &str, _: &Value| { + calls += 1; + token.cancel(); + Ok(Value::Null) + }; + let (out, _) = vectorcraft_doc::cancel::scope(&token, None, || p.run_calling(INPUT.as_bytes(), b"{}", p.limits(), &mut bridge)); + assert_eq!(out, Err(Error::Cancelled)); + assert_eq!(calls, 1); +} + +/// Without a bridge (a run that isn't a filter's run on a document), and while the module is being +/// loaded, `vc_call` returns [`CALL_NO_CONTEXT`]; in a run, that fails the run as any failed call +/// does, also when the plug-in goes on. +#[test] +fn without_a_bridge_a_call_returns_no_context() { + let no_context = |out: vectorcraft_plugins::Result>| { + let e = out.unwrap_err(); + assert!(matches!(e, Error::Abi(_)) && e.to_string().contains("no command can be called here"), "{e}"); + }; + // The start function's call gets the code, and the module loads; a run, with a bridge or + // without one, then fails on it, and the bridge is never asked. + let start = format!("(func $start {}) (start $start)", expect(&select_all(), CALL_NO_CONTEXT)); + let src = tpl::with_imports(&manifest("test.call.none"), &format!("{CALLS} {start}"), "", INPUT); + let p = Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap(); + no_context(p.run(INPUT.as_bytes(), b"{}")); + let (out, seen) = run_with(&p, |_| Ok(json!({}))); + no_context(out); + assert!(seen.is_empty()); + // A call from `vc_run` without a bridge. + no_context(calling("test.call.none", &expect(&select_all(), CALL_NO_CONTEXT)).run(INPUT.as_bytes(), b"{}")); +} + +/// A module that imports `vc_call` or `vc_call_result` needs the permission `commands`, which needs +/// `api` 2 and a `commandAllowlist`; only a filter with the usual budget lists one. +#[test] +fn the_call_imports_need_the_commands_permission() { + let import = r#"(import "env" "vc_call_result" (func (param i32 i32) (result i32)))"#; + let without = r#"{"id":"test.call.perm","name":"C","kind":"filter","api":2,"permissions":["doc.read"]}"#; + let e = Plugin::load(&wat::parse_str(tpl::with_imports(without, import, "", INPUT)).unwrap(), Limits::default()).unwrap_err().to_string(); + assert!( + e.contains("imports `env.vc_call_result`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `commands`"), + "{e}" + ); + for (manifest, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","permissions":["commands"],"commandAllowlist":["select.all"]}"#, "needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"]}"#, "`commands` needs a `commandAllowlist`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"commandAllowlist":["select.all"]}"#, "`commandAllowlist` needs the permission `commands`"), + ( + r#"{"id":"a","name":"x","kind":"effect","api":2,"permissions":["doc.read","effect.render","commands"],"commandAllowlist":["select.all"]}"#, + "only a filter with the usual budget", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","api":2,"budget":"heavy","permissions":["commands"],"commandAllowlist":["select.all"]}"#, + "only a filter with the usual budget", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select.all","select.all"]}"#, + "names `select.all`", + ), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select..all"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["1select"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":[""]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select/all"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select\\all"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["../select.all"]}"#, "not a command id"), + ( + r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":[]}"#, + "`commands` needs a `commandAllowlist`", + ), + ] { + let e = Manifest::parse(manifest.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{manifest}: {e}"); + } + let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["{}"]}}"#, "a".repeat(129)); + assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("not a command id")); + let ids: Vec = (0..257).map(|i| format!("\"c{i}\"")).collect(); + let many = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":[{}]}}"#, ids.join(",")); + assert!(Manifest::parse(many.as_bytes()).unwrap_err().to_string().contains("at most 256 commands")); + let m = Manifest::parse(manifest("test.call.ok").as_bytes()).unwrap(); + assert!(m.allows_call("select.all") && !m.allows_call("select.none")); + assert_eq!(serde_json::to_value(&m).unwrap()["commandAllowlist"], json!(["select.all"])); +} + +/// A small xorshift generator, so the cases are the same on every run. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + + /// Up to `max` random bytes. + fn bytes(&mut self, max: u64) -> Vec { + (0..self.below(max + 1)).map(|_| self.next() as u8).collect() + } + + /// A random JSON value nested at most `depth` deep. + fn json(&mut self, depth: u32) -> Value { + match self.below(if depth == 0 { 4 } else { 6 }) { + 0 => Value::Null, + 1 => Value::Bool(self.below(2) == 0), + 2 => json!((self.next() as i64) >> self.below(64)), + 3 => Value::String(String::from_utf8_lossy(&self.bytes(12)).into_owned()), + 4 => Value::Array((0..self.below(4)).map(|_| self.json(depth - 1)).collect()), + _ => Value::Object((0..self.below(4)).map(|i| (format!("k{i}"), self.json(depth - 1))).collect()), + } + } +} + +/// Any bytes as a call's id and arguments: random bytes, JSON values of every kind, cut-off and +/// corrupted JSON, ids with path characters or a NUL, and arguments of exactly [`MAX_CALL_DATA`] +/// bytes and one byte more. The host makes the call only for a listed id with a JSON object of at +/// most [`MAX_CALL_DATA`] bytes; every other call gets a code and fails the run with an error, and +/// none panics. The plug-in calls with its parameters as the id and its input as the arguments. +#[test] +fn any_bytes_as_a_call_get_a_code() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let ids: [&[u8]; 7] = [b"select.all", b"select.al", b"select.all\0", b"../select.all", b"select/all", b"\xff\xfe", b""]; + let mut cases: Vec<(Vec, Vec)> = Vec::new(); + for i in 0..600 { + let args = match i % 4 { + 0 => rng.bytes(48), + 1 => serde_json::to_vec(&rng.json(3)).unwrap(), + 2 => { + let mut v = serde_json::to_vec(&rng.json(3)).unwrap(); + v.truncate(rng.below(v.len() as u64 + 1) as usize); + v + } + _ => { + let mut v = serde_json::to_vec(&json!({"a": rng.json(3)})).unwrap(); + let at = rng.below(v.len() as u64) as usize; + v[at] = rng.next() as u8; + v + } + }; + let id = if rng.below(3) == 0 { rng.bytes(16) } else { ids[rng.below(ids.len() as u64) as usize].to_vec() }; + cases.push((args, id)); + } + for len in [MAX_CALL_DATA, MAX_CALL_DATA + 1] { + let mut v = br#"{"a":""#.to_vec(); + v.resize(len - 2, b'x'); + v.extend_from_slice(br#""}"#); + cases.push((v, b"select.all".to_vec())); + } + let p = calling("test.call.fuzz", "(drop (call $vc_call (local.get $pp) (local.get $pl) (local.get $in) (local.get $il)))"); + let mut made = 0; + for (args, id) in &cases { + let mut seen = Vec::new(); + let mut bridge = |_: &str, a: &Value| { + seen.push(a.clone()); + Ok(Value::Null) + }; + let (out, _) = p.run_calling(args, id, p.limits(), &mut bridge); + let object = if args.len() > MAX_CALL_DATA { + None + } else if args.is_empty() { + Some(json!({})) + } else { + serde_json::from_slice::(args).ok().filter(Value::is_object) + }; + let shown = String::from_utf8_lossy(&args[..args.len().min(64)]).into_owned(); + match object.filter(|_| id.as_slice() == b"select.all") { + Some(v) => { + assert_eq!(out.as_deref().ok(), Some(INPUT.as_bytes()), "{shown}"); + assert_eq!(seen, [v], "{shown}"); + made += 1; + } + None => { + assert!(seen.is_empty(), "{shown}"); + let e = out.expect_err(&shown); + assert!(matches!(e, Error::Abi(_) | Error::Limit(_)), "{shown}: {e}"); + } + } + } + // Both kinds of case came up: calls made and calls refused. + assert!(made >= 5 && made < cases.len() - 5, "{made} of {}", cases.len()); +} diff --git a/crates/render/src/encode.rs b/crates/render/src/encode.rs index 201931a8a..635f90683 100644 --- a/crates/render/src/encode.rs +++ b/crates/render/src/encode.rs @@ -248,7 +248,7 @@ fn drawn_bounds_with(symbols: &SymbolBoxes, n: &Node, held: Option<&Arc>, return None; } match &n.kind { - NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None, + NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children .iter() .skip(usize::from(n.extras().shaper.is_some())) @@ -289,7 +289,7 @@ fn without_undrawn(n: &Node) -> Option { if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { return None; } - let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }); + let undrawn = |c: &Node| !c.visible || c.is_guide() || c.is_template(); let mut changed = false; let kept: Vec> = n .children()? diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index 777276e69..98c031c89 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -447,7 +447,7 @@ impl Parts<'_> { /// Node `n` (a top-level layer, or with Maximum Editability anything in one) and its parts. fn add(&mut self, n: &Node) { // Templates and guides aren't artwork. - if matches!(n.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }) || !(n.visible || self.o.hidden_layers) { + if n.is_template() || n.is_guide() || !(n.visible || self.o.hidden_layers) { return; } let (hidden, opacity) = (!n.visible, opacity_byte(n.opacity)); diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index e56278f1d..a488cceff 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -271,6 +271,28 @@ fn guides_are_not_layers() { assert_eq!(parse(&export(&b.build(), &o)).count, -3, "the group and the square"); } +/// Ruler guides on a layer and a sublayer change nothing in the export: the same file with and +/// without Maximum Editability, the same drawn bounds and the same cached size of each layer. +#[test] +fn ruler_guides_are_not_exported() { + use vectorcraft_doc::{NodeId, NodeKind}; + let plain = doc(); + let mut guided = plain.clone(); + let guide = |id, vertical, pos| Node::new(NodeId(id), NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (bottom, inner) = (guided.layers[0].id, guided.layers[1].children().unwrap().last().unwrap().id); + guided.insert(Some(bottom), usize::MAX, guide(900, true, 20.0)).unwrap(); + guided.insert(Some(bottom), usize::MAX, guide(901, false, 15.0)).unwrap(); + guided.insert(Some(inner), usize::MAX, guide(902, true, 3.0)).unwrap(); + for psd in [PsdOptions::default(), PsdOptions { max_editability: true, ..PsdOptions::default() }] { + assert_eq!(export(&guided, &opts(psd.clone())), export(&plain, &opts(psd))); + } + assert_eq!(art_bounds(&guided), art_bounds(&plain)); + for (g, p) in guided.layers.iter().zip(&plain.layers) { + assert_eq!(crate::drawn_bounds(&guided, g), crate::drawn_bounds(&plain, p)); + assert_eq!(crate::lru::node_bytes(g), crate::lru::node_bytes(p)); + } +} + #[test] fn a_background_colour_is_the_bottom_layer() { let doc = doc(); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 9bc9ba756..9d5e9c0d1 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -445,7 +445,8 @@ fn three_d_bounds(symbols: &SymbolBoxes, n: &Node) -> Option> { if !effects::has_revolve(n) && !effects::has_solid(n) { return None; } - let art = effects::revolve_art(n).or_else(|| effects::solid_art(n))?; + let doc = Some(symbols.document()); + let art = effects::revolve_art_in(doc, n).or_else(|| effects::solid_art_in(doc, n))?; Some(three_d_art_bounds(symbols, &art)) } diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 75143f9a0..e9aa8486d 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -588,7 +588,7 @@ impl Renderer { let art = match effects::crop_marks_art(a) .or_else(|| crate::addons::halftone::art(doc, a)) .or_else(|| { - let art = effects::revolve_art(a).or_else(|| effects::solid_art(a)); + let art = effects::revolve_art_in(Some(doc), a).or_else(|| effects::solid_art_in(Some(doc), a)); self.stats.three_d_built += usize::from(art.is_some()); three_d |= art.is_some(); art diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 5c4474b22..c03a6cc0f 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -612,6 +612,96 @@ fn an_export_and_rasterize_hold_the_solid_of_3d_type() { assert!(rasterized.width() > letters.width() + 15.0, "{letters:?} {rasterized:?}"); } +/// A symbol instance, a blend and a clipping group with Extrude & Bevel, alone or in a group, draw +/// their solids on the canvas, in an export and in Object › Rasterize, not their flat art. +#[test] +fn a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids() { + use vectorcraft_doc::{BlendSpacing, BlendSpec, NodeId, NodeKind, Symbol}; + let square = |x: f64, y: f64| { + Arc::new(Node::path( + NodeId(0), + shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), Paint::None, 0.0), + )) + }; + let mut d = Document::new(200.0, 200.0); + d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: square(0.0, 0.0) }); + let mut clip = (*square(30.0, 30.0)).clone(); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() }); + let instance = NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((40.0, 40.0)) }; + let clipped = NodeKind::Group { children: vec![Arc::new(clip), square(20.0, 20.0)], clip: true }; + let in_group = |kind: NodeKind| NodeKind::Group { children: vec![Arc::new(Node::new(NodeId(0), kind)), square(100.0, 100.0)], clip: false }; + let kinds = [ + ("a symbol instance", instance.clone()), + ("a blend", NodeKind::Blend { children: vec![square(30.0, 30.0), square(60.0, 40.0)], spec }), + ("a clipping group", clipped.clone()), + ("a group holding a symbol instance", in_group(instance)), + ("a group holding a clipping group", in_group(clipped)), + ]; + for (what, kind) in kinds { + let mut n = Node::new(d.alloc_id(), kind); + let flat = crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap(); + n.appearance.effects = + vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }]; + let solid_doc = doc_of(&mut d.clone(), n); + let n = &solid_doc.layers[0].children().unwrap()[0]; + let (solid, rasterized) = (crate::encode::art_bounds(&solid_doc).unwrap(), crate::drawn_bounds(&solid_doc, n).unwrap()); + let size = |r: Rect| r.width() + r.height(); + assert!(size(solid) > size(flat) + 20.0, "{what}: {flat:?} {solid:?}"); + assert!(size(rasterized) > size(flat) + 20.0, "{what}: {flat:?} {rasterized:?}"); + // The renderer builds the solid from the object itself, with the document's symbols, as + // the effect builds it: one solid built, not the object's art drawn again with the effect. + let built = vectorcraft_effects::solid_art_in(Some(&solid_doc), n).and_then(|a| a.visual_bounds()).unwrap(); + assert!((solid.x0 - built.x0).abs() < 1.0 && (solid.x1 - built.x1).abs() < 1.0, "{what}: {built:?} {solid:?}"); + let mut r = Renderer::new(); + r.art_bounds_on(&solid_doc, Rect::new(0.0, 0.0, 200.0, 200.0)); + assert_eq!(r.stats.three_d_built, 1, "{what}"); + } +} + +/// A loaded file can hold a 3D effect on art past the limits commands check: a blend of more +/// than 4,096 objects, art nested more than 64 levels deep in a clipping group. The art then draws +/// as it does without the effect, and drawing it does not fail. +#[test] +fn art_past_the_three_d_limits_draws_as_without_the_effect() { + use vectorcraft_doc::{BlendSpacing, BlendSpec, NodeId, NodeKind}; + let square = |x: f64| { + Arc::new(Node::path( + NodeId(0), + shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), Paint::None, 0.0), + )) + }; + let mut clip = (*square(0.0)).clone(); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut deep = (*square(0.0)).clone(); + for _ in 0..200 { + deep = Node::group(NodeId(0), vec![Arc::new(deep)]); + } + let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() }); + let kinds = [ + ("a blend of 6,000 objects", NodeKind::Blend { children: (0..6).map(|i| square(30.0 * i as f64)).collect(), spec }), + ("art 200 levels deep in a clip", NodeKind::Group { children: vec![Arc::new(clip), Arc::new(deep)], clip: true }), + ]; + let d = Document::new(200.0, 200.0); + for (what, kind) in kinds { + let mut n = Node::new(NodeId(500), kind); + let flat = crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap(); + n.appearance.effects = + vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }]; + let loaded = doc_of(&mut d.clone(), n); + let n = &loaded.layers[0].children().unwrap()[0]; + assert!(vectorcraft_effects::validate_three_d_in(Some(&loaded), n, "threeD.extrude", &json!({})).is_err(), "{what}"); + assert_eq!(crate::encode::art_bounds(&loaded), Some(flat), "{what}"); + assert!(crate::drawn_bounds(&loaded, n).is_some(), "{what}"); + } +} + fn doc_of(d: &mut Document, n: Node) -> Document { let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 93adbf245..92246e1cd 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -63,7 +63,7 @@ pub fn installed_memory() -> Option { /// Installed memory in bytes (`MemTotal` in `/proc/meminfo`), or the memory limit of the process's /// cgroups where that is lower. #[cfg(target_os = "linux")] -fn physical_memory() -> Option { +pub fn physical_memory() -> Option { let info = std::fs::read_to_string("/proc/meminfo").ok()?; let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; @@ -174,14 +174,14 @@ fn mountinfo_unescape(field: &str) -> String { /// Installed memory in bytes (`GlobalMemoryStatusEx`). #[cfg(windows)] -fn physical_memory() -> Option { +pub fn physical_memory() -> Option { winsafe::GlobalMemoryStatusEx().ok().map(|m| m.ullTotalPhys) } /// Installed memory in bytes, as `sysctl` prints it (`hw.memsize` on macOS, `hw.physmem` on /// FreeBSD). #[cfg(any(target_os = "macos", target_os = "freebsd"))] -fn physical_memory() -> Option { +pub fn physical_memory() -> Option { let (sysctl, name) = if cfg!(target_os = "macos") { ("/usr/sbin/sysctl", "hw.memsize") } else { ("/sbin/sysctl", "hw.physmem") }; let out = std::process::Command::new(sysctl).args(["-n", name]).output().ok().filter(|o| o.status.success())?; sysctl_bytes(&out.stdout) @@ -193,8 +193,9 @@ fn sysctl_bytes(out: &[u8]) -> Option { std::str::from_utf8(out).ok()?.trim().parse().ok() } -#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd", target_arch = "wasm32")))] -fn physical_memory() -> Option { +/// Installed memory in bytes: not read on other targets, the web among them. +#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd")))] +pub fn physical_memory() -> Option { None } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5ccb3f0b9..141c3524c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,7 +15,7 @@ mod freeform; mod fx; mod group; mod images; -pub use images::{image_cached, keep_decoded_image}; +pub use images::{image_cached, keep_decoded_image, physical_memory}; mod ink; mod live; mod lru; @@ -283,6 +283,7 @@ use frame::{ #[cfg(test)] use frame::{OFFSCREEN_PIXELS, Slices}; pub use geometry::region_pixels; +pub use node_checks::draws_plugin_effects; use node_checks::holds_plugin_effect; #[cfg(test)] use node_checks::painted_items; diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index 54ab4e51e..85f327b89 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -243,10 +243,14 @@ pub(crate) fn path_bytes(p: &BezPath) -> usize { size_of_val(p.elements()) } -/// Approximate bytes of node `n` and its members: the nodes and their path anchors. +/// Approximate bytes of node `n` and its members: the nodes and their path anchors. Ruler guides +/// are not drawn art and are not counted. pub(crate) fn node_bytes(n: &Node) -> usize { let mut b = 0; n.walk(&mut |c| { + if c.is_ruler_guide() { + return; + } b += size_of::() + NODE_OVERHEAD; if let Some(p) = c.path_data() { b += p.subpaths.iter().map(|s| size_of::() + s.anchors.len() * size_of::()).sum::(); diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index c4c81b337..bc516567d 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -1,4 +1,4 @@ -use super::{Affine, AppearanceItem, Arc, BezPath, Node, NodeKind, StrokeLayer, brush_fx, effects}; +use super::{Affine, AppearanceItem, Arc, BezPath, Document, Node, NodeKind, StrokeLayer, brush_fx, effects}; /// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be /// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom. @@ -108,9 +108,11 @@ pub(crate) fn holds_instance(n: &Node) -> bool { } /// Is `a` left out of a frame `doc` draws with `opts`, before any culling: hidden, a template or -/// guide the view hides, drawn hidden, or the instance its symbol is being edited from? +/// guide the view hides, a ruler guide (the canvas draws those), drawn hidden, or the instance its +/// symbol is being edited from? pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, a: &Node) -> bool { - let view_item = (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) + let view_item = a.is_ruler_guide() + || (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) || (opts.skip_templates || opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); !a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id) } @@ -142,6 +144,16 @@ pub(crate) fn holds_plugin_effect(n: &Node) -> bool { holds } +/// Does `doc` draw a plug-in effect, so that installing or removing a plug-in +/// ([`effects::plugin_revision`]) can change how it draws? It does when an object in its layers, in +/// the art of its symbols or patterns, or in the art of its brushes holds one ([`holds_plugin_effect`]). +pub fn draws_plugin_effects(doc: &Document) -> bool { + let symbols = doc.symbols.iter().map(|s| &s.art); + let patterns = doc.patterns.iter().flat_map(|p| &p.art); + doc.layers.iter().chain(symbols).chain(patterns).any(|a| holds_plugin_effect(a)) + || vectorcraft_brush::library(doc).iter().any(|b| b.art().into_iter().any(holds_plugin_effect)) +} + /// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color /// model, the color's components and the amount (all 0 without a stain). pub(crate) fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] { diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index a722c799e..2372abdbf 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -361,7 +361,8 @@ impl Renderer { let Some(pe) = &f.doc.pattern_edit else { return false }; let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false }; let Some(def) = f.doc.pattern(&pe.pattern) else { return false }; - let children: Vec> = layer.children().cloned().unwrap_or_default(); + // The tile's art: the layer's ruler guides are not part of it. + let children: Vec> = layer.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect(); if layer.visible && !children.is_empty() { let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0); let (before, after) = def.preview_offsets_dimmed_sides(); diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index 3827829d0..6daf192fc 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -425,7 +425,8 @@ impl Renderer { if !k.is_finite() || k <= 1e-12 { return; } - // The art in its own coordinates, with its own resources. + // The art in its own coordinates, with its own resources (its composite fonts included). + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&e.doc.composite_fonts); let symbols = e.doc.symbol_boxes().quick(); let visible = m.inverse().transform_rect_bbox(f.visible); let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f }; diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index a5bfcbf26..431e0aa97 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -105,8 +105,11 @@ impl Renderer { } } - /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). + /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). Type + /// set in one of the document's composite fonts draws in the document's definition, also when + /// no session has registered it (a document that is not in front, or read from a file). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, width, height, view, opts, false, 1) } @@ -258,8 +261,7 @@ impl Renderer { }; // `reset` keeps the threshold of the previous render: set it every time. ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; - self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; + self.raster = raster_settings(opts); if opts.artboards && !opts.outline { // Paper: white on screen, no ink on the ink planes. ctx.set_transform(f.view); @@ -340,6 +342,7 @@ impl Renderer { pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { let (w, h) = region_pixels(region, scale); let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, w, h, view, opts, true, 1).pixels } @@ -356,22 +359,62 @@ impl Renderer { /// [`Self::render_node`] with `opts` (e.g. [`RenderOptions::mark_undecoded_images`]). pub fn render_node_with(&mut self, doc: &Document, n: &Arc, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option { + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); self.check_plugins(doc); self.check_symbols(doc); - let inv = view.inverse(); - let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let pixels = self.node_pixels(doc, n, (w, h), view, visible, opts, false, 1); + Some(Rendered { width: w as u32, height: h as u32, pixels }) + } + + /// Node `n` drawn into `w`×`h` transparent pixels through `view`, culled to `visible` (document + /// space); `forced` draws it whatever its visibility and bounds. With [`RenderOptions::supersample`] + /// and Art Optimized edges the art is drawn with hard edges at several times the size and + /// averaged down, and [`RenderOptions::precise`] picks the quality render mode, as in a page + /// render ([`Self::render_as`], [`Self::draw_frame`]). `cell` is as in [`Self::render_as`]. + #[allow(clippy::too_many_arguments)] + fn node_pixels( + &mut self, + doc: &Document, + n: &Arc, + (w, h): (u16, u16), + view: Affine, + visible: Rect, + opts: &RenderOptions, + forced: bool, + cell: u32, + ) -> Vec { + if opts.supersample + && opts.anti_alias == AntiAlias::Art + && let Some(k) = supersample_factor(u32::from(w), u32::from(h)) + { + let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; + // `supersample_factor` keeps each side within MAX_CONTEXT_SIDE, so it fits in a u16. + let (bw, bh) = (u32::from(w) * k, u32::from(h) * k); + let hairline = std::mem::replace(&mut self.hairline, f64::from(k)); + let big = self.node_pixels(doc, n, (bw as u16, bh as u16), Affine::scale(f64::from(k)) * view, visible, &hard, forced, k); + self.hairline = hairline; + return downsample(&big, bw as usize, bh as usize, k as usize); + } let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let mut ctx = single_threaded_context(w, h); ctx.set_aliasing_threshold(opts.anti_alias.threshold()); let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output: view, plates: None }; + let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output, plates: None }; + let raster = std::mem::replace(&mut self.raster, raster_settings(opts)); self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); - self.draw_arc(&mut ctx, &frame, n); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); + if forced { + self.draw_forced_arc(&mut ctx, &frame, n); + } else { + self.draw_arc(&mut ctx, &frame, n); + } + let pm = self.finish(&mut ctx, self.raster); self.end_frame(); - Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) + self.raster = raster; + pm.data_as_u8_slice().to_vec() } /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into @@ -395,6 +438,7 @@ impl Renderer { background: Option<[u8; 4]>, opts: &RenderOptions, ) -> Option { + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); self.check_plugins(doc); self.check_symbols(doc); let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?; @@ -402,28 +446,7 @@ impl Renderer { // A 1-pixel margin on each side, unless the thumbnail is too small to keep one. let s = (w as f64 - 2.0).max(1.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); - let mut ctx = single_threaded_context(w, w); - ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - let symbols = doc.symbol_boxes().quick(); - let frame = Frame { - mt: false, - doc, - symbols: &symbols, - view, - visible: b.inflate(1.0, 1.0), - px: 1.0 / s, - opts, - ink: Ink::Display, - output: view, - plates: None, - }; - self.begin_frame(); - (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); - self.clip_paths.clear(); - self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); - self.end_frame(); - let mut pixels = pm.data_as_u8_slice().to_vec(); + let mut pixels = self.node_pixels(doc, &Arc::new(n.clone()), (w, w), view, b.inflate(1.0, 1.0), opts, true, 1); if let Some(bg) = background { over_background(&mut pixels, bg); } @@ -431,6 +454,12 @@ impl Renderer { } } +/// The rasterizer settings for `opts`: the quality render mode when [`RenderOptions::precise`] is set. +fn raster_settings(opts: &RenderOptions) -> vello_cpu::RasterizerSettings { + let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; + vello_cpu::RasterizerSettings { render_mode, ..Default::default() } +} + /// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight). pub(crate) fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { let alpha = u32::from(a); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 9b59367de..ca6611ea5 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -596,6 +596,8 @@ impl Renderer { Some(a) => self.draw_live_body(ctx, f, &a, true), None => self.draw_live_node(ctx, f, n), }, + // The canvas draws ruler guides over the art; the renderer draws nothing for them. + NodeKind::RulerGuide { .. } => {} } } @@ -680,18 +682,20 @@ impl Renderer { self.draw_knockout(ctx, f, children); } else if let Some((_, path)) = self.isolation_path(f) { // Isolation mode, in a container on the way down to the isolated one: each run of - // members around it draws dimmed as one (#833). + // members around it draws dimmed as one (#833). A run of ruler guides alone draws + // nothing, so it gets no dimmed pass. + let draws = |run: &[Arc]| run.iter().any(|c| !c.is_ruler_guide()); let mut rest = children; while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { let (around, from) = rest.split_at(i); - if !around.is_empty() { + if draws(around) { self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); } let Some((on_path, after)) = from.split_first() else { break }; self.draw_arc(ctx, f, on_path); rest = after; } - if !rest.is_empty() { + if draws(rest) { self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); } } else { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index a12e79e86..d5c129e5c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -425,6 +425,29 @@ fn an_instance_is_culled_by_the_art_nested_9_slice_guides_move() { assert_eq!(img.pixel(95, 50), [255, 0, 0, 255]); } +#[test] +fn an_instance_that_only_moves_is_culled_by_the_art_its_9_slice_guides_move() { + // A symbol made in the app: art in the 20 × 20 square, a natural size of 2 × 2, and guides a + // quarter in. An instance that only moves draws it at 20 × 20, ten times its natural size. The + // bar from x −9.9 to 0.1 crosses the left guide and is placed by the middle column's map, + // x → 1.9x: moved 115 pt right, it draws from x 96.19 to 115.19 while its art box starts at + // x 105, out of view. + let mut d = Document::new(100.0, 100.0); + let bar = rect_node(&mut d, Rect::new(-9.9, -1.0, 0.1, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(bar) }); + let frame = rect_node(&mut d, Rect::new(-10.0, -10.0, 10.0, 10.0), Paint::None, Paint::None, 0.0); + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY }); + let art = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(inner)]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) }); + d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.into(), serde_json::json!({ "Panel": [2.0, 2.0] })); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let (id, l) = (d.alloc_id(), d.layers[0].id); + let xf = Affine::translate((115.0, 50.0)); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap(); + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(img.pixel(98, 50), [255, 0, 0, 255]); +} + #[test] fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() { // The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect @@ -952,6 +975,43 @@ fn node_renders_follow_the_anti_alias_option() { assert!(drawn(&thumb) && !partial(&thumb), "render_node_thumbnail_with: every alpha is 0 or 255"); } +/// Node renders follow `supersample` and `precise` as a page render does: two rectangles that meet +/// at x = 50.37 leave no seam when supersampled, and a node render gives the pixels of a page +/// render of the same art with the same options. +#[test] +fn node_renders_follow_the_supersample_and_precise_options() { + let mut d = Document::new(100.0, 100.0); + let left = rect_node(&mut d, Rect::new(10.0, 10.0, 50.37, 90.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let right = rect_node(&mut d, Rect::new(50.37, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(0.2, 0.6, 0.9)), Paint::None, 0.0); + // Blended at half opacity over both, so the group composites offscreen. + let mut over = rect_node(&mut d, Rect::new(30.0, 60.0, 70.0, 80.0), Paint::solid(Color::rgb(0.9, 0.7, 0.1)), Paint::None, 0.0); + over.opacity = 0.5; + over.blend = vectorcraft_color::BlendMode::Multiply; + let group = Node::group(d.alloc_id(), vec![Arc::new(left), Arc::new(right), Arc::new(over)]); + let l = d.layers[0].id; + d.insert(Some(l), 0, group.clone()).unwrap(); + let node = Arc::new(group.clone()); + let page = |opts: &RenderOptions| RenderOptions { background: None, artboards: false, ..opts.clone() }; + let mut r = Renderer::new(); + for (supersample, precise) in [(false, false), (true, false), (false, true), (true, true)] { + let opts = RenderOptions { supersample, precise, ..Default::default() }; + let case = format!("supersample {supersample}, precise {precise}"); + let img = r.render_node_with(&d, &node, 100, 100, Affine::IDENTITY, &opts).unwrap(); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &page(&opts)); + assert!(img.pixels == whole.pixels, "{case}: render_node_with draws as a page render does"); + // Pixel column 50 at row 50: the black rectangle covers 37% of it and the blue one the rest. + let seam = img.pixels[(50 * 100 + 50) * 4 + 3]; + assert_eq!(seam == 255, supersample, "{case}: render_node_with seam alpha {seam}"); + // A 37-pixel thumbnail draws 0.4375 pixels a point, the seam at x = 18.66. + let thumb = r.render_node_thumbnail_with(&d, &group, 37, None, &opts).unwrap(); + let view = Affine::translate((18.5, 18.5)) * Affine::scale(0.4375) * Affine::translate((-50.0, -50.0)); + let whole = Renderer::new().render(&d, 37, 37, view, &page(&opts)); + assert!(thumb.pixels == whole.pixels, "{case}: the thumbnail draws as a page render does"); + let seam = thumb.pixels[(18 * 37 + 18) * 4 + 3]; + assert_eq!(seam == 255, supersample, "{case}: thumbnail seam alpha {seam}"); + } +} + #[test] fn opacity_mask_with_undecodable_image_leaves_out_object() { use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask}; diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 82c5c77ff..8f6d68f57 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -138,6 +138,78 @@ fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty()); } +/// Type in one of a document's composite fonts draws in the document's definition when nothing has +/// registered it: in a render, an ink render, a thumbnail and a placed document's art, as type set +/// in the part's font draws. +/// The font database is left without it afterwards. +#[test] +fn a_documents_own_composite_font_draws_without_being_registered() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts}; + let _adding = adding_fonts(); + const NAME: &str = "CF Unregistered"; + let db = vectorcraft_text::FontDb::global(); + let typed = |family: &str| { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) + }; + let (mut composite, id) = typed(NAME); + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new("JetBrains Mono", "Regular"); + composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts }); + let (plain, plain_id) = typed("JetBrains Mono"); + assert!(db.composite_font(NAME).is_none()); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::IDENTITY; + assert_eq!( + Renderer::new().render(&composite, 300, 150, view, &opts).pixels, + Renderer::new().render(&plain, 300, 150, view, &opts).pixels, + "rendered" + ); + let region = Rect::new(0.0, 0.0, 300.0, 150.0); + assert_eq!( + Renderer::new().render_region_inks(&composite, region, 1.0, &opts), + Renderer::new().render_region_inks(&plain, region, 1.0, &opts), + "rendered as ink amounts" + ); + let thumbnail = |d: &Document, id| Renderer::new().render_thumbnail(d, id, 64).map(|r| r.pixels); + assert_eq!(thumbnail(&composite, id), thumbnail(&plain, plain_id), "thumbnail"); + let placed = |d: &Document| crate::placed_document::render_inside(&mut Renderer::new(), d, &d.layers[0], 300, 150, view).map(|r| r.pixels); + assert_eq!(placed(&composite), placed(&plain), "a placed document's art"); + // A page that places the document, drawn exactly (no bitmap of the placed art). + let page = |d: &Document, id: NodeId, key: &str| { + use vectorcraft_doc::{ImageBlob, LinkInfo, PlacedDocument}; + let blob = ImageBlob::new("application/json", key.as_bytes().to_vec()); + let p = PlacedDocument { + link: LinkInfo::new("/art/type.vectorcraft"), + key: key.into(), + bounding: false, + page_box: Default::default(), + width: 300.0, + height: 150.0, + xf: Affine::IDENTITY, + placement: Default::default(), + }; + let art = d.node(id).unwrap().clone(); + vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0))); + let mut page = Document::new(300.0, 150.0); + page.images.insert(key.into(), blob); + let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p))); + let l = page.layers[0].id; + page.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&page, 300, 150, view, &opts).pixels + }; + assert_eq!( + page(&composite, id, "fontchange-composite-placed"), + page(&plain, plain_id, "fontchange-plain-placed"), + "a placed document's art drawn exactly" + ); + assert!(db.composite_font(NAME).is_none(), "given back after each render"); +} + /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes /// available: the glyph geometry kept for it is laid out again. #[test] diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index d8b267cb9..d5e6919f0 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1824,6 +1824,44 @@ fn pattern_and_symbol_art_with_plugin_effects_follow_the_plugins_installed() { plugin_change_draws_as_new(with_art(d, vec![group]), ID, Some(triangle(82.0, 32.0, 10.0)), Some(triangle(82.0, 32.0, 30.0))); } +/// A document draws plug-in effects ([`draws_plugin_effects`]) when an object in its layers, in a +/// symbol's or a pattern's art, or in a brush's art has a visible plug-in effect, and not when its +/// objects have no effect, another effect or a hidden plug-in effect. +#[test] +fn a_document_draws_plugin_effects_where_any_of_its_art_has_one() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + use vectorcraft_geom::shapes::rectangle; + let plugin = || effect("plugin.test.render.draws", serde_json::json!({})); + let square = |d: &mut Document, effects: Vec| { + filled(d, rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::BLACK), effects) + }; + let mut cases: Vec<(&str, Document, bool)> = vec![]; + for (name, effects, draws) in [ + ("no effect", vec![], false), + ("another effect", vec![drop_shadow()], false), + ("hidden plug-in effect", vec![vectorcraft_doc::Effect { visible: false, ..plugin() }], false), + ("plug-in effect", vec![plugin()], true), + ] { + let mut d = Document::new(200.0, 100.0); + let n = square(&mut d, effects); + cases.push((name, with_art(d, vec![n]), draws)); + } + let mut d = Document::new(200.0, 100.0); + let art = square(&mut d, vec![plugin()]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Shaped".into(), art: Arc::new(art) }); + cases.push(("symbol art", d, true)); + let mut d = Document::new(200.0, 100.0); + let art = square(&mut d, vec![plugin()]); + d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Shaped", vec![Arc::new(art)])); + cases.push(("pattern art", d, true)); + let mut d = Document::new(200.0, 100.0); + let art = square(&mut d, vec![plugin()]); + vectorcraft_brush::store(&mut d, &[Brush { name: "Shaped".into(), kind: BrushKind::Art(ArtBrush { art, ..Default::default() }) }]); + cases.push(("brush art", d, true)); + let wrong: Vec<&str> = cases.iter().filter(|(_, d, draws)| draws_plugin_effects(d) != *draws).map(|(name, ..)| *name).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect { vectorcraft_doc::Effect { id: id.into(), params, visible: true } } diff --git a/crates/scriptcheck/Cargo.toml b/crates/scriptcheck/Cargo.toml index d0101be96..2e9dac687 100644 --- a/crates/scriptcheck/Cargo.toml +++ b/crates/scriptcheck/Cargo.toml @@ -9,5 +9,8 @@ rust-version.workspace = true [lints] workspace = true +[dependencies] +unicode-general-category = "1" + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index 1e5b21fc2..9a637274d 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -116,8 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> } /// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262 -/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character no -/// name may hold stays as written. Line breaks don't change, so every line keeps its number. +/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a +/// name may not hold where the escape stands stays as written. Line breaks don't change, so every +/// line keeps its number. pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> { match unescape_names(&src, acc) { Some(out) => Cow::Owned(out), @@ -145,20 +146,17 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option { changed.then_some(out) } -/// The name `text` with each `\uXXXX` escape for a character a name may hold written as that -/// character. +/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written +/// as that character: a character a name may start with first, and one a name may hold after that. fn unescape_name(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("\\u") { out.push_str(rest.get(..i).unwrap_or_default()); let after = rest.get(i + 2..).unwrap_or_default(); - let c = after - .get(..4) - .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32) - .filter(|&c| crate::lex::ident_part(c)); + let first = out.is_empty(); + let c = crate::lex::escaped(rest.get(i..).unwrap_or_default()) + .filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); match c { Some(c) => { out.push(c); diff --git a/crates/scriptcheck/src/analyze/features.rs b/crates/scriptcheck/src/analyze/features.rs index a0fdc084a..b5dd2299e 100644 --- a/crates/scriptcheck/src/analyze/features.rs +++ b/crates/scriptcheck/src/analyze/features.rs @@ -156,8 +156,6 @@ impl<'s, 'a, 'l> Analyzer<'s, 'a, 'l> { } else if n == catalog::DOLLAR_EVAL_FILE { self.acc.feature(FeatureId::DollarEvalFile, line); self.acc.outside(OutsideId::UnseenCode, line, None); - } else if n == catalog::LOCALIZE { - self.acc.feature(FeatureId::Localize, line); } else { self.acc.feature(FeatureId::Dollar, line); } diff --git a/crates/scriptcheck/src/catalog.rs b/crates/scriptcheck/src/catalog.rs index 50ddee3a1..7ee71c9ee 100644 --- a/crates/scriptcheck/src/catalog.rs +++ b/crates/scriptcheck/src/catalog.rs @@ -16,6 +16,8 @@ pub(crate) const DOLLAR_EVAL_FILE: &str = "evalFile"; // https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/ pub(crate) const NOTIFY: &[&str] = &["alert", "confirm", "prompt"]; +// The global function. `$.localize` is a property of `$` (the Dollar object page above), so it is +// a `$` member: // https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/ pub(crate) const LOCALIZE: &str = "localize"; diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index be66dd4b1..92d8c72e0 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -8,6 +8,10 @@ //! level and the levels of XML literals and their code are kept on stacks capped at the nesting //! limit. +use std::cmp::Ordering; + +use unicode_general_category::{GeneralCategory as G, get_general_category}; + use crate::catalog; use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE}; @@ -130,13 +134,105 @@ fn is_blank(c: char) -> bool { !is_break(c) && (c.is_whitespace() || c == '\u{feff}') } -fn ident_start(c: char) -> bool { - c == '$' || c == '_' || c.is_ascii_alphabetic() || (!c.is_ascii() && c.is_alphabetic()) +/// A character a name may start with: a Unicode `ID_Start` character, `$` or `_` (the Names and +/// Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, +/// Lm, Lo and Nl) and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode +/// Standard Annex #31). The categories come from Unicode 16.0. +pub(crate) fn ident_start(c: char) -> bool { + match c { + '$' | '_' => true, + _ if c.is_ascii() => c.is_ascii_alphabetic(), + _ if is_other_id_start(c) => true, + _ => { + matches!( + get_general_category(c), + G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber + ) && !is_pattern_syntax(c) + } + } } -/// A character a name may hold after its first. +/// A character a name may hold after its first: a Unicode `ID_Continue` character or `$`. +/// `ID_Continue` adds to `ID_Start` the decimal digits (Nd), combining marks (Mn, Mc), connector +/// punctuation (Pc, which holds `_`) and `Other_ID_Continue` (which holds the zero width non-joiner +/// and joiner), less `Pattern_Syntax` and `Pattern_White_Space`. pub(crate) fn ident_part(c: char) -> bool { - ident_start(c) || c.is_ascii_digit() || (!c.is_ascii() && c.is_alphanumeric()) + match c { + '0'..='9' => true, + _ if ident_start(c) => true, + _ if c.is_ascii() => false, + _ if is_other_id_continue(c) => true, + _ => { + matches!(get_general_category(c), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation) + && !is_pattern_syntax(c) + } + } +} + +/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start +/// with `\u` and four hexadecimal digits, or when they give a lone surrogate. +pub(crate) fn escaped(s: &str) -> Option { + s.strip_prefix("\\u")? + .get(..4) + .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32) +} + +/// `Other_ID_Start` (Unicode PropList 16.0): characters kept in `ID_Start` though their category is +/// not a letter. +fn is_other_id_start(c: char) -> bool { + matches!(c, '\u{1885}' | '\u{1886}' | '\u{2118}' | '\u{212E}' | '\u{309B}' | '\u{309C}') +} + +/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// category is not a letter, mark, digit or connector. +fn is_other_id_continue(c: char) -> bool { + matches!(c, '\u{B7}' | '\u{387}' | '\u{1369}'..='\u{1371}' | '\u{19DA}' | '\u{200C}' | '\u{200D}' | '\u{30FB}' | '\u{FF65}') +} + +/// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no +/// identifier character belongs to it. `Pattern_White_Space` outside ASCII (U+0085, U+200E, U+200F, +/// U+2028 and U+2029) holds no letter, mark, digit or connector, so the category checks leave it out. +const PATTERN_SYNTAX: [(char, char); 23] = [ + ('\u{A1}', '\u{A7}'), + ('\u{A9}', '\u{A9}'), + ('\u{AB}', '\u{AC}'), + ('\u{AE}', '\u{AE}'), + ('\u{B0}', '\u{B1}'), + ('\u{B6}', '\u{B6}'), + ('\u{BB}', '\u{BB}'), + ('\u{BF}', '\u{BF}'), + ('\u{D7}', '\u{D7}'), + ('\u{F7}', '\u{F7}'), + ('\u{2010}', '\u{2027}'), + ('\u{2030}', '\u{203E}'), + ('\u{2041}', '\u{2053}'), + ('\u{2055}', '\u{205E}'), + ('\u{2190}', '\u{245F}'), + ('\u{2500}', '\u{2775}'), + ('\u{2794}', '\u{2BFF}'), + ('\u{2E00}', '\u{2E7F}'), + ('\u{3001}', '\u{3003}'), + ('\u{3008}', '\u{3020}'), + ('\u{3030}', '\u{3030}'), + ('\u{FD3E}', '\u{FD3F}'), + ('\u{FE45}', '\u{FE46}'), +]; + +/// Whether a non-ASCII character is `Pattern_Syntax`. +fn is_pattern_syntax(c: char) -> bool { + PATTERN_SYNTAX + .binary_search_by(|&(lo, hi)| { + if hi < c { + Ordering::Less + } else if lo > c { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .is_ok() } /// After `<`, the start of an XML literal: a name, `>` (a list), `!` (a comment or CDATA), `?` (a @@ -300,20 +396,13 @@ impl<'s> Lexer<'s> { } } - /// The rest of a name (its first character included): letters, digits, `$`, `_` and `\u` - /// escapes. + /// The rest of a name (its first character included): the characters [`ident_part`] allows, + /// written out or as `\uXXXX` escapes. An escape for any other character, or one without four + /// hexadecimal digits, ends the name. fn name(&mut self) { loop { match self.peek() { - Some('\\') if self.peek_at(1) == Some('u') => { - self.skip(2); - for _ in 0..4 { - match self.peek() { - Some(h) if h.is_ascii_hexdigit() => self.bump(h), - _ => break, - } - } - } + Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6), Some(c) if ident_part(c) => self.bump(c), _ => break, } @@ -820,7 +909,7 @@ impl<'s> Iterator for Lexer<'s> { self.close_level(); self.bump('}'); return Some(self.markup_token(start, line, T::XmlMore, stmt, expr)); - } else if ident_start(c) || (c == '\\' && self.peek_at(1) == Some('u')) { + } else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) { self.name(); T::Ident } else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) { diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 21025a6e9..c7eed0c1a 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -35,6 +35,7 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[ ("directive.target", " //@target acme", "x(); //@target acme"), ("directive.targetengine", "#targetengine session", "var targetengine = 'session';"), ("dollar", "$.writeln('hi');", "log.writeln('hi');"), + ("dollar", "$.localize = true;", "obj.localize = true;"), ("dollar.env", "var home = $.getenv('HOME');", "var home = env.getenv('HOME');"), ("dollar.evalFile", "$.evalFile(f);", "loader.evalFile(f);"), ("dynamicCode", "eval(code);", "safe.eval(code);"), @@ -68,7 +69,6 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[ ("file.write", "Folder.temp.remove();", "cache.temp.remove();"), ("hostCheck", "if (parseInt(app.version) < 20) {}", "if (parseInt(appVersion) < 20) {}"), ("localize", "var s = localize(text);", "var s = text.localize;"), - ("localize", "var s = $.localize(text);", "var s = obj.localize(text);"), ("menuCommand", "app.executeMenuCommand('acmecopyall');", "executeMenuCommand('acmecopyall');"), ("notify", "alert('done');", "panel.alert('done');"), ("notify", "if (confirm('Go?')) {}", "function confirm(m) {}"), @@ -103,6 +103,15 @@ fn each_feature_is_found_where_it_is_and_nowhere_else() { } } +#[test] +fn the_dollar_objects_localize_property_is_not_the_global_localize_function() { + for src in ["$.localize = true;", "if ($.localize) {}", "var s = $.localize(text);"] { + let r = report(src); + assert!(feature(&r, "dollar").is_some(), "{src}: {:?}", r.features); + assert!(feature(&r, "localize").is_none(), "{src}: {:?}", r.features); + } +} + #[test] fn features_are_listed_in_alphabetical_order_of_their_ids() { let ids: Vec<&str> = FeatureId::ALL.iter().map(|f| f.id()).collect(); @@ -259,6 +268,99 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() { assert_eq!(r.lines, 3); } +/// `src` with the escapes in its names written as the characters they stand for. +fn unescaped(src: &str) -> String { + let limits = Limits::default(); + crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned() +} + +#[test] +fn names_are_made_of_the_unicode_identifier_characters() { + // U+2118 (Other_ID_Start) starts a name, and U+203F (connector punctuation), U+00B7 + // (Other_ID_Continue) and the zero width non-joiner and joiner continue one. + for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}"] { + assert_eq!(tokens(name), [(T::Ident, name)], "{name:?}"); + } + for (src, want) in [ + // Characters that only continue a name, and digits, don't start one. + ("\u{203F}a", [(T::Other, "\u{203F}"), (T::Ident, "a")]), + ("\u{B7}a", [(T::Other, "\u{B7}"), (T::Ident, "a")]), + ("\u{200C}a", [(T::Other, "\u{200C}"), (T::Ident, "a")]), + ("0a", [(T::Number, "0"), (T::Ident, "a")]), + // A superscript digit (category No) and a letter that is Pattern_Syntax are in no name. + ("a\u{B2}", [(T::Ident, "a"), (T::Other, "\u{B2}")]), + ("a\u{2E2F}", [(T::Ident, "a"), (T::Other, "\u{2E2F}")]), + ("\u{2E2F}a", [(T::Other, "\u{2E2F}"), (T::Ident, "a")]), + // `#` and a name away from the start of a line, and a regular expression's flags. + ("a #\u{2118}b", [(T::Ident, "a"), (T::Other, "#\u{2118}b")]), + ("=/a/g\u{B2}", [(T::RegExp, "/a/g"), (T::Other, "\u{B2}")]), + ] { + let mut got = tokens(src); + if src.starts_with('=') { + got.remove(0); + } + assert_eq!(got, want, "{src:?}"); + } + assert_eq!(tokens("a #\u{B2}"), [(T::Ident, "a"), (T::Other, "#"), (T::Other, "\u{B2}")]); + assert_eq!(tokens("=/a/g\u{200D}"), [(T::Punct, "="), (T::RegExp, "/a/g\u{200D}")]); + // The code in an XML literal's braces reads names by the same rule. + assert!(tokens("var x = {\u{2118}b + c\u{203F}d};").iter().filter(|t| t.0 == T::Ident).any(|t| t.1 == "c\u{203F}d")); + assert!(tokens("var x = {\u{2118}b};").contains(&(T::Ident, "\u{2118}b"))); +} + +#[test] +fn an_escape_stands_for_a_character_only_where_a_name_may_hold_it() { + for (src, want) in [ + ("\\u2118a", "\u{2118}a"), + ("a\\u203Fb", "a\u{203F}b"), + ("a\\u00B7b", "a\u{B7}b"), + ("a\\u200Cb", "a\u{200C}b"), + ("a\\u200Db", "a\u{200D}b"), + ("a\\u0030", "a0"), + ("\\u0024\\u005F", "$_"), + // Not at the start of a name. + ("\\u0030abc", "\\u0030abc"), + ("\\u203Fa", "\\u203Fa"), + ("\\u00B7a", "\\u00B7a"), + // In no name. + ("a\\u00B2", "a\\u00B2"), + ("a\\u2E2F", "a\\u2E2F"), + ("\\u2E2F", "\\u2E2F"), + // Malformed escapes and a lone surrogate. + ("\\u", "\\u"), + ("a\\u12;", "a\\u12;"), + ("a\\uZZZZ", "a\\uZZZZ"), + ("a\\uD800", "a\\uD800"), + ("", ""), + ] { + assert_eq!(unescaped(src), want, "{src:?}"); + } + let r = report("\\u0061pp.a\\u203Fb;\napp.\\u2118c;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.a\u{203F}b", "app.\u{2118}c"]); + // An escape a name may not hold where it stands, or a malformed one, is not part of a name. + for (src, want) in [ + ("\\u0030a", [(T::Other, "\\"), (T::Ident, "u0030a")]), + ("a\\u00B2", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\u12", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\uD800", [(T::Ident, "a"), (T::Other, "\\")]), + ] { + assert_eq!(tokens(src).get(..2), Some(&want[..]), "{src:?}"); + } + assert_eq!(tokens("a\\u0030\\u200C"), [(T::Ident, "a\\u0030\\u200C")]); + let r = report("app.\\u0030abc;\napp.a\\u00B2;\n"); + assert!(r.members.iter().all(|m| !m.name.contains('\\') && !m.name.contains("0abc")), "{:?}", r.members); +} + +#[test] +fn malformed_escapes_in_bulk_are_read_once_through() { + let src = format!("{}\n{}\nalert(1);\n", "\\u\\uD800\\u12\\u0030\\u2118".repeat(20_000), "a\u{203F}".repeat(20_000)); + let r = report(&src); + assert_eq!(r.lines, 3); + assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..])); + assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); +} + #[test] fn the_scripts_own_platform_names_are_not_the_platforms() { let r = report(concat!( diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 78113dcd8..115d6f709 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -160,10 +160,13 @@ pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { /// written by `len`. pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { let mut p = Props::new(); - // Tracking and manual kerning both add space after every character. + // Tracking and manual kerning both add space after every character, in thousandths of the em + // of the font size written (the vertical scale, see `font_props`). The canvas spaces them by + // the horizontal scale: a uniform scale is the same, and `textLength` stretches type scaled + // horizontally alone, glyphs and spacing together, from the one to the other. let spacing = st.tracking + st.kerning.unwrap_or(0.0); if spacing != 0.0 { - p.push(("letter-spacing", len(spacing / 1000.0 * st.size))); + p.push(("letter-spacing", len(spacing / 1000.0 * st.size * st.v_scale / 100.0))); } if st.kerning.is_some() { // Manual kerning replaces the font's pair kerning. @@ -606,12 +609,9 @@ impl Rules<'_> { if n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { art.unsupported("fills or strokes on the type object"); } - // Area type that Shrink Text to Fit scales down is written in the styles it draws in. - let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. })) - .then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale) - .filter(|f| *f != 1.0) - .map(|f| vectorcraft_text::scaled(t, f)); - let t = fitted.as_ref().unwrap_or(t); + // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in. + let fitted = vectorcraft_text::drawn(t); + let t = &*fitted; let st = t.first_style(); if t.runs.iter().any(|r| r.style != st) { self.warn("type with several character styles is written in its first one"); diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 129bd83e2..746ab84d2 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -633,6 +633,10 @@ impl Writer<'_> { /// An object, inside `` when it links to a URL (Attributes panel). pub(super) fn node(&mut self, n: &Node) { + // Ruler guides are not written, not even as a link. + if n.is_ruler_guide() { + return; + } let layer = usize::from(n.is_layer()); self.layer_nest += layer; match n.url().filter(|_| n.visible && !self.anonymous) { @@ -717,7 +721,7 @@ impl Writer<'_> { self.line(""); } } - NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => {} NodeKind::Path { path, rule, .. } => { if path.is_empty() { return; diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index fb06f8005..4f91d6c97 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -170,6 +170,10 @@ impl Writer<'_> { } let mut named: Vec<(NodeId, String)> = Vec::new(); self.doc.walk(|n| { + // Ruler guides are not written, so their names reserve no id. + if n.is_ruler_guide() { + return; + } if let Some(name) = &n.name { named.push((n.id, sanitize_id(name))); } diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 19bbbd5be..b12811338 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -534,8 +534,11 @@ impl Writer<'_> { // The element's user space is the document's; gradients span the laid-out text. let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); self.note_fonts(t, &lay); + // The styles the layout draws the text in: Dynamic Text scales its sizes, leading and baseline shifts. + let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); + let drawn = fitted.as_ref().unwrap_or(t); let space = (lay.bounds, self.xf * t.xf); - let base = self.char_props(&t.first_style(), space); + let base = self.char_props(&drawn.first_style(), space); let mut props = base.clone(); let (from, to) = t.kind.path_span().unwrap_or((start, 1.0)); // The anchor sits where the layout aligns the line within its span. @@ -556,7 +559,7 @@ impl Writer<'_> { let a = self.attrs(&props); let offset = fmt_num(at * 100.0, 3); let mut s = format!(""); - for run in &t.runs { + for run in &drawn.runs { let mut diff = run_diff(&base, self.char_props(&run.style, space)); diff.extend(direction_props(&run.style)); let a = self.attrs(&diff); diff --git a/crates/svg/src/glyph.rs b/crates/svg/src/glyph.rs index c019f9b0a..22697f58a 100644 --- a/crates/svg/src/glyph.rs +++ b/crates/svg/src/glyph.rs @@ -44,18 +44,20 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti // usvg resolves `context-fill` and `context-stroke` only for markers. let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex)); let (doc, _) = crate::import::import(&svg, &ImportOptions::default(), crate::MAX_NODES).ok()?; + // A layer's art, its ruler guides left out. + let art_of = |l: &Node| l.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect::>(); let mut art = if whole { - Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect()) + Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), art_of(l)))).collect()) } else { // The glyph, top-level, is a layer when a ``, else an object named after its id. let named = |n: &Node| n.name.as_deref() == Some(id.as_str()); match doc.layers.iter().find(|l| named(l)) { - Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()), + Some(layer) => Node::group(NodeId(0), art_of(layer)), None => { let mut found = None; for layer in &doc.layers { layer.walk(&mut |n| { - if found.is_none() && named(n) { + if found.is_none() && !n.is_ruler_guide() && named(n) { found = Some(n.clone()); } }); @@ -199,7 +201,7 @@ fn own_art(n: &Node, doc: &Document) -> Option { im.pixels = Some(Arc::new(blob.clone())); } if let Some(children) = n.children_mut() { - *children = children.iter().filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); + *children = children.iter().filter(|c| !c.is_ruler_guide()).filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); } Some(n) } diff --git a/crates/svg/src/import/convert.rs b/crates/svg/src/import/convert.rs index d8cf3bcd5..8bdadb031 100644 --- a/crates/svg/src/import/convert.rs +++ b/crates/svg/src/import/convert.rs @@ -192,49 +192,58 @@ const MAX_CLIP_DEPTH: usize = 16; /// multiply the work at every level: without this, a few lines of SVG would never finish. const MAX_CLIP_READS: usize = 1024; -/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document. -pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> { +/// The shapes a text placeholder path in a clip path stands for (the text's glyph outlines), given +/// the path and the transform `m` that maps its user space to the document; `None` for any other +/// path. +pub(super) type ClipText<'a> = &'a dyn Fn(&usvg::Path, Affine) -> Option>; + +/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document, with the +/// glyph outlines `text` gives for the text in it. +pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine, text: ClipText) -> Vec<(BezPath, FillRule)> { let mut reads = MAX_CLIP_READS; - clip_shapes_depth(cp, ts, 0, &mut reads) + clip_shapes_depth(cp, ts, 0, &mut reads, text) } /// Recursively collect shapes for `cp` in document space up to `MAX_CLIP_DEPTH`, and while `reads` /// are left. -fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize) -> Vec<(BezPath, FillRule)> { +fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize, text: ClipText) -> Vec<(BezPath, FillRule)> { if depth > MAX_CLIP_DEPTH || *reads == 0 { return Vec::new(); } *reads -= 1; - fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, out: &mut Vec<(BezPath, FillRule)>) { + fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, text: ClipText, out: &mut Vec<(BezPath, FillRule)>) { for c in g.children() { match c { usvg::Node::Group(g) => { let m_child = m * aff(g.transform()); if let Some(child_cp) = g.clip_path() { let mut child_shapes = Vec::new(); - collect(g, m_child, depth + 1, reads, &mut child_shapes); + collect(g, m_child, depth + 1, reads, text, &mut child_shapes); if !child_shapes.is_empty() { - let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads); + let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads, text); intersect_shapes(&child_shapes, &clip_paths, out); } } else { - collect(g, m_child, depth, reads, out); + collect(g, m_child, depth, reads, text, out); } } - usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + usvg::Node::Path(p) => match text(p, m) { + Some(glyphs) => out.extend(glyphs), + None => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + }, _ => {} } } } let mut shapes = Vec::new(); - collect(cp.root(), ts * aff(cp.transform()), depth, reads, &mut shapes); + collect(cp.root(), ts * aff(cp.transform()), depth, reads, text, &mut shapes); if !shapes.is_empty() && depth > 0 && depth < MAX_CLIP_DEPTH && *reads > 0 && let Some(parent_cp) = cp.clip_path() { - let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads); + let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads, text); let mut intersected = Vec::new(); intersect_shapes(&shapes, &parent_shapes, &mut intersected); shapes = intersected; @@ -267,7 +276,7 @@ fn intersect_shapes(shapes: &[(BezPath, FillRule)], clips: &[(BezPath, FillRule) /// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`, /// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.) -pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool { +pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine, text: ClipText) -> bool { let Some(b) = art.visual_bounds() else { return false }; if cp.clip_path().is_some() { return false; @@ -275,7 +284,7 @@ pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> // Art touching the clip's edge isn't cut by it. let e = 1e-6 * (b.width() + b.height()).max(1e-9); let b = b.inflate(-e, -e); - let shapes = clip_shapes(cp, ts); + let shapes = clip_shapes(cp, ts, text); let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c))) } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 6324e5380..ca31f28b8 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -194,7 +194,7 @@ impl Importer { _ => self.named("", NodeKind::Group { children: kids, clip: false })?, }; // usvg clips a `` of a symbol to its viewport: that only counts when it cuts the art. - let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf)); + let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf, &|p, m| self.clip_text(p, m))); let name = match self.symbols.get(&sym) { Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(), Some(_) => return None, @@ -500,26 +500,32 @@ impl Importer { } /// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer - /// becomes a clip group in it, holding the art it clipped. + /// becomes a clip group in it, holding the art it clipped. The layer's ruler guides stay last, + /// outside the clip group. pub(super) fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec>) { if art.is_empty() { return; } if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind { *clip = false; - let clipped = std::mem::take(children); + let mut clipped = std::mem::take(children); + let guides = clipped.split_off(Node::guide_run_start(&clipped)); if let Some(group) = self.named("", NodeKind::Group { children: clipped, clip: true }) { children.push(Arc::new(group)); } + children.extend(guides); } if let Some(ch) = l.children_mut() { - let i = index.min(ch.len()); + let i = index.min(Node::guide_run_start(ch)); ch.splice(i..i, art); } } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { - let mut shapes = clip_shapes(cp, ts); + if let Some(n) = self.clip_type(cp, ts) { + return Some(n); + } + let mut shapes = clip_shapes(cp, ts, &|p, m| self.clip_text(p, m)); let id = cp.id().to_string(); if shapes.len() == 1 { let (bp, r) = shapes.pop()?; @@ -540,6 +546,53 @@ impl Importer { self.named(&id, NodeKind::Group { children, clip: false }) } + /// The glyph outlines, in the document, of the text a clip placeholder path `p` stands for (see + /// [`text`]), whose user space `m` maps to the document; nothing when the text is hidden. `None` + /// when `p` isn't a placeholder: in a clip path only its id tells (usvg paints clip path content + /// black). + fn clip_text(&self, p: &usvg::Path, m: Affine) -> Option> { + if p.id().is_empty() { + return None; + } + let i = self.slots.find(p)?; + let t = self.slots.texts.get(i)?; + if !p.is_visible() { + return Some(vec![]); + } + let mut bp = BezPath::clone(&*self.slots.outlines(i)?); + bp.apply_affine(m * self.slots.inner(p) * t.obj.xf); + let r = p.fill().map(|f| rule(f.rule())).unwrap_or_default(); + Some(if bp.elements().is_empty() { vec![] } else { vec![(bp, r)] }) + } + + /// Clip path `cp` (on an element whose user space `ts` maps to the document) as live type, when + /// all it holds is one visible text that clips by the non-zero rule and has no clip path of its + /// own: the text object with no fill or stroke, the same structure Make Clipping Mask produces + /// for type. + fn clip_type(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { + let mut m = ts * aff(cp.transform()); + let mut kids = cp.root().children(); + while let [usvg::Node::Group(g)] = kids + && g.clip_path().is_none() + { + m *= aff(g.transform()); + kids = g.children(); + } + let [usvg::Node::Path(p)] = kids else { return None }; + if p.id().is_empty() || !p.is_visible() || p.fill().is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { + return None; + } + let mut obj = self.slots.texts.get(self.slots.find(p)?)?.obj.clone(); + obj.xf = m * self.slots.inner(p) * obj.xf; + let grow = text::size_scale(&obj, obj.xf); + for run in &mut obj.runs { + run.style.fill = Paint::None; + run.style.stroke = Paint::None; + } + text::fold_scale(&mut obj, grow); + self.named(cp.id(), NodeKind::Text(Box::new(obj))) + } + /// One clip path child: a path, or a compound path of its subpaths when it has several. fn clip_shape(&mut self, bp: &BezPath, r: FillRule, clipping: bool) -> Option { let subs = PathData::from_bezpath(bp).subpaths; diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs index 04e388794..15a77e37f 100644 --- a/crates/svg/src/import/layers.rs +++ b/crates/svg/src/import/layers.rs @@ -146,6 +146,7 @@ pub(super) fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option< show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }) .collect(); (!pages.is_empty()).then_some(pages) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 2502bd2bd..b11ce6af3 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -10,6 +10,13 @@ //! gradients and patterns in the text's user space; the importer swaps each placeholder for the //! [`PendingText`] it stands for. //! +//! usvg accepts only shapes, `` and `` as the children of a ``, so a `` +//! there (and a `` there that shows one) becomes a placeholder path instead, which the importer +//! finds by its id (`{prefix}{i}-…`). The importer clips by the text's glyph outlines, or uses the +//! text itself as live type. A `` in a clip path that has an id also gets the usual +//! placeholder group after its path, and a `` there with an id keeps a copy in a `` +//! after its path, for `` elements outside the clip path. +//! //! Characters keep SVG's per-character `x`/`y`/`dx`/`dy`/`rotate` lists: a character with an //! absolute `x` that moves down by at least half a line starts a new line (the leading becomes the //! line spacing), other vertical moves become baseline shift, horizontal moves become kerning (an @@ -166,14 +173,21 @@ pub(super) struct PendingText { pub(super) struct TextSlots { prefix: String, pub texts: Vec, + /// The transform between a clip placeholder path's user space and its text's, by placeholder + /// id, where the placeholder leaves it out (see [`clip_uses`]). + inner: HashMap, + /// The glyph outlines of each text laid out so far, in text space, by index. + outlines: RefCell>>, } impl TextSlots { - /// The text a placeholder's main path stands for: by its id, or inside `` instances - /// (where usvg drops ids) by its colour (the index) and its rectangle. + /// The text a placeholder's main path stands for: by its id (`{prefix}{index}`, or + /// `{prefix}{index}-{k}` for the clip placeholders), or inside `` instances (where usvg + /// drops ids) by its colour (the index) and its rectangle. pub fn find(&self, p: &usvg::Path) -> Option { if !p.id().is_empty() { - return p.id().strip_prefix(&self.prefix)?.parse().ok(); + let rest = p.id().strip_prefix(&self.prefix)?; + return rest.split_once('-').map_or(rest, |(i, _)| i).parse().ok(); } let Some(usvg::Paint::Color(c)) = p.fill().map(|f| f.paint()) else { return None }; if p.stroke().is_some() || p.data().segments().count() != 5 { @@ -185,6 +199,23 @@ impl TextSlots { let near = |a: f32, b: f64| (a as f64 - b).abs() <= 1e-3 * b.abs().max(1.0); (near(b.left(), m.x0) && near(b.top(), m.y0) && near(b.right(), m.x1) && near(b.bottom(), m.y1)).then_some(i) } + + /// The transform from the user space of clip placeholder path `p` to the space of the text it + /// stands for (the identity unless [`clip_uses`] left the text's transform out of it). + pub fn inner(&self, p: &usvg::Path) -> Affine { + self.inner.get(p.id()).copied().unwrap_or(Affine::IDENTITY) + } + + /// The glyph outlines of text `i` in text space, laid out once however many clips use it. + pub fn outlines(&self, i: usize) -> Option> { + if let Some(bp) = self.outlines.borrow().get(&i) { + return Some(bp.clone()); + } + let t = self.texts.get(i)?; + let bp = Rc::new(vectorcraft_text::layout(FontDb::global(), &t.obj).to_bezpath()); + self.outlines.borrow_mut().insert(i, bp.clone()); + Some(bp) + } } /// Replace every `` usvg would draw by a placeholder group (see the module docs). @@ -200,10 +231,11 @@ pub(super) fn prepare<'s>( while xml.descendants().any(|n| n.attribute("id").is_some_and(|id| id.starts_with(&prefix))) { prefix.insert(0, '_'); } - let mut slots = TextSlots { prefix, texts: vec![] }; + let mut slots = TextSlots { prefix, ..TextSlots::default() }; let style_xml = original_xml.unwrap_or(xml); let cx = Ctx::new(style_xml, dpi); - let mut out = String::new(); + // The replacements, in source order: each element's range and what takes its place. + let mut edits: Vec<(Range, String)> = vec![]; let mut copied = 0; let is_text = |n: &XNode| { n.is_element() @@ -211,52 +243,227 @@ pub(super) fn prepare<'s>( && n.tag_name().namespace() == Some(SVG_NS) && !n.ancestors().any(|a| a.has_tag_name("style")) }; + // The slot of each text, by element, for the `` elements in clip paths below. + let mut slot_of = HashMap::new(); for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); - if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") { + // CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement): + // text is clip path content only as a child of the ``. + let clip_child = in_clip_path(t); + if !clip_child && t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") { warnings.push(format!("{label} in a clip path ignored")); continue; } let r = t.range(); - // The element's qualified name (`text` or `svg:text`), so the placeholder lands in the same - // namespace; skip anything whose source range isn't the element itself (entity expansions). - let Some(qname) = svg - .get(r.start..r.end) - .and_then(|s| s.strip_prefix('<')) - .map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("")) - else { - continue; - }; + let Some(q) = qualifier(svg, t) else { continue }; let flowed = t.tag_name().name() == "flowRoot"; - let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue }; if cx.css.own(style_t, "display").as_deref() == Some("none") || r.start < copied { continue; } let built = if flowed { cx.build_flow(style_t, &label, warnings) } else { cx.build(style_xml, style_t, &label, warnings) }; let Some((text, paints)) = built else { continue }; - out.push_str(&svg[copied..r.start]); - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, slots.texts.len(), text.marker, &paints); + let mut out = String::new(); + let index = slots.texts.len(); + if clip_child { + let id = format!("{}{index}-c", slots.prefix); + let props = clip_props(&cx.css, Some(style_t), None); + clip_placeholder(&mut out, q, &id, &own_transform(&cx.css, style_t), &props, t, text.marker); + // A `` elsewhere can still show the text by its id: the clip path leaves out this + // group (a group isn't clip path content), but the `` finds it. + if t.attribute("id").is_some() { + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + } + } else { + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + } copied = r.end; + edits.push((r, out)); + slot_of.insert(t.id(), index); slots.texts.push(text); } if slots.texts.is_empty() { return (Cow::Borrowed(svg), slots); } - out.push_str(&svg[copied..]); + clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings); + edits.sort_by_key(|(r, _)| r.start); + let mut out = String::with_capacity(svg.len()); + let mut copied = 0; + for (r, s) in edits { + if r.start < copied { + continue; + } + out.push_str(svg.get(copied..r.start).unwrap_or("")); + out.push_str(&s); + copied = r.end; + } + out.push_str(svg.get(copied..).unwrap_or("")); (Cow::Owned(out), slots) } +/// Is `n` a child of a ``? +fn in_clip_path(n: XNode) -> bool { + n.parent_element().is_some_and(|p| p.tag_name().name() == "clipPath" && p.tag_name().namespace() == Some(SVG_NS)) +} + +/// The namespace prefix of element `n` as the source writes it (empty, or `svg:` and the like), so +/// what replaces it lands in the same namespace. `None` when its source range isn't the element +/// itself (entity expansions). +fn qualifier<'s>(svg: &'s str, n: XNode) -> Option<&'s str> { + let r = n.range(); + let qname = svg + .get(r.start..r.end) + .and_then(|s| s.strip_prefix('<')) + .map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""))?; + qname.strip_suffix(n.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) +} + +/// Replace each `` in a clip path that shows a text by the clip placeholder of that text, +/// placed by the `` (usvg drops ids and paints clip path content black, so the text a +/// placeholder group stands for couldn't be found again inside one). +#[allow(clippy::too_many_arguments)] +fn clip_uses( + svg: &str, + xml: &roxmltree::Document, + style_xml: &roxmltree::Document, + cx: &Ctx, + slot_of: &HashMap, + slots: &mut TextSlots, + edits: &mut Vec<(Range, String)>, + warnings: &mut Vec, +) { + let is_use = |n: &XNode| n.is_element() && n.tag_name().name() == "use" && n.tag_name().namespace() == Some(SVG_NS) && in_clip_path(*n); + if !xml.descendants().any(|n| is_use(&n)) { + return; + } + let (ids, style_ids) = (first_ids(xml), first_ids(style_xml)); + for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() { + let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; + let Some(t) = ids.get(id) else { continue }; + let Some(&i) = slot_of.get(&t.id()) else { continue }; + let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let Some(q) = qualifier(svg, u) else { continue }; + // An undisplayed `` adds nothing to the clip (usvg leaves it out as it is). + if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") { + continue; + } + let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()); + let em = cx.font_size(style_u); + let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); + let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); + let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u)); + let text_xf = match style_t { + Some(s) => own_transform(&cx.css, s), + None => t.attribute("transform").unwrap_or("").to_string(), + }; + // One element can carry one clip path. The text's own clip path is in the text's user space + // and wins; a clip path of the `` is in the ``'s, so the text's transform moves + // to the importer instead. + let clipped = |n: XNode| cx.css.own(n, "clip-path").is_some_and(|v| v.trim() != "none"); + let id = format!("{}{i}-{k}", slots.prefix); + let transform = if style_t.is_some_and(clipped) { + if clipped(style_u) { + let label = u.attribute("id").map_or_else(|| "a use of a text".to_string(), |id| format!("'{id}'")); + warnings.push(format!("clip path of {label} in a clip path ignored: the text's own applies")); + } + format!("{placed} {text_xf}") + } else { + slots.inner.insert(id.clone(), parse_transform(Some(&text_xf))); + placed + }; + let props = clip_props(&cx.css, style_t, Some(style_u)); + let mut out = String::new(); + clip_placeholder(&mut out, q, &id, &transform, &props, u, marker); + // Another `` can still show this one by its id: a copy of it stays in a ``, which + // the clip path leaves out, with the properties style rules gave it written out. + if u.attribute("id").is_some() { + use_copy(&mut out, q, &cx.css, u, style_u); + } + edits.push((u.range(), out)); + } +} + +/// Write a copy of `` `u` in a ``: its attributes in no namespace, its link as `href`, +/// and the properties set on it by rules and its `style` (from `style_u`) as its `style`. +fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) { + let _ = write!(out, "<{q}defs><{q}use"); + for a in u.attributes().filter(|a| a.namespace().is_none() && !matches!(a.name(), "style" | "href")) { + let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value())); + } + if let Some(h) = href(u) { + let _ = write!(out, " href=\"{}\"", xml_escape(h)); + } + let declared = css.declared(style_u); + let mut props: Vec<_> = declared.iter().collect(); + props.sort(); + let style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect(); + let _ = write!(out, " style=\"{}\"/>", xml_escape(&style)); +} + +/// The identity transform, as a `transform` value usvg reads. +const IDENTITY: &str = "matrix(1 0 0 1 0 0)"; + +/// The transform of element `n`: a `transform` declaration in a rule or its `style` (which the +/// prepass has written in SVG syntax), else its `transform` attribute; empty when it has none. +fn own_transform(css: &Styles, n: XNode) -> String { + css.own(n, "transform").filter(|v| !v.eq_ignore_ascii_case("none")).unwrap_or_default() +} + +/// Each id of `doc` with the first element that has it (a reference goes to that one, as in usvg). +fn first_ids<'a, 'i>(doc: &'a roxmltree::Document<'i>) -> HashMap<&'a str, XNode<'a, 'i>> { + let mut ids = HashMap::new(); + for n in doc.descendants() { + if let Some(id) = n.attribute("id") { + ids.entry(id).or_insert(n); + } + } + ids +} + +/// The properties of clip path content that the placeholder of text `t` keeps, as a `style` value: +/// its `clip-path` (or that of the `` `u` showing it, when the text has none), and its +/// computed `clip-rule` and `visibility`, inherited from `u` when there is one. Every value is +/// written out, so no style rule that matches the placeholder path applies to it. +fn clip_props(css: &Styles, t: Option, u: Option) -> String { + let set = |n: XNode, p: &str| css.own(n, p).filter(|v| v.trim() != "inherit"); + let clip = |n: XNode| css.own(n, "clip-path").filter(|v| v.trim() != "none"); + let mut style = format!("clip-path:{};", t.and_then(clip).or_else(|| u.and_then(clip)).unwrap_or_else(|| "none".into())); + for (p, initial) in [("clip-rule", "nonzero"), ("visibility", "visible")] { + let v = t.and_then(|t| set(t, p)).or_else(|| u.or(t).and_then(|n| css.prop(n, p))).unwrap_or_else(|| initial.into()); + let _ = write!(style, "{p}:{v};"); + } + style +} + +/// Write the placeholder of a text in a clip path: a path over the text's bounds `b` with id `id`, +/// transform `transform`, properties `props` ([`clip_props`]) and the conditions of element `n`. +/// usvg drops a group in a clip path and paints clip path content black whatever its fill, so the +/// placeholder is a path, and the importer finds it by its id ([`TextSlots::find`]). +fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: &str, n: XNode, b: Rect) { + let _ = write!(out, "<{q}path id=\"{}\"", xml_escape(id)); + for a in ["systemLanguage", "requiredFeatures", "requiredExtensions"] { + if let Some(v) = n.attribute(a) { + let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); + } + } + // In `style`, so no rule that matches the path moves it. + let transform = if transform.trim().is_empty() { IDENTITY } else { transform }; + let _ = write!(out, " style=\"transform:{};{}display:inline\"", xml_escape(transform), xml_escape(props)); + let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0); +} + /// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. #[allow(clippy::too_many_arguments)] fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { let _ = write!(out, "<{q}g"); - for a in ["id", "transform", "systemLanguage", "requiredFeatures", "requiredExtensions"] { + for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] { if let Some(v) = t.attribute(a) { let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); } } - // The text's own non-inherited properties, overriding any rule that matches the ``. - let mut style = String::new(); + // The text's own non-inherited properties, overriding any rule that matches the ``. The + // transform too: a `transform` declaration in a rule or `style` beats the attribute. + let transform = own_transform(css, style_t); + let mut style = format!("transform:{};", if transform.is_empty() { IDENTITY } else { &transform }); for (p, initial) in [("opacity", "1"), ("clip-path", "none"), ("mask", "none"), ("filter", "none"), ("mix-blend-mode", "normal"), ("isolation", "auto")] { @@ -853,6 +1060,7 @@ impl Ctx { } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); } + keep_reader_anchors(&mut obj); // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0)); @@ -1057,6 +1265,55 @@ fn lines( (Justify::Left, left, None) } +/// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` +/// aligns a line's advance, which ends after the letter spacing of its last character (SVG +/// export anchors lines so); VectorCraft aligns a line without that tracking +/// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the +/// type moves when every line moves alike, otherwise each paragraph's right indent takes the +/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing +/// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end. +fn keep_reader_anchors(obj: &mut TextObject) { + let share = |j: Justify| match j { + Justify::Center => 0.5, + Justify::Right => 1.0, + _ => 0.0, + }; + // Laid out only when a line may move: tracked type with a centred or right-aligned paragraph. + if !matches!(obj.kind, TextKind::Point) + || obj.vertical + || obj.runs.iter().all(|r| r.style.tracking == 0.0) + || obj.paragraph_styles().iter().all(|p| share(p.justify) == 0.0) + { + return; + } + let plain = obj.plain_text(); + // Each line's paragraph, its tracking left out and how far that moves it. + let lines: Vec<(usize, f64, f64)> = measure(obj) + .lines + .iter() + .map(|l| { + let para = plain.get(..l.start).map_or(0, |before| before.matches('\n').count()); + let trailing = if l.trailing.is_finite() { l.trailing } else { 0.0 }; + (para, trailing, share(obj.para_at(para).justify) * trailing) + }) + .collect(); + let Some(&(_, _, first)) = lines.first() else { return }; + if first == 0.0 && lines.iter().all(|l| l.2 == 0.0) { + return; + } + if lines.iter().all(|l| (l.2 - first).abs() < 1e-9) { + obj.xf *= Affine::translate((-first, 0.0)); + return; + } + let mut styles = obj.paragraph_styles(); + for &(para, trailing, moves) in &lines { + if let Some(style) = styles.get_mut(para).filter(|_| moves != 0.0) { + style.right_indent += trailing; + } + } + obj.set_paragraph_styles(styles); +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a3ad7a069..8a447f6c3 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -78,8 +78,11 @@ //! 64 periods). //! * `` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG //! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. -//! * Text lines after the first start at the first line's x; text in a clip path is ignored; -//! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways. +//! * Text lines after the first start at the first line's x; absolute positions inside type on a +//! path are ignored; vertical text sets every glyph sideways. +//! * A `` in a ``, or a `` there that shows one, clips by its glyph outlines. +//! When a clip path holds only one text, and that text has no clip path of its own and no +//! even-odd clip rule, the clip group's clipping path is the text as live type that paints nothing. //! //! ## Import text //! diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs new file mode 100644 index 000000000..07773dd5f --- /dev/null +++ b/crates/svg/tests/it/clip_text.rs @@ -0,0 +1,429 @@ +//! SVG import of clip paths made of text (CSS Masking Module Level 1 §6.1, +//! https://www.w3.org/TR/css-masking-1/#ClipPathElement: `` and `` are clip path +//! content). + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Document, Node, NodeKind, TextKind}; +use vectorcraft_geom::kurbo::Shape; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_svg::{ExportOptions, export, import, import_with_report}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +fn open(body: &str) -> Document { + import(&svg(body)).unwrap_or_else(|e| panic!("{e}")) +} + +/// The top-level objects of the first layer. +fn top(d: &Document) -> &[std::sync::Arc] { + d.layers[0].children().unwrap() +} + +/// The clip group `n` is, with its clipping path and the art it clips. +fn clip_group(n: &Node) -> (&Node, &[std::sync::Arc]) { + let NodeKind::Group { children, clip: true } = &n.kind else { panic!("not a clip group: {:?}", n.kind) }; + let (first, rest) = children.split_first().unwrap(); + (first, rest) +} + +/// The bounds of the region a clipping path clips to. +fn clip_bounds(clip: &Node) -> Rect { + let (bp, _) = vectorcraft_effects::clip_outline(clip).expect("the clipping path clips to something"); + bp.bounding_box() +} + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn a_text_clip_path_clips_by_the_text_as_live_type() { + let d = open( + r##"HI + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 1, "{objects:?}"); + let (clip, art) = clip_group(&objects[0]); + assert_eq!(art.len(), 1); + assert_eq!(art[0].name.as_deref(), Some("art")); + // The clipping path is the text object with no fill or stroke, as after Make Clipping Mask. + let NodeKind::Text(t) = &clip.kind else { panic!("the clipping path is {:?}", clip.kind) }; + assert_eq!(t.plain_text(), "HI"); + assert!(t.runs.iter().all(|r| r.style.fill == Paint::None && r.style.stroke == Paint::None)); + assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size); + assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf); + let p = clip.clip_paint(); + assert!(p.fill.is_none() && p.stroke.is_none(), "a clip path paints nothing"); + // It clips to the glyphs: inside the line, not the whole rectangle. + let b = clip_bounds(clip); + assert!(b.x0 >= 20.0 - 1e-6 && b.x1 < 220.0 && b.y1 <= 150.0 + 1e-6 && b.y0 > 50.0 && b.width() > 40.0, "{b:?}"); +} + +#[test] +fn text_and_shapes_in_one_clip_path_clip_to_their_union() { + let d = open( + r##"O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 2, "the rectangle and the glyph outlines: {shapes:?}"); + let rect = shapes[0].0.bounding_box(); + assert!(near(rect.x0, 300.0, 1e-6) && near(rect.y1, 50.0, 1e-6), "{rect:?}"); + let glyphs = shapes[1].0.bounding_box(); + assert!(glyphs.x0 >= 20.0 - 1e-6 && glyphs.x1 < 120.0 && glyphs.y1 < 160.0 && glyphs.y0 > 50.0, "{glyphs:?}"); + let b = clip_bounds(clip); + assert!(near(b.x1, 350.0, 1e-6) && near(b.y0, 10.0, 1e-6), "{b:?}"); +} + +#[test] +fn a_text_clip_path_keeps_its_units_and_transforms() { + // The clip path's box units and the text's own transform place the type. + let d = open( + r##" + I + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let b = clip_bounds(clip); + // The box maps x 0.5…1 to 200…300 and y 0…1 to 100…200. + assert!(b.x0 >= 200.0 - 1e-6 && b.x1 <= 300.0 && b.y0 >= 100.0 && b.y1 <= 180.0 + 1e-6 && b.height() > 30.0, "{b:?}"); + // The clip path's transform moves it, as it moves a shape in it. + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_text_clip_path_with_a_clip_rule_or_a_clip_of_its_own_clips_by_its_outlines() { + // The text's own clip path cuts its glyphs: the clip keeps their part inside the rectangle. + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(!matches!(clip.kind, NodeKind::Text(_)), "outlines, as the clip of the text cuts them"); + let b = clip_bounds(clip); + assert!(b.y1 <= 120.0 + 1e-6 && b.y0 > 50.0 && b.x0 >= 20.0 - 1e-6, "{b:?}"); + // An even-odd text clip keeps its rule on the outlines. + let d = open( + r##"O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd); +} + +#[test] +fn tspans_and_type_on_a_path_clip_too() { + let d = open( + r##"AB + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.plain_text().contains('A') && t.plain_text().contains('B'), "{:?}", t.plain_text()); + let b = clip_bounds(clip); + assert!(b.y0 < 80.0 && b.y1 > 150.0, "both lines clip: {b:?}"); + + let d = open( + r##" + Around + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind); + assert!(clip_bounds(clip).width() > 50.0); +} + +#[test] +fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf); + // Font-relative positions count in the ``'s font size; an undisplayed `` adds nothing. + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("only the displayed use: {:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(80.0, 120.0)) < 1e-9, "{:?}", t.xf); + // The same text drawn as well as used in a clip path. + let d = open( + r##"HI + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + assert!(matches!(objects[0].kind, NodeKind::Text(_))); + let (clip, _) = clip_group(&objects[1]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 2, "{shapes:?}"); + let glyphs = shapes[0].0.bounding_box(); + assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}"); +} + +#[test] +fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() { + // The text's transform moves its glyphs to x 200…; the ``'s clip keeps x < 250 of its own + // user space, which the text's transform doesn't move. + let d = open( + r##" + HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(b.x0 >= 200.0 - 1e-6 && near(b.x1, 250.0, 1e-6), "{b:?}"); + // Without a clip on the ``, the text's transform still places the type. + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(200.0, 160.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_css_transform_places_a_text_in_a_clip_path() { + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn css_transforms_place_a_use_of_a_text_in_a_clip_path() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(25.0, 150.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_use_refers_to_the_first_element_with_its_id() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(!matches!(clip.kind, NodeKind::Text(_)), "the rectangle clips: {:?}", clip.kind); + let b = clip_bounds(clip); + assert!(near(b.x0, 300.0, 1e-6) && near(b.x1, 350.0, 1e-6) && near(b.y1, 50.0, 1e-6), "{b:?}"); +} + +#[test] +fn a_text_in_a_clip_path_can_be_shown_by_a_use_elsewhere() { + let d = open( + r##"HI + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + let (clip, _) = clip_group(&objects[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let mut shown = vec![]; + objects[1].walk(&mut |n: &Node| { + if let NodeKind::Text(t) = &n.kind { + shown.push(t.plain_text()); + } + }); + assert_eq!(shown, ["HI"], "the use shows the text: {:?}", objects[1]); +} + +#[test] +fn a_use_in_a_clip_path_can_be_shown_by_another_use() { + let d = open( + r##"HI + + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + let (clip, _) = clip_group(&objects[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let mut shown = vec![]; + objects[1].walk(&mut |n: &Node| match &n.kind { + NodeKind::Text(t) => shown.push(t.plain_text()), + NodeKind::Path { .. } => shown.push("a path".into()), + _ => {} + }); + assert_eq!(shown, ["HI"], "the other use shows the text: {:?}", objects[1]); + // A rule that styles the `` where it stands still applies to what the other use shows. + let d = open( + r##" + HI + + + "##, + ); + let objects = top(&d); + let mut at = vec![]; + objects[1].walk(&mut |n: &Node| { + if let NodeKind::Text(t) = &n.kind { + at.push(t.xf.translation().to_point()); + } + }); + assert!(at.len() == 1 && at[0].distance(Point::new(60.0, 200.0)) < 1e-9, "{at:?}"); +} + +#[test] +fn style_rules_for_paths_do_not_reach_a_text_in_a_clip_path() { + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "the visible, non-zero text: {:?}", clip.kind); + // The text's own values, and those it inherits, still apply. + let d = open( + r##" + O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd); +} + +#[test] +fn a_hidden_text_alone_in_a_clip_path_is_not_live_type() { + let d = open( + r##"HI + "##, + ); + let objects = top(&d); + let clips = objects.iter().filter_map(|n| match &n.kind { + NodeKind::Group { children, clip: true } => children.first(), + _ => None, + }); + for clip in clips { + assert!(!matches!(clip.kind, NodeKind::Text(_)), "a hidden text clips nothing: {:?}", clip.kind); + } +} + +#[test] +fn a_text_left_out_by_its_conditions_clips_nothing() { + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}"); +} + +#[test] +fn a_text_clip_on_a_symbol_instance_cuts_art_between_the_letters() { + // The art sits in the space between the two letters, inside the text's box: the glyphs cut it, + // so it stays clipped art instead of becoming an instance without the clip. + let body = |clip: &str| { + format!( + r##" + {clip} + "## + ) + }; + let instances = |d: &Document| { + let mut n = 0; + d.walk(|node| n += usize::from(matches!(node.kind, NodeKind::SymbolInstance { .. }))); + n + }; + let d = open(&body(r#"I I"#)); + assert_eq!(instances(&d), 0, "{:?}", top(&d)[0].kind); + assert!(matches!(top(&d)[0].kind, NodeKind::Group { clip: true, .. })); + // A clip around the art leaves it whole: an instance. + let d = open(&body(r#""#)); + assert_eq!(instances(&d), 1); +} + +#[test] +fn a_clip_path_clipped_by_a_text_clip_path_nests() { + let d = open( + r##"HI + + "##, + ); + let (clip, art) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "the outermost clip is the text: {:?}", clip.kind); + let (inner, _) = clip_group(&art[0]); + assert!(matches!(inner.kind, NodeKind::Path { clipping: true, .. }), "{:?}", inner.kind); +} + +#[test] +fn hidden_text_in_a_clip_path_clips_nothing() { + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}"); +} + +#[test] +fn text_further_down_in_a_clip_path_is_still_not_clip_path_content() { + let body = r##"No + "##; + let (_, warnings) = import_with_report(&svg(body)).unwrap(); + assert!(warnings.iter().any(|w| w.contains("in a clip path ignored")), "{warnings:?}"); +} + +#[test] +fn a_text_clip_survives_a_round_trip_through_svg_export() { + let d = open( + r##"HI + "##, + ); + let before = clip_bounds(clip_group(&top(&d)[0]).0); + let again = import(&export(&d, &ExportOptions::default())).unwrap(); + let mut clips = vec![]; + again.walk(|n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + clips.push(children[0].clone()); + } + }); + assert_eq!(clips.len(), 1); + let after = clip_bounds(&clips[0]); + let tol = 0.01 * before.width().max(before.height()); + assert!( + near(after.x0, before.x0, tol) && near(after.x1, before.x1, tol) && near(after.y0, before.y0, tol) && near(after.y1, before.y1, tol), + "{before:?} → {after:?}" + ); +} diff --git a/crates/svg/tests/it/ids.rs b/crates/svg/tests/it/ids.rs index 0781397fa..c41dd4bcf 100644 --- a/crates/svg/tests/it/ids.rs +++ b/crates/svg/tests/it/ids.rs @@ -159,3 +159,21 @@ fn unique_ids_differ_when_only_gradient_stops_differ() { assert!(!ids1.is_empty() && !ids2.is_empty()); assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}"); } + +/// Ruler guides on each layer, named as a layer and an object above them are, with a URL, are not +/// written: the SVG is the same as without them, with every kind of object id. +#[test] +fn ruler_guides_are_not_written_and_take_no_id() { + let plain = doc("a", 0.0); + let mut guided = plain.clone(); + for (i, (l, name)) in [(guided.layers[0].id, "Top & Front"), (guided.layers[1].id, "Logo mark (v2)")].into_iter().enumerate() { + let mut g = Node::new(guided.alloc_id(), vectorcraft_doc::NodeKind::RulerGuide { vertical: i == 0, pos: 30.0, artboard: None }); + g.name = Some(name.into()); + g.edit_attrs(|a| a.url = "https://example.com/".into()); + guided.insert(Some(l), usize::MAX, g).unwrap(); + } + for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] { + let opts = ExportOptions { object_ids, ..Default::default() }; + assert_eq!(export(&guided, &opts), export(&plain, &opts), "{object_ids:?}"); + } +} diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 57a7251bd..10418f344 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -645,3 +645,26 @@ fn nested_svg_images_past_count_cap_are_left_out() { let (_d, warnings) = import_with(&svg, &ImportOptions::default()).unwrap(); assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images")))); } + +/// #3566: entity references in element content count toward the expansion limit, as in +/// attributes. A 10 KB entity, a second entity that refers to it 250 times, and eight references +/// to the second inside `` would expand to about 20 MB from a file of a few KB: the import +/// refuses it before the XML reader expands anything. The namespace entities drawing apps write +/// (`&ns_svg;`) still import. +#[test] +fn entities_repeated_in_element_content_are_refused_and_namespace_entities_import() { + let bomb = format!( + r#"]>{}"#, + "x".repeat(10_000), + "&a;".repeat(250), + "&b;".repeat(8) + ); + assert!(bomb.len() < 20_000, "{} bytes", bomb.len()); + match import_with(&bomb, &ImportOptions::default()) { + Err(SvgError::Parse(e)) => assert!(e.contains("entities"), "{e}"), + other => panic!("refused as a parse error, not {:?}", other.map(|(_, w)| w)), + } + let ns = r#"]>"#; + let (d, _) = import_with(ns, &ImportOptions::default()).unwrap(); + assert_eq!(art(&d).len(), 1, "the rectangle"); +} diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 7d0e08c0b..35289a0c1 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -108,6 +108,19 @@ fn text_keeps_its_z_order_between_shapes() { assert!(t.xf.translation().to_point().distance(Point::new(5.0, 20.0)) < 1e-9); } +#[test] +fn a_css_transform_places_text() { + // A `transform` declaration in a rule or `style` beats the attribute, as on shapes. + for body in [ + r#"Moved"#, + r#"Moved"#, + ] { + let d = open(body); + let t = only_text(&d); + assert!(t.xf.translation().to_point().distance(Point::new(10.0, 40.0)) < 1e-9, "{body}: {:?}", t.xf); + } +} + #[test] fn text_keeps_its_parent_group_clip_and_mask() { let d = open( @@ -232,6 +245,38 @@ fn per_character_positions_with_middle_anchor() { assert!(close(mid(0), 50.0, 0.05) && close(mid(1), 100.0, 0.05), "{} {}", mid(0), mid(1)); } +/// `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, +/// and VectorCraft aligns a line without that tracking: the imported lines move back by their +/// share of it and stay where a reader draws them, and a centred or right-aligned tracked line +/// exported and read back stays where it was (export anchors lines the same way). +#[test] +fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { + let starts = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + lay.lines.iter().map(|l| (t.xf * lay.glyphs[l.glyph_start].origin).x).collect::>() + }; + let advances = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + lay.lines.iter().map(|l| lay.glyphs[l.glyph_start..l.glyph_end].iter().map(|g| g.advance).sum::()).collect::>() + }; + for (anchor, share) in [("middle", 0.5), ("end", 1.0)] { + let d = open(&format!(r#"WAVE"#)); + let t = only_text(&d); + let (start, advance) = (starts(t)[0], advances(t)[0]); + assert!(close(start, 200.0 - share * advance, 1e-6), "{anchor}: the line starts at {start}, not {}", 200.0 - share * advance); + let back = import(&vectorcraft_svg::export(&d, &vectorcraft_svg::ExportOptions::default())).unwrap(); + let again = starts(only_text(&back))[0]; + assert!(close(again, start, 1e-6), "{anchor}: read back, the line starts at {again}, not {start}"); + } + // Lines anchored differently: each moves by its own share. + let d = open( + r#"WaveLine"#, + ); + let t = only_text(&d); + let (s, a) = (starts(t), advances(t)); + assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); +} + #[test] fn tspan_lines_dx_and_baseline_shift() { let d = open( diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index 3605bd014..09c175a37 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -4,6 +4,7 @@ mod basic_shapes; mod blend_export; +mod clip_text; mod colorized_images; mod colour_glyphs; mod container_export; diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index adcc101f2..a031de741 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -554,6 +554,18 @@ fn ragged_lines_keep_their_character_spacing() { } } +/// Tracking and manual kerning in type scaled uniformly, which no `textLength` stretches, take the +/// scale in a viewer as they do on the canvas. +#[test] +fn spacing_in_uniformly_scaled_type_takes_the_scale() { + for st in [ + CharStyle { h_scale: 200.0, v_scale: 200.0, tracking: 100.0, ..style(12.0) }, + CharStyle { h_scale: 150.0, v_scale: 150.0, kerning: Some(80.0), ..style(12.0) }, + ] { + assert_drawn_in_place(&area_doc(vec![run("tracked wide", st)], 280.0, Justify::Left)); + } +} + /// Type scaled horizontally before a tab is stretched to its own width, not to the tab's gap. #[test] fn scaled_type_before_a_tab_keeps_its_width() { diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index 656bf4a8b..8deb84a45 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -263,20 +263,20 @@ impl DocBuilder { } } -/// All non-layer nodes of a document in paint order. +/// All nodes of a document other than layers and ruler guides, in paint order. pub fn art_nodes(doc: &Document) -> Vec<&Node> { let mut v = Vec::new(); doc.walk(|n| { - if !n.is_layer() { + if !n.is_layer() && !n.is_ruler_guide() { v.push(n) } }); v } -/// Ids of the top-level art (direct children of layers), bottom first. +/// Ids of the top-level art (direct children of layers other than ruler guides), bottom first. pub fn top_level_ids(doc: &Document) -> Vec { - doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect() + doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).map(|n| n.id)).collect() } /// Every node id (layers included), paint order. diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index c03b33dd5..8791acf42 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_engine::Session; /// Serialize a document to JSON (the canonical form for equality checks). @@ -11,7 +11,8 @@ pub fn doc_json(doc: &Document) -> Value { serde_json::to_value(doc).expect("document serializes") } -/// Tree invariants: ids unique, `node_count` equals the number of walked nodes, `next_id` above +/// Tree invariants: ids unique, `node_count` equals the number of walked nodes other than ruler +/// guides, ruler guides only at the top of a layer, `next_id` above /// every id, top level made of layers, layers only at the top or inside layers, clip groups have /// a first child, compound children are paths, no image holds its own pixels (the document keeps /// them). @@ -21,11 +22,21 @@ pub fn check_document(doc: &Document) -> Result<(), String> { let mut max = 0u64; let mut err = None; doc.walk(|n| { - walked += 1; + // `node_count` counts objects: ruler guides are left out of it. + walked += usize::from(!n.is_ruler_guide()); max = max.max(n.id.0); if !seen.insert(n.id) && err.is_none() { err = Some(format!("duplicate id {}", n.id)); } + let guides = n.children().map_or(0, |ch| ch.iter().filter(|c| c.is_ruler_guide()).count()); + if guides > 0 && err.is_none() { + let ch = n.children().map(Vec::as_slice).unwrap_or_default(); + if !n.is_layer() { + err = Some(format!("{} holds a ruler guide but is not a layer", n.id)); + } else if ch.len() - Node::guide_run_start(ch) != guides { + err = Some(format!("layer {} has a ruler guide below its art", n.id)); + } + } match &n.kind { NodeKind::Group { children, .. } if children.iter().any(|c| c.is_layer()) && err.is_none() => { err = Some(format!("group {} contains a layer", n.id)); diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 4dbac8f6e..10b060800 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec { out } -/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could -/// write files anywhere, so callers substitute a temp path. +/// Parameter-name keys that name a file or folder a command may write: fuzzing these with +/// arbitrary strings could write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - k == "path" + matches!(k, "path" | "diffPath") || is_folder_key(k) +} + +/// Path keys that name a folder a command creates or writes files into. +pub fn is_folder_key(k: &str) -> bool { + matches!(k, "folder" | "destinationFolder") +} + +/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for +/// a folder key. +pub fn safe_location(k: &str, safe_path: &str) -> String { + if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() } +} + +/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string. +pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value { + if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() } } /// A deterministic list of junk param objects for `doc` (the command's params doc): each @@ -516,7 +532,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec { let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})]; for k in &keys { for (i, v) in junk.iter().enumerate() { - let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() }; + let v = safe_value(k, v, safe_path); // Sparse: one key at a time with every junk value, plus a few combos. let mut o = serde_json::Map::new(); o.insert(k.clone(), v.clone()); @@ -524,7 +540,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec { if i % 5 == 0 { let mut all = serde_json::Map::new(); for k2 in &keys { - let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() }; + let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() }; all.insert(k2.clone(), v2); } out.push(Value::Object(all)); diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index a3a141940..53de928be 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -89,6 +89,7 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g let base = g.adv; if g_des != 1.0 { g.adv *= g_des; + g.tracking *= g_des; g.sx *= g_des; g.dx *= g_des; g.kern_shift *= g_des; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8e594c036..10c44eb73 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -37,9 +37,9 @@ pub(crate) use bidi::{is_rtl, para_bidi}; use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; +pub use driver::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; #[cfg(test)] -pub(crate) use driver::layout_faces_per_call; -pub use driver::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; +pub(crate) use driver::{fit_passes, layout_faces_per_call}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; #[cfg(test)] @@ -93,6 +93,34 @@ fn takes_cell(g: &SGlyph) -> bool { g.adv > 0.0 || g.face.advance(g.gid) > 0.0 } +/// Vertical type on a path: the turn that takes upright glyph `g` (of size `em`, its glyph space +/// mapped onto the path by `pre`) from its local transform `local` (`unturned` takes its Character +/// Rotation off) to the path, and where the next glyph of its cluster goes in its glyph space. +/// +/// It stands in glyph space as it stands in vertical point type's line space: a quarter turn +/// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift +/// kept across the column and a leading space taken off moving it along the path, then moved across +/// the path as a glyph lying on its side is. +fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) { + let cell = if takes_cell(g) { upright_cell(g) } else { advance }; + let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift))); + let about = Point::new(cell * 0.5, -upright_centre(g, cell, em)); + let mut turn = + Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift; + // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on + // a path that runs to the left: it turns half a turn about its cell's center. + let tangent = Vec2::new(angle.cos(), angle.sin()); + let placed = pre * turn * local * unturned; + if (placed * Point::new(0.0, -1.0) - placed * Point::ZERO).dot(tangent) > 0.0 { + let centre = turn * shift * about; + turn = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * turn; + } + // Its own advance across, from where the cell centers it: where the shaper set the next glyph of + // its cluster. + let across = g.face.advance(g.gid) * g.sx; + (pre * turn, (cell - across) * 0.5 + across) +} + /// Clear the caret stops of each glyph that shares its cluster with another glyph: only a glyph /// that alone stands for its characters is a ligature. fn keep_lone_ligature_carets(glyphs: &mut [PositionedGlyph]) { @@ -137,6 +165,10 @@ struct Ctx<'a> { /// next glyph of its cluster would take, the turn it got, the line-space x that next glyph is /// set after), so that a combining mark shaped into the same cluster turns with it. upright_cluster: Option<(usize, usize, Affine, f64)>, + /// Vertical type on a path: the tate-chu-yoko block [`Self::emit`] is placing, as (the + /// block's transform from its first glyph's place on the path, the center of its em in the + /// column before that transform), so that the block's other glyphs stand with it on a curve. + tcy_block: Option<(Affine, Point)>, out: TextLayout, } @@ -329,7 +361,7 @@ impl Ctx<'_> { }; // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an // upright one in vertical type by moving it up the column once it stands upright. - let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g); + let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some()); let lead = if upright { 0.0 } else { g.lead }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline), moved along the line by @@ -365,28 +397,75 @@ impl Ctx<'_> { let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy; (cell + proportional, top) }); - if self.vertical && self.on_path { - // Vertical path type keeps the baseline path and turns each glyph across it. - m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; - if let Some((cell, top)) = path_cell { - // An upright glyph whose top then faces the path's end turns half a turn about the - // center of its cell (without its Character Rotation), so that its top faces the - // path's start and the characters of an upright ligature read along the path in - // order. The effects that keep the glyph's up direction fixed already turn it so on - // a path that runs to the left. - let turned = m * unturned; - let tangent = Vec2::new(angle.cos(), angle.sin()); - let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent); - if along(-top) > along(cell - top) { - let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top); - m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m; + // Vertical type on a path: the column is centered on the line Align to Path puts the baseline + // on, so the em boxes of upright glyphs, tate-chu-yoko blocks and glyphs lying on their side + // straddle the path. Glyph space here is horizontal path type's: x runs along the path and y + // across it, to the right of the path's direction. + if let (true, true, Some(t)) = (self.vertical, self.on_path, g.tcy) { + // Tate-chu-yoko stands upright as one block: set across the column, centered on its em of + // it and on the path, its top toward the path's start, then moved by + // its Up/Down toward the path's start and by its Left/Right to the path's left, whatever + // the effect does to the glyph's axes. The block's first glyph sets where the block + // stands; the others take its em, its place on the path and its column, so the block + // stays whole on a curve. + let (block, centre) = match self.tcy_block.filter(|_| t.pen > 0.0) { + Some(b) => b, + None => { + // A later glyph without its first glyph: placed back by its pen offset. + let (em, back) = + if t.pen > 0.0 { (t.pen, Affine::translate((-t.pen, 0.0))) } else { (self.style_at(g.byte).size, Affine::IDENTITY) }; + let centre = Point::new(em * 0.5, 0.0); + let squeeze = + Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0)); + let on_path = pre * back; + let mut block = on_path * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze; + // The effects that keep the glyph's axes fixed leave the block's top toward the + // path's end on a path that runs to the left: it turns half a turn about its + // center, as an upright glyph does. + let tangent = Vec2::new(angle.cos(), angle.sin()); + if (block * Point::new(centre.x, centre.y - 1.0) - block * centre).dot(tangent) > 0.0 { + block = Affine::translate((on_path * centre).to_vec2() * 2.0) * Affine::scale(-1.0) * block; + } + let left = Vec2::new(tangent.y, -tangent.x); + block = Affine::translate(-tangent * t.up + left * t.right) * block; + self.tcy_block = Some((block, centre)); + (block, centre) } + }; + let em = centre.x * 2.0; + let across = Affine::translate((centre.x - t.width * 0.5 + t.ink, centre.y + g.face.ideographic_centre() * em)); + m = block * across * local; + } else if self.vertical && self.on_path && (g.inline.is_some() || !stands_upright(g)) { + // A glyph lying on its side (and an inline graphic) is a quarter turn clockwise from an + // upright glyph, as horizontal type on the path sets it: its baseline runs along the path + // and its top faces the path's left. It moves across the path so that the center of its + // ideographic em box lies on the path. + // + // + let centre = g.face.ideographic_centre() * g.face.units_per_em() * g.sy; + m = pre * Affine::translate((0.0, centre)) * local; + } else if self.vertical && self.on_path { + // An upright glyph is turned in glyph space ([`upright_path_turn`]), so every effect bends it + // as it bends glyphs lying on their side: its top faces the path's start and the characters + // of an upright ligature read along the path in order. + let next = self.out.glyphs.len(); + let after_base = self.out.glyphs.last().is_some_and(|p| p.byte == g.byte && p.line == line && p.len > 0); + if let Some((_, _, turn, after)) = cluster.filter(|c| c.0 == g.byte && c.1 == next && after_base && g.len > 0) { + // A later glyph of an upright glyph's cluster: set after it as the shaper placed it, + // and turned with it, in its glyph space. + m = turn * Affine::translate((after, 0.0)) * local; + self.upright_cluster = Some((g.byte, next + 1, turn, after)); + } else { + let (turn, after) = upright_path_turn(g, self.style_at(g.byte).size, pre, local, unturned, angle, advance); + m = turn * local; + self.upright_cluster = (g.len > 0).then_some((g.byte, next + 1, turn, after)); } } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y - // from right to left across it). - let em = self.style_at(g.byte).size; + // from right to left across it). Every glyph of the block takes the em of its first + // glyph, the pen offset of the others. + let em = if t.pen > 0.0 { t.pen } else { self.style_at(g.byte).size }; let start = origin.x - t.pen; let centre = Point::new(start + em * 0.5, origin.y - g.face.ideographic_centre() * em); let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0)); @@ -580,6 +659,7 @@ mod emit_kashida_mark_last { len: 2, run: 0, adv: 10.0, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, @@ -629,6 +709,7 @@ mod emit_kashida_mark_last { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Last visual glyph of the cluster: a mark that wrongly keeps the mark run (run 1). @@ -639,6 +720,7 @@ mod emit_kashida_mark_last { len: 4, run: 1, adv: 0.0, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, @@ -692,6 +774,7 @@ mod emit_kashida_mark_last { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Simulate the pre-fix path: clone run from the mark glyph only. diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 805c4dead..68519f397 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -209,24 +209,105 @@ const SHRINK_PASSES: usize = 11; pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { #[cfg(any(test, feature = "test-fonts"))] crate::test_fonts::count_layout(); + // The sizes the fits step and limit are the ones the Character panel shows (`shown`), whatever + // em size a font height reference sets them at. + let shown = t; let resolved = db.font_heights_resolved(t); let t = &*resolved; let first = layout_once(db, t, opts); match (opts.fit.min_scale(), &t.kind) { - (Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first), + (Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, shown, opts, min).unwrap_or(first), + (_, TextKind::OnPath { .. }) if t.dynamic_text => fill_path(db, t, shown, opts, &first).unwrap_or(first), _ => first, } } +/// The document size of the first character of `t` (its run's size; an empty run before it draws +/// nothing), in tenths of a point. None when it is not a usable size. +fn first_size_tenths(t: &TextObject) -> Option { + let points = t.style_scale()?.points; + let run = t.runs.iter().find(|r| !r.text.is_empty()).or(t.runs.first()); + run.map(|r| r.style.size * points * 10.0).filter(|s| s.is_finite() && *s > 0.0) +} + +/// Most layout passes Dynamic Text runs past the first (a guess, a search out from it, bisection). +const DYNAMIC_PASSES: usize = 11; + +/// The sizes, in tenths of a document point, Dynamic Text keeps the first run's size within: 0.1 +/// to 1,296 pt, the Character panel's range. +const DYNAMIC_TENTHS: (f64, f64) = (1.0, 12_960.0); + +/// Dynamic Text for type on a path: the largest scale at which the text does not run past its end +/// bracket, so that it fills the span between its brackets. The first run's document size moves in +/// steps of 0.1 pt within 0.1 to 1,296 pt; at either limit the text keeps that size, and at the +/// smallest it may still overflow. Each guess is the size tried last times the span's length over +/// the length the text took there, which is exact for a straight span with no Spacing; a guess +/// outside the sizes still open gives way to a step out from the last one (doubling) or to +/// bisection. At most [`DYNAMIC_PASSES`] passes. `t` is laid out; the sizes are those of `shown`, +/// `t` before its font height references were resolved. None (keep `first`) when the text takes no +/// length, the span has none, or the text has no usable size. +fn fill_path(db: &FontDb, t: &TextObject, shown: &TextObject, opts: &LayoutOptions, first: &TextLayout) -> Option { + let tenths = first_size_tenths(shown)?; + let (min, max) = DYNAMIC_TENTHS; + // The size in tenths that would fill the span, from a layout at `k` tenths. + let guess = |k: f64, l: &TextLayout| -> Option { + let (used, room) = l.path_fill?; + let g = (k * room / used).floor(); + (used.is_finite() && used > 1e-9 && room.is_finite() && room > 1e-9 && g.is_finite()).then(|| g.clamp(min, max)) + }; + let mut next = guess(tenths, first)?; + let at = |k: f64| -> TextLayout { + #[cfg(test)] + FIT_PASSES.set(FIT_PASSES.get() + 1); + let f = k / tenths; + let mut l = layout_once(db, &scaled(t, f), opts); + l.fit_scale = f; + l + }; + // The largest size tried that fits, and the smallest that overflows, each with its layout. + let mut fits: Option<(f64, TextLayout)> = None; + let mut over: Option<(f64, TextLayout)> = None; + let mut step = 1.0; + for _ in 0..DYNAMIC_PASSES { + let l = at(next); + let guessed = guess(next, &l); + if l.overflow { + if over.as_ref().is_none_or(|(o, _)| next < *o) { + over = Some((next, l)); + } + } else if fits.as_ref().is_none_or(|(f, _)| next > *f) { + fits = Some((next, l)); + } + // The sizes still open lie strictly between the largest that fits and the smallest that + // overflows, within the limits. + let lo = fits.as_ref().map_or(min - 1.0, |f| f.0); + let hi = over.as_ref().map_or(max + 1.0, |o| o.0); + if hi - lo <= 1.0 { + break; + } + next = match guessed.filter(|g| *g > lo && *g < hi) { + Some(g) => g, + None if fits.is_some() && over.is_some() => (lo + (hi - lo) * 0.5).floor(), + // Everything tried fits: try larger. Everything tried overflows: try smaller. + None if fits.is_some() => (lo + step).min(hi - 1.0), + None => (hi - step).max(lo + 1.0), + }; + step *= 2.0; + } + // The largest that fits, or at the smallest size (or at the pass limit) the smallest tried. + fits.or(over).map(|(_, l)| l) +} + /// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which /// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size /// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there, -/// still overflowing. None (keep the full-size layout) when the text has no usable size. -fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option { +/// still overflowing. `t` is laid out; the sizes are those of `shown`, `t` before its font height +/// references were resolved. None (keep the full-size layout) when the text has no usable size. +fn shrink_to_fit(db: &FontDb, t: &TextObject, shown: &TextObject, opts: &LayoutOptions, min: f64) -> Option { // Exact size edits after object scaling may store a local size below 0.1. Fit in the // same document points as the Character controls, independent of that representation. - let points = t.style_scale()?.points; - let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?; + let points = shown.style_scale()?.points; + let size = shown.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?; if !(min.is_finite() && min < 1.0) { return None; } @@ -292,6 +373,31 @@ pub fn scaled(t: &TextObject, f: f64) -> TextObject { s } +/// `t` in the styles its layout draws it in: [`scaled`] by the layout's +/// [`TextLayout::fit_scale`] when Shrink Text to Fit (area type) or Dynamic Text (type on a path) +/// scales it, `t` itself otherwise. Text with neither setting is not laid out. +pub fn drawn(t: &TextObject) -> std::borrow::Cow<'_, TextObject> { + let fits = match &t.kind { + TextKind::Area { .. } => t.area.fit.min_scale().is_some(), + TextKind::OnPath { .. } => t.dynamic_text, + TextKind::Point => false, + }; + let f = if fits { layout(FontDb::global(), t).fit_scale } else { 1.0 }; + if f != 1.0 { std::borrow::Cow::Owned(scaled(t, f)) } else { std::borrow::Cow::Borrowed(t) } +} + +#[cfg(test)] +thread_local! { + /// How many layouts Dynamic Text ran past the first on this thread ([`fit_passes`]). + static FIT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many layouts Dynamic Text ran past the first on this thread. +#[cfg(test)] +pub(crate) fn fit_passes() -> usize { + FIT_PASSES.get() +} + #[cfg(test)] thread_local! { /// Set while [`layout_faces_per_call`] lays text out: layout keeps no run faces. @@ -374,6 +480,7 @@ fn layout_runs(db: &FontDb, t: &TextObject, opts: &LayoutOptions, set: Option<&T system_layout: t.system_layout, fractional_widths: t.fractional_widths, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index e1d55eece..56c71c2f4 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -389,6 +389,9 @@ struct LineGeometry { ax1: f64, width: f64, render_width: f64, + /// The tracking after the line's last character, when that character ends the line (see + /// [`prepare_line_geometry`]); 0 otherwise. + trailing: f64, order: Vec, drawn: Vec, opt_l: f64, @@ -413,6 +416,9 @@ struct LineJustification { /// Right-aligned area type: the line's last tab, which takes the text after it to the right /// edge (the text before it sits at its stops, from the left edge). last_tab: Option, + /// The tracking after the line's last character that its alignment leaves out of its width: + /// [`LineGeometry::trailing`] for a line that isn't justified, 0 for one that is. + trailing: f64, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -582,6 +588,31 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData // ends with the empty line after it (`run_paragraph_lines`). let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); let last_of_para = position.end >= n && !soft_return; + // The tracking after the line's last character: an aligned line is measured without it. The + // page of users' `.ai` files sets a centred line of tracked type half that tracking right of + // where a line measured with it goes, and a right-aligned one the whole tracking right (in + // lines that end a paragraph). The last character is the last one the line draws before a + // paragraph's end, a forced line break, or the spaces the layout wraps the line at (they hang + // past it). Spaces or a tab typed before a paragraph's end or a forced line break are the + // line's last characters, and the letter before them keeps its tracking: the page counts it in + // the 2 lines of the files that end so. Forced line breaks and wraps aren't in the files; they + // take the rule so that turning a wrap into a forced line break or a new paragraph (Break + // Text, Convert To Point Type) keeps the line where it was. A line a hyphen ends, one whose + // last character hangs, and vertical and right-to-left lines keep the tracking. + let tail = sg.get(trimmed..position.end).unwrap_or_default(); + let ends_drawn = match tail.split_last() { + None => true, + Some((last, before)) if last.ch == '\u{2028}' => before.is_empty(), + // Spaces at a wrap: the paragraph goes on. + Some(_) => position.end < n, + }; + let trailing = trimmed + .checked_sub(1) + .and_then(|k| sg.get(k)) + .filter(|g| { + ends_drawn && trimmed > index && !g.level.is_rtl() && !cx.vertical && !paragraph.rtl && hyphen.is_none() && hang.is_none() && opt_r == 0.0 + }) + .map_or(0.0, |g| if g.tracking.is_finite() { g.tracking } else { 0.0 }); LineGeometry { position, index, @@ -593,6 +624,7 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData ax1, width, render_width, + trailing, order, drawn, opt_l, @@ -738,6 +770,9 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin // A tab ending the line counts: its text sits at the left edge, the tab up to the right one. .then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0))) .flatten(); + // An aligned line is as wide as its characters without the tracking after the last one. + let trailing = if justify || pushed_in { 0.0 } else { line.trailing }; + let width = width - trailing; let start_x = if justify || pushed_in || last_tab.is_some() { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { @@ -753,7 +788,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing } } /// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop @@ -780,10 +815,11 @@ fn tab_glyph<'p>( return (advance, leader); } // The width of what follows up to the line's end (nothing, when the tab ends the line), with - // the hyphen a hyphenated line ends with. + // the hyphen a hyphenated line ends with, and without the tracking after its last character, + // as a right-aligned line is measured. let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv); - let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen }; + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen - justification.trailing }; ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) } @@ -905,6 +941,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData baseline: position.baseline, x0: start_x, x1: x_end, + trailing: justification.trailing, ascent: position.m.asc, descent: position.m.desc, first_baseline: if pen.model == LeadingModel::EmBoxTop { diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index aafe17531..de0272cb4 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -174,6 +174,15 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, let s = style_metrics(cx.face_at(0).as_deref(), cx.style_at(0)); (s.0, s.1) }); + // Vertical type: the caret and the selection span the column, half its em on each side of the + // line Align to Path puts the baseline on. + let m = if cx.vertical { + let half = sg.iter().map(|g| g.face.units_per_em() * g.sx * 0.5).fold(f64::NAN, f64::max); + let half = if half.is_finite() { half } else { cx.style_at(0).size * 0.5 }; + (half, half) + } else { + m + }; if ap.is_empty() { cx.out.overflow = !sg.is_empty(); cx.out.lines.push(LineInfo { @@ -181,6 +190,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, baseline: 0.0, x0: 0.0, x1: 0.0, + trailing: 0.0, ascent: m.0, descent: m.1, first_baseline: 0.0, @@ -285,6 +295,8 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, .count() }; let (mut steps, mut s0, mut hung) = arrange(&sg); + // What the whole text takes along the path and what the span holds: Dynamic Text's first guess. + cx.out.path_fill = Some((steps.iter().sum(), avail + hung.0 + hung.1)); // Where the text that fits ends: before the first glyph that doesn't, which threaded text // flows on from, as from the last line of area type. let mut fits = text_len; @@ -408,6 +420,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, baseline: ps.y, x0: ps.x, x1: pe.x, + trailing: 0.0, ascent: m.0, descent: m.1, first_baseline: 0.0, diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 30f8d76eb..920e802b7 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -16,6 +16,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { len, run: 0, adv, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, @@ -136,6 +137,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; cx.emit(&glyph, pre, origin, angle, 30.0 - lead, 0); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index cbc5157ed..1e4aeb41d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -48,7 +48,8 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; -pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; +pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; +pub use shape::spacing_scale; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; @@ -284,6 +285,14 @@ pub struct LineInfo { pub x0: f64, /// Right edge of the line content, trailing spaces excluded. pub x1: f64, + /// The tracking after the line's last character (in `x1` and in its glyph's advance) that the + /// line's alignment leaves out: the line is aligned as if it ended this much before `x1` (a + /// centred line is centred and a right-aligned one, or the text after the last tab of + /// right-aligned area type, ends there; a left-aligned line starts where it would anyway). 0 + /// when spaces or a tab typed before a paragraph's end or a forced line break end the line, + /// when a hyphen ends it or its last character hangs, in justified lines and lines that push + /// characters in, in vertical and right-to-left lines, and in type on a path. + pub trailing: f64, pub ascent: f64, pub descent: f64, /// Area type: how far the line's baseline sits below the top of its frame cell (inside the @@ -361,9 +370,14 @@ pub struct TextLayout { pub on_path: bool, /// Area type: the frame cells text flowed into (one per row/column). pub frames: Vec, - /// Shrink Text to Fit: the factor the text's sizes, leading and baseline shifts were scaled - /// by to fit its frame (1.0 when the text is not shrunk). + /// Shrink Text to Fit and Dynamic Text: the factor the text's sizes, leading and baseline + /// shifts were scaled by to fit its frame or the span of its path (1.0 when the text is not + /// scaled). Shrink Text to Fit keeps it at most 1; Dynamic Text may take it above 1. pub fit_scale: f64, + /// Type on a path: the length the arranged glyphs take along the path, and the length of the + /// span between the brackets with what hangs outside them ([`Self::fit_scale`]'s first guess + /// for Dynamic Text). `None` for other type and for a path with no length. + pub path_fill: Option<(f64, f64)>, /// Inline graphics, in text order. pub inlines: Vec, /// List markers, in text order. @@ -383,6 +397,7 @@ impl Default for TextLayout { on_path: false, frames: Vec::new(), fit_scale: 1.0, + path_fill: None, inlines: Vec::new(), markers: Vec::new(), } diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index b913c7cc2..3c56b2c91 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -30,6 +30,10 @@ pub(crate) struct SGlyph { pub run: usize, /// Advance in points (tracking, manual kerning and horizontal scale applied). pub adv: f64, + /// The tracking `adv` holds after the glyph's character, in points: on the last glyph of its + /// cluster, 0 on the others. An aligned line leaves its last character's out of its width + /// ([`crate::layout`]). + pub tracking: f64, /// Offset from the pen position, y down. pub dx: f64, pub dy: f64, @@ -177,6 +181,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { let mut h = g.clone(); h.gid = gid; h.adv = g.face.advance(gid) * g.sx; + h.tracking = 0.0; h.dx = 0.0; h.dy = 0.0; h.kern_shift = 0.0; @@ -357,6 +362,19 @@ pub(crate) fn cap_x_heights(face: Option<&FontFace>, st: &CharStyle) -> (f64, f6 (face.cap_height * k, face.x_height * k) } +/// The share of the em that tracking and manual kerning (thousandths of an em) of characters in +/// style `st` are measured in. In horizontal type that is the Horizontal Scale: they are +/// thousandths of the em as the scale draws it, so at 50% a tracking of 100 adds 0.05 em, half as +/// much as at 100%, as the page of users' `.ai` files spaces such type (and as PDF scales its +/// character and word spacing by its horizontal scaling, ISO 32000-1 §9.4.4). Baking a horizontal +/// scale of the type's transform into the Horizontal Scale then keeps the spacing. In vertical +/// type, whose advances down the column don't all take the Horizontal Scale, the em itself: 1. A +/// scale that isn't a positive number counts as 100%. +pub fn spacing_scale(st: &CharStyle, vertical: bool) -> f64 { + let hs = st.h_scale / 100.0; + if vertical || !(hs.is_finite() && hs > 0.0) { 1.0 } else { hs } +} + /// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style, `inlines` /// (parallel to `runs`) the inline graphic of each run that is one, and `levels` each byte's bidi /// embedding level from `range.start` (empty: all left to right). Glyphs come out in logical @@ -736,7 +754,8 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range< let right_y = right.dy - right.bshift; let Some(left_style) = runs.get(left.run).map(|(_, s)| *s) else { continue }; let em = left.sx * left.face.upem / (left_style.h_scale / 100.0); - let tracking = left_style.tracking / 1000.0 * em; + // The tracking shaping added: thousandths of the em as its Horizontal Scale draws it. + let tracking = left_style.tracking / 1000.0 * em * spacing_scale(left_style, false); // A manual kerning value on the left glyph is part of its advance and, right to left, of // its offset too (it spaces the left glyph from its left neighbor): it is not taken off. let advance = glyphs[cluster_start..=cluster_end].iter().map(|g| g.adv).sum::() - tracking; @@ -796,6 +815,23 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i) } +/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that +/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the +/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. +fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { + if a == b { + return true; + } + !vertical + && *a + == CharStyle { + standard_vertical_roman: a.standard_vertical_roman, + tcy_up_down: a.tcy_up_down, + tcy_left_right: a.tcy_left_right, + ..b.clone() + } +} + /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character @@ -914,8 +950,10 @@ fn shaping_script(c: char) -> Script { /// vertical centre on the middle of the cap height raised by the inline's shift (see /// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual /// kerning, which right to left in horizontal type also moves the art right by the value, as it -/// moves a letter's glyph. The glyph's ascent and descent grow to the art's extent so a tall -/// graphic opens up its line. A missing symbol takes a square of the same height and draws nothing. +/// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the +/// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the +/// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same +/// height and draws nothing. #[allow(clippy::too_many_arguments)] fn inline_glyph( face: &Arc, @@ -942,9 +980,10 @@ fn inline_glyph( } _ => (kurbo::Affine::IDENTITY, h, None), }; - let tracking = st.tracking / 1000.0 * size; - let manual_kern = st.kerning.map(|v| v / 1000.0 * size).filter(|k| k.is_finite()).unwrap_or(0.0); - let adv = width + if tracking.is_finite() { tracking } else { 0.0 } + manual_kern; + let spacing_em = size * spacing_scale(st, vertical); + let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0); + let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0); + let adv = width + tracking + manual_kern; SGlyph { face: face.clone(), gid: 0, @@ -952,6 +991,7 @@ fn inline_glyph( len: range.len().max(1), run, adv: if adv.is_finite() { adv } else { 0.0 }, + tracking: if adv.is_finite() { tracking } else { 0.0 }, dx: 0.0, dy: 0.0, kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 }, @@ -1046,6 +1086,9 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let km = full / face.upem; let hs = st.h_scale / 100.0; let vs = st.v_scale / 100.0; + // The em the tracking and manual kerning are thousandths of: in horizontal type, as the + // Horizontal Scale draws it ([`spacing_scale`]; the runs shaped together share the scale). + let spacing_em = size * spacing_scale(st, feats.vertical); let ascent = face.ascent * km * vs; let descent = face.descent * km * vs; let cap = face.cap_height * km * vs; @@ -1275,9 +1318,10 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cluster_starts.dedup(); // A glyph is drawn in one style: where a cluster would take in characters of runs in different // styles, the runs are shaped apart, as runs that do not shape alike are. - // Except at harakat styled apart only in paint ([`crate::harakat`]). + // Except at harakat styled apart only in paint ([`crate::harakat`]), and, in horizontal type, where + // the runs differ only in settings that vertical type alone reads. let inside_clusters: Vec = boundaries() - .filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1) + .filter(|&b| cluster_starts.binary_search(&b).is_err() && !drawn_alike(run_at(b - 1).1, run_at(b).1, feats.vertical)) .filter(|b| mark_boundaries.binary_search(b).is_err()) .collect(); if !inside_clusters.is_empty() { @@ -1358,10 +1402,11 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe None => gid, }; let mut kern_shift = 0.0; + let mut tracked = 0.0; if ch == SOFT_HYPHEN || ch == '\u{2028}' { adv = 0.0; } else { - let manual_kern = gst.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0); + let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); // Manual kerning spaces a character from the next one in reading order. Right to left, // that one is drawn to its left: the cluster's glyphs move right along the baseline by // the value, so that the space its last glyph's advance gains is at the cluster's left. @@ -1369,8 +1414,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe kern_shift = manual_kern; } if last_in_cluster { - let tracking = gst.tracking / 1000.0 * size; - adv += tracking + manual_kern; + tracked = gst.tracking / 1000.0 * spacing_em; + adv += tracked + manual_kern; } } out.push(SGlyph { @@ -1380,6 +1425,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe len: end.saturating_sub(cl).max(1), run, adv, + tracking: tracked, dx: xo as f64 * k * hs, dy: -(yo as f64) * k * vs, kern_shift, diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index cbb2c20d2..aab7f0e1f 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -59,7 +59,8 @@ pub const VARIABLE_WIDEN: f32 = 0.25; /// axis (400–700, default 400, the face itself being Regular) whose maximum makes the glyphs of /// [`VARIABLE_CHARS`] [`VARIABLE_WIDEN`] wider (outlines and advances, through `gvar`), with the /// named instances Normal (the default, the face Regular), SemiBold (550) and Bold (700), plus an instance without -/// a name and one named like another (`BOLD`), which style lists leave out. `None` when it can't +/// a name and one named like another (`BOLD`), which style lists leave out. Its version (name ID 5) is +/// `Version 1.000`. `None` when it can't /// be built. pub fn variable_font() -> Option> { const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); @@ -106,6 +107,7 @@ pub fn variable_font() -> Option> { (1, VARIABLE_FAMILY), (2, "Regular"), (4, "Varitest Sans Regular"), + (5, "Version 1.000"), (6, "VaritestSans-Regular"), (256, "Weight"), (257, "Normal"), diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 2dd3c424c..8934090d5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -225,6 +225,177 @@ fn tracking_and_scale() { assert!(b.y1 < -4.0, "shifted up: {b:?}"); } +/// In horizontally scaled type, tracking and manual kerning are thousandths of the em as the scale +/// draws it (`spacing_scale`): at 50% they add half the space they add at 100%, after every +/// character, as the page of users' `.ai` files spaces such type. +#[test] +fn tracking_and_manual_kerning_take_the_horizontal_scale() { + let laid = + |h_scale: f64, tracking: f64, kerning: Option| layout(db(), &point("Wave", CharStyle { h_scale, tracking, kerning, ..style(20.0) })); + let advances = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::>(); + let line = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + // 200/1000 of a 20 pt em: 4 pt after each character at 100%, 2 pt at 50%. + for (h_scale, extra) in [(100.0, 4.0), (50.0, 2.0)] { + for (name, plain, spaced) in [ + ("tracking", laid(h_scale, 0.0, None), laid(h_scale, 200.0, None)), + ("manual kerning", laid(h_scale, 0.0, Some(0.0)), laid(h_scale, 0.0, Some(200.0))), + ] { + let (a, b) = (advances(&plain), advances(&spaced)); + assert_eq!(a.len(), 4, "{name}: {a:?}"); + assert_eq!(a.len(), b.len(), "{name}"); + for (p, s) in a.iter().zip(&b) { + assert!((s - p - extra).abs() < 1e-9, "{name} at {h_scale}%: {extra} pt more after each character: {a:?} {b:?}"); + } + let longer = line(&spaced) - line(&plain); + assert!((longer - 4.0 * extra).abs() < 1e-9, "{name} at {h_scale}%: the line is {} pt longer, not {longer}", 4.0 * extra); + } + } +} + +/// A centred or right-aligned line is as wide as its characters without the tracking after the +/// last one, as the page of users' `.ai` files aligns tracked lines: centred half that tracking +/// right of a line measured with it, right-aligned the whole tracking right, so the last glyph's +/// own advance ends on the anchor or the frame's edge. The tracking is still in the last glyph's +/// advance (the caret after it, and a selection of it, keep it). +#[test] +fn an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width() { + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + // 200/1000 of a 20 pt em: 4 pt after each character, also at 50% Horizontal Scale with 400. + for (tracking, h_scale, after) in [(200.0, 100.0, 4.0), (-100.0, 100.0, -2.0), (400.0, 50.0, 4.0)] { + let st = CharStyle { tracking, h_scale, ..style(20.0) }; + let mut t = point("Wave", st.clone()); + let w = width(&t); + let start = |t: &TextObject| layout(db(), t).glyphs[0].origin.x; + let end = |t: &TextObject| { + let l = layout(db(), t); + let last = l.glyphs.last().unwrap(); + last.origin.x + last.advance + }; + assert!(near(start(&t), 0.0), "left aligned point type starts at its anchor"); + t.para.justify = Justify::Center; + assert!(near(start(&t), -(w - after) / 2.0), "{tracking} at {h_scale}%: centred from {}, not {}", -(w - after) / 2.0, start(&t)); + t.para.justify = Justify::Right; + assert!(near(end(&t) - after, 0.0), "{tracking} at {h_scale}%: right aligned, the last glyph ends {} past the anchor", end(&t) - after); + let plain = layout(db(), &TextObject { runs: vec![TextRun::new("Wave", CharStyle { tracking: 0.0, ..st.clone() })], ..t.clone() }); + let tracked = layout(db(), &t); + let last_advance = |l: &TextLayout| l.glyphs.last().unwrap().advance; + assert!(near(last_advance(&tracked) - last_advance(&plain), after), "{tracking}: the last glyph keeps its tracking"); + // Area type: the same in its frame. + let frame = Rect::new(0.0, 0.0, 300.0, 100.0); + let centred = area("Wave", st.clone(), frame, Justify::Center); + assert!(near(start(¢red), (300.0 - (w - after)) / 2.0), "{tracking}: centred area type starts at {}", start(¢red)); + let right = area("Wave", st.clone(), frame, Justify::Right); + assert!(near(end(&right) - after, 300.0), "{tracking}: right aligned area type ends {} past its edge", end(&right) - after - 300.0); + // A line that ends in a space keeps its last letter's tracking: the space comes after it. + let spaced = area("Wave ", st, frame, Justify::Right); + let l = layout(db(), &spaced); + let e = l.glyphs.iter().find(|g| g.byte == 3).unwrap(); + assert!(near(e.origin.x + e.advance, 300.0), "{tracking}: before a space the line ends at {}", e.origin.x + e.advance); + } +} + +/// Right-aligned area type with a tab sets the text after its last tab as it sets a right-aligned +/// line: the last glyph's advance, less the tracking after it, ends on the frame's edge. +#[test] +fn the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out() { + let frame = Rect::new(0.0, 0.0, 336.0, 100.0); + let lay = layout(db(), &area("Item\t24.00", CharStyle { tracking: 200.0, ..style(13.0) }, frame, Justify::Right)); + assert!(lay.glyphs[0].origin.x.abs() < 1e-6, "the item starts at the left edge: {}", lay.glyphs[0].origin.x); + let last = lay.glyphs.last().unwrap(); + let after = 200.0 / 1000.0 * 13.0; + let end = last.origin.x + last.advance - after; + assert!((end - 336.0).abs() < 1e-6, "the price ends at {end}, not on the edge"); +} + +/// Which lines leave the tracking after their last character out of their width +/// (`LineInfo::trailing`): those a paragraph's end, a forced line break or a wrap ends, also a +/// wrap at a space (the space hangs past the line). Spaces typed before a paragraph's end or a +/// forced line break end the line themselves, and the letter before them keeps its tracking. So +/// do a line a hyphen ends, one whose last character hangs (burasagari, Optical Margin +/// Alignment), a justified line, right-to-left lines and lines ending in a right-to-left run, +/// vertical type and type on a path. +#[test] +fn which_lines_leave_the_tracking_after_their_last_character_out() { + let st = CharStyle { tracking: 200.0, ..style(20.0) }; + // 200/1000 of a 20 pt em. + let t = 4.0; + let trailing = |o: &TextObject| layout(db(), o).lines.iter().map(|l| l.trailing).collect::>(); + let centred = |text: &str| { + let mut o = point(text, st.clone()); + o.para.justify = Justify::Center; + o + }; + let narrow = Rect::new(0.0, 0.0, 90.0, 300.0); + assert_eq!(trailing(&area("Wave Wave", st.clone(), narrow, Justify::Center)), [t, t], "a wrap at a space"); + assert_eq!(trailing(&area("Wave-Wave", st.clone(), narrow, Justify::Center)), [t, t], "a wrap after a hyphen"); + assert_eq!(trailing(¢red("Wave\u{2028}Wave")), [t, t], "a forced line break"); + assert_eq!(trailing(¢red("Wave \u{2028}Wave")), [0.0, t], "a space typed before a forced line break"); + assert_eq!(trailing(¢red("Wave \nWave")), [0.0, t], "a space typed before a paragraph's end"); + // Hyphenated: the hyphen ends the line, and has no tracking. + let mut hyphenated = area("internationalization", st.clone(), Rect::new(0.0, 0.0, 120.0, 300.0), Justify::Right); + hyphenated.para.hyphenate = true; + hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine; + let lay = layout(db(), &hyphenated); + assert!(lay.lines.len() > 1 && lay.lines[0].end < "internationalization".len(), "the word is hyphenated: {:?}", lay.lines); + assert_eq!(lay.lines[0].trailing, 0.0, "a hyphenated line"); + // Justified: justification counts it (only the paragraph's last line is aligned). + assert_eq!(trailing(&area("Wave Wave", st.clone(), narrow, Justify::JustifyLeft)), [0.0, t], "a justified line"); + // A last character that hangs. + let mut hanging = area("あいう。", st.clone(), Rect::new(0.0, 0.0, 300.0, 100.0), Justify::Center); + hanging.para.burasagari = vectorcraft_doc::Burasagari::Forced; + assert_eq!(trailing(&hanging), [0.0], "a full stop burasagari hangs"); + let mut optical = area("Wave.", st.clone(), Rect::new(0.0, 0.0, 300.0, 100.0), Justify::Right); + optical.optical_margin = true; + assert_eq!(trailing(&optical), [0.0], "a full stop Optical Margin Alignment hangs"); + optical.runs = vec![TextRun::new("Wave", st.clone())]; + assert_eq!(trailing(&optical), [t], "a letter that doesn't hang"); + // Right to left: a right-to-left paragraph ending in Latin, a Latin one ending in Hebrew. + assert_eq!(trailing(¢red("שלום abc")), [0.0], "a right-to-left paragraph"); + assert_eq!(trailing(¢red("abc שלום")), [0.0], "a right-to-left run ending the line"); + // Vertical type. + let mut vertical = centred("Wave"); + vertical.vertical = true; + assert_eq!(trailing(&vertical), [0.0], "vertical type"); + // Type on a path, centred: as wide as all its advances. + let mut on_path = centred("Wave"); + let w = width(&on_path); + let mut line = kurbo::BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let lay = layout(db(), &on_path); + assert_eq!(lay.lines[0].trailing, 0.0, "type on a path"); + assert!((lay.glyphs[0].origin.x - (500.0 - w) / 2.0).abs() < 1e-6, "{}", lay.glyphs[0].origin.x); +} + +/// Justification… Desired Glyph Scaling scales each glyph's advance with the tracking in it: an +/// aligned line leaves out its last character's tracking as scaled. +#[test] +fn an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it() { + let mut t = point("Wave", CharStyle { tracking: 400.0, ..style(20.0) }); + t.para.justify = Justify::Center; + t.para.justification.glyph_scaling = [80.0, 90.0, 110.0]; + let lay = layout(db(), &t); + let w: f64 = lay.glyphs.iter().map(|g| g.advance).sum(); + // 400/1000 of a 20 pt em, at 90%. + let left_out = 8.0 * 0.9; + assert!((lay.lines[0].trailing - left_out).abs() < 1e-9, "{}", lay.lines[0].trailing); + assert!((lay.glyphs[0].origin.x + (w - left_out) / 2.0).abs() < 1e-6, "centred from {}", lay.glyphs[0].origin.x); +} + +/// The factor tracking and manual kerning take from the Horizontal Scale: the scale in horizontal +/// type, 1 in vertical type and for a scale that isn't a positive number. +#[test] +fn spacing_scale_is_the_horizontal_scale_of_horizontal_type() { + let at = |h_scale: f64, vertical: bool| spacing_scale(&CharStyle { h_scale, ..style(20.0) }, vertical); + assert_eq!(at(50.0, false), 0.5); + assert_eq!(at(250.0, false), 2.5); + assert_eq!(at(50.0, true), 1.0, "vertical type keeps the em"); + for junk in [0.0, -50.0, f64::NAN, f64::INFINITY] { + assert_eq!(at(junk, false), 1.0, "{junk}"); + } +} + #[test] fn underline_and_strikethrough_bars() { let mut t = point("ab", style(20.0)); diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index d7cd58401..69f9bdb66 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -176,6 +176,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout baseline, x0: 0.0, x1: x, + trailing: 0.0, ascent: 10.0, descent: 3.0, first_baseline: 0.0, diff --git a/crates/text/src/tests/fit.rs b/crates/text/src/tests/fit.rs index 415ddc90a..1f9d55aa4 100644 --- a/crates/text/src/tests/fit.rs +++ b/crates/text/src/tests/fit.rs @@ -1,8 +1,8 @@ -//! Area type fitting: Shrink Text to Fit. +//! Area type fitting (Shrink Text to Fit) and type on a path fitting (Dynamic Text). use crate::*; use kurbo::Shape; -use vectorcraft_doc::{AreaFit, CharStyle, TextKind}; +use vectorcraft_doc::{AreaFit, CharStyle, TextKind, TextRun}; use vectorcraft_geom::PathData; const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \ @@ -67,6 +67,18 @@ fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() { assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6); } +/// A size that measures the cap height shrinks in steps of 0.1 pt of the size the Character panel +/// shows, not of the em size the layout sets it at. +#[test] +fn shrink_text_steps_the_size_the_character_panel_shows() { + let mut t = area(COPY, 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(30.0)); + t.runs[0].style.font_height_reference = vectorcraft_doc::text::FontHeightReference::CapHeight; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale); + let shown = 14.0 * l.fit_scale; + assert!((shown * 10.0 - (shown * 10.0).round()).abs() < 1e-6, "{shown}"); +} + #[test] fn shrink_text_respects_its_minimum() { let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0)); @@ -174,3 +186,182 @@ fn shrink_text_follows_top_to_top_leading_and_character_alignment() { } assert!(layout(db(), &bigger).overflow); } + +/// Type on a path `path` set at `size`, with Dynamic Text on. +fn dynamic_on(text: &str, size: f64, path: &kurbo::BezPath, end: Option) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size, ..CharStyle::default() }); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(path), start: 0.0, end }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + t +} + +/// Type on a straight path `len` points long, set at `size`, with Dynamic Text on. +fn dynamic(text: &str, size: f64, len: f64) -> TextObject { + dynamic_on(text, size, &kurbo::Line::new((0.0, 100.0), (len, 100.0)).to_path(0.1), None) +} + +/// `t` laid out at `f` times its sizes with Dynamic Text off. +fn fixed_at(t: &TextObject, f: f64) -> TextLayout { + let mut s = scaled(t, f); + s.dynamic_text = false; + layout(db(), &s) +} + +/// Checks that Dynamic Text laid `t` out at the largest tenth of a point that fits: the first run's +/// drawn size is whole tenths, the layout is that of `t` at that size, and a tenth more overflows. +fn assert_fills(t: &TextObject, what: &str) -> TextLayout { + let l = layout(db(), t); + let size = t.runs[0].style.size * l.fit_scale; + assert!((size * 10.0 - (size * 10.0).round()).abs() < 1e-6, "{what}: {size}"); + assert!(!l.overflow, "{what}: fits at {size}"); + let same = fixed_at(t, l.fit_scale); + assert_eq!((same.glyphs.len(), same.overflow), (l.glyphs.len(), false), "{what}"); + assert!(fixed_at(t, (size + 0.1) / t.runs[0].style.size).overflow, "{what}: {size} + 0.1 pt still fits"); + l +} + +/// Dynamic Text: short text grows and long text shrinks to fill the span of its path, at the +/// largest size in tenths of a point that does not run past the end bracket, and the line then +/// takes the span's length within one step. +/// +#[test] +fn dynamic_text_fills_the_span_of_its_path() { + let short = dynamic("Fill me", 12.0, 400.0); + let mut off = short.clone(); + off.dynamic_text = false; + assert_eq!(layout(db(), &off).fit_scale, 1.0, "off, the text keeps its size"); + let l = assert_fills(&short, "short"); + assert!(l.fit_scale > 2.0, "short text grows: {}", l.fit_scale); + let (used, room) = l.path_fill.unwrap_or_default(); + let size = 12.0 * l.fit_scale; + assert!(used <= room + 1e-6 && room - used <= used * 0.1 / size * 1.5 + 1e-6, "{used} of {room} at {size}"); + let long = dynamic(COPY, 12.0, 400.0); + assert!(fixed_at(&long, 1.0).overflow); + let l = assert_fills(&long, "long"); + assert!(l.fit_scale < 1.0, "long text shrinks: {}", l.fit_scale); + // Deterministic. + assert_eq!(layout(db(), &long).fit_scale, l.fit_scale); +} + +/// Dynamic Text refits to the brackets: an end bracket nearer the start makes the text smaller, and +/// a closed path with no end bracket holds the text once round. +/// +#[test] +fn dynamic_text_follows_its_brackets() { + let line = kurbo::Line::new((0.0, 100.0), (400.0, 100.0)).to_path(0.1); + let whole = layout(db(), &dynamic_on("Brackets", 12.0, &line, None)); + let half = dynamic_on("Brackets", 12.0, &line, Some(0.5)); + let l = assert_fills(&half, "half"); + assert!(l.fit_scale < whole.fit_scale * 0.6, "{} vs {}", l.fit_scale, whole.fit_scale); + let circle = kurbo::Circle::new((200.0, 200.0), 100.0).to_path(0.1); + let round = dynamic_on("Round and round the path", 12.0, &circle, None); + let l = assert_fills(&round, "circle"); + let (used, room) = l.path_fill.unwrap_or_default(); + assert!((room - std::f64::consts::TAU * 100.0).abs() < 1.0, "{room}"); + assert!(used > room * 0.95, "{used} of {room}"); +} + +/// The runs keep their proportions: one factor scales every size. +#[test] +fn dynamic_text_keeps_the_runs_proportions() { + let mut t = dynamic("Small", 10.0, 400.0); + t.runs.push(TextRun::new(" LARGE", CharStyle { size: 20.0, ..CharStyle::default() })); + let l = assert_fills(&t, "two runs"); + let d = drawn(&t); + assert!((d.runs[1].style.size / d.runs[0].style.size - 2.0).abs() < 1e-9); + assert!((d.runs[0].style.size - 10.0 * l.fit_scale).abs() < 1e-9); + // A size Dynamic Text does not scale is drawn as stored. + let mut point = t.clone(); + point.dynamic_text = false; + assert!(matches!(drawn(&point), std::borrow::Cow::Borrowed(_))); +} + +/// Nothing to fit: empty text, a path with no length and a span with no length lay out as they are. +/// Past the size limits the text stays at 0.1 pt (and overflows) or at 1,296 pt. +#[test] +fn dynamic_text_edges_and_limits() { + let empty = layout(db(), &dynamic("", 12.0, 400.0)); + assert_eq!((empty.fit_scale, empty.overflow), (1.0, false)); + let spaces = assert_fills(&dynamic(" ", 12.0, 400.0), "spaces"); + assert!(spaces.fit_scale > 1.0); + let one = assert_fills(&dynamic("W", 12.0, 100.0), "one character"); + assert!(one.fit_scale > 1.0); + let point = kurbo::Line::new((50.0, 50.0), (50.0, 50.0)).to_path(0.1); + let l = layout(db(), &dynamic_on("No room", 12.0, &point, None)); + assert_eq!((l.fit_scale, l.overflow), (1.0, true)); + let line = kurbo::Line::new((0.0, 100.0), (400.0, 100.0)).to_path(0.1); + let mut closed_span = dynamic_on("No span", 12.0, &line, Some(0.5)); + closed_span.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.5, end: Some(0.5) }; + let l = layout(db(), &closed_span); + assert_eq!((l.fit_scale, l.overflow), (1.0, true)); + // A span of a hundredth of a point: down at 0.1 pt the text still overflows. + let tiny = layout(db(), &dynamic(COPY, 12.0, 0.01)); + assert!(tiny.overflow && (12.0 * tiny.fit_scale - 0.1).abs() < 1e-9, "{}", 12.0 * tiny.fit_scale); + // A span of 100,000 points: one letter stops at 1,296 pt. + let huge = layout(db(), &dynamic("i", 12.0, 100_000.0)); + assert!(!huge.overflow && (12.0 * huge.fit_scale - 1296.0).abs() < 1e-9, "{}", 12.0 * huge.fit_scale); + // The limits apply to the first character's size, past an empty run before it. + let mut empty_first = dynamic("", 0.1, 100_000.0); + empty_first.runs.push(TextRun::new("i", CharStyle { size: 100.0, ..CharStyle::default() })); + let l = layout(db(), &empty_first); + assert!((100.0 * l.fit_scale - 1296.0).abs() < 1e-9, "{}", 100.0 * l.fit_scale); + // A size that measures the cap height steps and stops as the Character panel shows it. + let mut cap = dynamic("i", 12.0, 100_000.0); + cap.runs[0].style.font_height_reference = vectorcraft_doc::text::FontHeightReference::CapHeight; + let l = layout(db(), &cap); + assert!((12.0 * l.fit_scale - 1296.0).abs() < 1e-9, "{}", 12.0 * l.fit_scale); + let cap_short = + TextObject { kind: dynamic("Cap height", 12.0, 400.0).kind, runs: vec![TextRun::new("Cap height", cap.runs[0].style.clone())], ..cap }; + let l = layout(db(), &cap_short); + let shown = 12.0 * l.fit_scale; + assert!(!l.overflow && (shown * 10.0 - (shown * 10.0).round()).abs() < 1e-6, "{shown}"); + // The same document size whatever the local size: a 12 pt run stored at 0.012 under a scale of 1,000. + let mut scaled_xf = dynamic("Fill me", 0.012, 0.4); + scaled_xf.xf = Affine::scale(1000.0); + let l = layout(db(), &scaled_xf); + let document_size = 12.0 * l.fit_scale; + assert!(!l.overflow && (document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6, "{document_size}"); +} + +/// Right-to-left text, vertical type, a bulleted paragraph, Optical Margin Alignment and Spacing +/// fit without overflowing, through the layout's own overflow rule. +#[test] +fn dynamic_text_fits_every_kind_of_line() { + let arc = { + let mut p = kurbo::BezPath::new(); + p.move_to((0.0, 200.0)); + p.quad_to((200.0, 0.0), (400.0, 200.0)); + p + }; + let rtl = dynamic_on("שלום עולם", 12.0, &arc, None); + assert!(assert_fills(&rtl, "right to left").fit_scale > 1.0); + let mut vertical = dynamic_on("Vertical type", 12.0, &kurbo::Line::new((0.0, 0.0), (0.0, 400.0)).to_path(0.1), None); + vertical.vertical = true; + assert!(assert_fills(&vertical, "vertical").fit_scale > 1.0); + let mut bullet = dynamic_on("Bulleted", 12.0, &arc, None); + bullet.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + let l = assert_fills(&bullet, "bulleted"); + assert!(!l.markers.is_empty()); + let mut optical = dynamic_on("“Quoted.”", 12.0, &arc, None); + optical.optical_margin = true; + assert!(assert_fills(&optical, "optical margin").fit_scale > 1.0); + let mut spaced = dynamic_on("Spaced around a curve", 12.0, &arc, None); + spaced.path_spacing = 10.0; + assert!(assert_fills(&spaced, "spacing").fit_scale > 1.0); +} + +/// The search lays the text out at most 11 times past the first, however far the size moves. +#[test] +fn dynamic_text_lays_out_a_bounded_number_of_times() { + for (text, len) in [("Fill me", 400.0), (COPY, 400.0), (COPY, 0.01), ("i", 100_000.0), ("Round", 37.3)] { + let before = crate::layout::fit_passes(); + let l = layout(db(), &dynamic(text, 12.0, len)); + let passes = crate::layout::fit_passes() - before; + assert!((1..=11).contains(&passes), "{text:?} on {len}: {passes} passes (fit {})", l.fit_scale); + } + // A straight span with no spacing starts from the right size: a few passes. + let before = crate::layout::fit_passes(); + layout(db(), &dynamic(COPY, 12.0, 300.0)); + assert!(crate::layout::fit_passes() - before <= 4); +} diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index c097fc48b..e590f103c 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -512,6 +512,43 @@ fn assert_authored_ownership(t: &TextObject, text: &str, l: &TextLayout, what: & } } +/// Repeated glyphs in one cluster can belong to differently painted runs. Match each authored +/// occurrence in order, without needing an installed font, and still reject a changed owner. +#[test] +fn duplicate_authored_glyphs_keep_occurrence_order() { + let mut text = TextObject::point(Point::ZERO, "\u{0651}", style(FAMILY)); + text.runs.push(vectorcraft_doc::TextRun::new("\u{0651}", CharStyle { fill: red(), ..style(FAMILY) })); + let glyph = |run| PositionedGlyph { + outline: Default::default(), + run, + byte: 0, + origin: Point::ZERO, + advance: 0.0, + len: 4, + angle: 0.0, + line: 0, + font_id: 0, + gid: 7, + xf: Affine::IDENTITY, + rtl: true, + scale: 1.0, + image: None, + colour: None, + bend: None, + unturned: Affine::IDENTITY, + pivot: Point::ZERO, + carets: Vec::new(), + width: 0.0, + width_start: 0.0, + }; + let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() }; + assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0); + + let mut wrong = before.clone(); + wrong.glyphs[0].run = 1; + assert!(std::panic::catch_unwind(|| assert_kashida_ownership(&text, &before, &wrong, &red(), "changed owner", false)).is_err()); +} + /// Compare authored glyph run indices before/after Insert Kashidas (matched by byte and gid; /// layout order can differ between point and justified area), and assert every inserted tatweel /// uses the base letter's paint at the cluster start—not `mark_fill`. When `require_mark_last`, @@ -525,16 +562,17 @@ fn assert_kashida_ownership( what: &str, require_mark_last: bool, ) -> usize { - let mut before_runs: std::collections::HashMap<(usize, u32), Vec> = std::collections::HashMap::new(); + let mut before_runs: std::collections::HashMap<(usize, u32), std::collections::VecDeque> = std::collections::HashMap::new(); for g in before.glyphs.iter().filter(|g| g.len > 0) { - before_runs.entry((g.byte, g.gid)).or_default().push(g.run); + before_runs.entry((g.byte, g.gid)).or_default().push_back(g.run); } let mut after_n = 0; for g in with_kashida.glyphs.iter().filter(|g| g.len > 0) { after_n += 1; let runs = before_runs.get_mut(&(g.byte, g.gid)).unwrap_or_else(|| panic!("{what}: authored glyph {} at {} missing before kashida", g.gid, g.byte)); - let want_run = runs.pop().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte)); + // Duplicate glyphs at the same cluster byte retain their occurrence order. + let want_run = runs.pop_front().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte)); assert_eq!(g.run, want_run, "{what}: authored run at {} gid {}", g.byte, g.gid); let want_fill = coloured.runs.get(want_run).map(|r| &r.style.fill).unwrap_or_else(|| panic!("{what}: missing run {want_run}")); assert_eq!(&run_of(coloured, g).style.fill, want_fill, "{what}: authored fill at {}", g.byte); diff --git a/crates/text/src/tests/inline.rs b/crates/text/src/tests/inline.rs index 76c90e65b..01db5393c 100644 --- a/crates/text/src/tests/inline.rs +++ b/crates/text/src/tests/inline.rs @@ -71,6 +71,10 @@ fn shift_tracking_and_missing_symbols() { let mut t = text("A", art(1.0), "B", 20.0); t.runs[1].style.tracking = 100.0; assert!((inline_glyph(&layout(db(), &t)).advance - 42.0).abs() < 1e-6); + // At 50% Horizontal Scale the tracking adds half as much, as after a glyph; the art keeps its + // width. + t.runs[1].style.h_scale = 50.0; + assert!((inline_glyph(&layout(db(), &t)).advance - 41.0).abs() < 1e-6); // Unresolved (missing symbol): a square of the height, nothing to draw. let t = text("A", InlineArt::new("gone"), "B", 20.0); let l = layout(db(), &t); diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index f669b07f4..a1e06887a 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -629,8 +629,33 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } +/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets, so +/// setting one of them on the f of "fi" keeps the ligature. Vertical type reads them per glyph, so +/// there the f is shaped apart from the i. +#[test] +fn vertical_only_settings_keep_a_horizontal_ligature_whole() { + let db = db_with(vertical_ligature_caret_font(280)); + let marks: [fn(&mut CharStyle); 3] = [|s| s.standard_vertical_roman = true, |s| s.tcy_up_down = 10.0, |s| s.tcy_left_right = 10.0]; + for (i, mark) in marks.into_iter().enumerate() { + for vertical in [false, true] { + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = vertical; + let plain = t.runs[0].style.clone(); + let mut marked = plain.clone(); + mark(&mut marked); + t.runs = vec![ + vectorcraft_doc::TextRun { text: "f".into(), style: marked, inline: None }, + vectorcraft_doc::TextRun { text: "i".into(), style: plain, inline: None }, + ]; + let l = layout(&db, &t); + let joined = l.glyphs.iter().any(|g| g.len == 2); + assert_eq!(joined, !vertical, "setting {i}, vertical {vertical}: {:?}", l.glyphs); + } + } +} + /// On a path, an upright ligature in vertical type stands with its top toward the path's start, and -/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical +/// its cell runs one em from the pen, as in vertical point type, with or without the font's vertical /// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends /// there. #[test] @@ -648,21 +673,21 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { assert!(l.on_path); let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; assert_eq!(g.carets, vec![(1, 0.5)], "{family}"); - assert!((g.width - 100.0).abs() < 1e-9 && (g.width_start + 12.0).abs() < 1e-9, "{family}: {} {}", g.width, g.width_start); + assert!((g.width - 100.0).abs() < 1e-9 && g.width_start.abs() < 1e-9, "{family}: {} {}", g.width, g.width_start); let x = |b| caret_position(&l, b).0.x - g.origin.x; - assert!((x(1) - 38.0).abs() < 1e-6, "{family}: {}", x(1)); + assert!((x(1) - 50.0).abs() < 1e-6, "{family}: {}", x(1)); let q = selection_quads(&l, 0, 1); assert_eq!(q.len(), 1, "{q:?}"); - assert!(((q[0][1] - q[0][0]).hypot() - 38.0).abs() < 1e-6, "{family}: {q:?}"); - // A baseline shift moves the turned glyph along the path, and its stop with it. - let ink = g.outline.bounding_box().x0; + assert!(((q[0][1] - q[0][0]).hypot() - 50.0).abs() < 1e-6, "{family}: {q:?}"); + // A baseline shift moves the turned glyph across the path, and leaves its stop in place. + let ink = g.outline.bounding_box(); t.runs[0].style.baseline_shift = 10.0; let l = layout(&db, &t); let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; - let moved = s.outline.bounding_box().x0 - ink; - assert!(moved.abs() > 1.0, "{family}: the shift moves the glyph along the path: {moved}"); + let moved = s.outline.bounding_box().center() - ink.center(); + assert!((moved.x.abs()) < 1e-6 && (moved.y + 10.0).abs() < 1e-6, "{family}: the shift moves the glyph to the path's left: {moved:?}"); let stop = caret_position(&l, 1).0.x - s.origin.x; - assert!((stop - 38.0 - moved).abs() < 1e-6, "{family}: {stop} against {}", 38.0 + moved); + assert!((stop - 50.0).abs() < 1e-6, "{family}: {stop}"); // Horizontal Scale widens the glyph across the path; its stop stays inside the glyph. t.runs[0].style.baseline_shift = 0.0; t.runs[0].style.h_scale = 200.0; @@ -693,7 +718,7 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { /// On a path, an upright ligature in vertical type reads along the path in order: the top of its /// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click /// and the selection put the stop, on a path running either way with any path effect. A glyph lying -/// on its side keeps its top toward the path's end. +/// on its side is a quarter turn clockwise from that, as horizontal type on the path sets it. #[test] fn an_upright_ligature_on_a_path_reads_along_it_in_order() { use vectorcraft_doc::PathEffect; @@ -738,8 +763,8 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() { let (q0, q1) = (along(q[0][0]), along(q[0][1])); assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}"); } - // Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces - // the path's end and its advance runs across the path, to its right. + // Without Standard Vertical Roman Alignment the letter lies on its side: its top faces the + // path's left and its advance runs along the path. let mut line = BezPath::new(); line.move_to((0.0, 0.0)); line.line_to((1000.0, 0.0)); @@ -749,9 +774,9 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() { let l = layout(&db, &t); let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; let tangent = Vec2::new(s.angle.cos(), s.angle.sin()); - let right = Vec2::new(-tangent.y, tangent.x); + let left = Vec2::new(tangent.y, -tangent.x); let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0)); - assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}"); + assert!((up - pen).dot(left) > 1.0 && (ahead - pen).dot(tangent) > 1.0, "{pen:?} {up:?} {ahead:?}"); } /// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 4b8bdbc09..4790b642a 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -66,6 +66,24 @@ fn optical_pairs_cross_paint_runs_and_manual_and_tracking_are_independent() { assert!((pair_advance(&rtl_manual) - pair_advance(&rtl_pair_zero) - 20.0).abs() < 0.01, "manual spacing changes the adjacent right-to-left pair"); } +/// At 50% Horizontal Scale the tracking adds half the space it adds at 100% (`spacing_scale`), +/// and the Optical pairs measure their gaps without it: tracked, each advance is the untracked +/// Optical one and the tracking, the corrections unchanged. +#[test] +fn optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking() { + let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, h_scale: 50.0, ..CharStyle::default() }; + let advances = |st: CharStyle| layout(db(), &TextObject::point(Point::ZERO, "IIAV", st)).glyphs.iter().map(|g| g.advance).collect::>(); + let optical = advances(st.clone()); + let unkerned = advances(CharStyle { kerning: Some(0.0), ..st.clone() }); + assert!((optical[0] - unkerned[0]).abs() > 0.1, "Optical changes the monospaced pair at 50%: {optical:?} {unkerned:?}"); + let tracked = advances(CharStyle { tracking: 100.0, ..st }); + assert_eq!(optical.len(), tracked.len()); + for (o, t) in optical.iter().zip(&tracked) { + // 100/1000 of the 100 pt em drawn 50% wide. + assert!((t - o - 5.0).abs() < 0.01, "{optical:?} {tracked:?}"); + } +} + #[test] fn rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order() { for optical_kerning in [false, true] { diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 225395d34..2bc1c2ff7 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -184,6 +184,304 @@ fn type_on_a_path_stays_horizontal() { assert!(!layout(FontDb::global(), &t).vertical); } +/// `text` in vertical type on a straight path from `from` to `to`, with path effect `effect`. +fn on_vertical_path(text: &str, from: (f64, f64), to: (f64, f64), effect: vectorcraft_doc::PathEffect) -> TextObject { + let mut t = vertical(text); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new(from, to).to_path(0.1)), start: 0.0, end: None }; + t.path_effect = effect; + t +} + +/// Glyph `i`'s top (its ascender direction) and its own advance direction in text space, as unit +/// vectors. +fn top_and_ahead(l: &TextLayout, i: usize) -> (Vec2, Vec2) { + let g = &l.glyphs[i]; + let pen = g.xf * Point::ZERO; + let unit = |v: Vec2| v / v.hypot(); + (unit(g.xf * Point::new(0.0, -500.0) - pen), unit(g.xf * Point::new(500.0, 0.0) - pen)) +} + +/// On a vertical path with Rainbow, an upright glyph's top faces the path's start, and a letter lying +/// on its side is a quarter turn clockwise from it: its top faces the path's left and its baseline +/// runs along the path. Both turn with the path when it runs the other way. The effects that keep +/// the glyphs' axes fixed on the page keep that relation only on a path that runs to the right. +#[test] +fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-1000.0, 0.0), (700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (upright, _) = top_and_ahead(&l, 0); + let (side, ahead) = top_and_ahead(&l, 1); + assert!((upright + tangent).hypot() < 1e-6, "{to:?}: the upright top faces the start: {upright:?}"); + // A clockwise quarter turn with y down takes (x, y) to (-y, x). + assert!((side - Vec2::new(-upright.y, upright.x)).hypot() < 1e-6, "{to:?}: {side:?} against {upright:?}"); + assert!((side - left).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "{to:?}: {side:?} {ahead:?}"); + } + // Skew, 3D Ribbon and Stair Step keep axes of the glyphs fixed on the page: on a path that runs + // to the right the relation holds. On one that runs to the left, Skew and 3D Ribbon mirror both + // kinds, and Stair Step keeps letters level while upright glyphs still face the path's start, so + // letters are a quarter turn counterclockwise from upright glyphs there. + for effect in [PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep] { + for to in [(1000.0, 0.0), (700.0, 700.0), (0.0, 1000.0), (0.0, -1000.0), (-1000.0, 0.0), (-700.0, 300.0)] { + // Skew and 3D Ribbon slant glyphs on a slanted path, where no turn relates them exactly. + if to.1 != 0.0 && effect != PathEffect::StairStep { + continue; + } + let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, effect)); + let (upright, _) = top_and_ahead(&l, 0); + let (side, _) = top_and_ahead(&l, 1); + let clockwise = to.0 >= 0.0; + let want = if clockwise { Vec2::new(-upright.y, upright.x) } else { Vec2::new(upright.y, -upright.x) }; + assert!((side - want).hypot() < 1e-6, "{effect:?} {to:?}: {side:?} against {upright:?}"); + } + } +} + +/// On a vertical path, letters lying on their side are set as horizontal type on the same path sets +/// them, with every path effect and on a path running either way, and they follow one another along +/// the path without overlapping where the effect keeps them square to it. +#[test] +fn on_a_vertical_path_letters_keep_the_horizontal_path_pose() { + use vectorcraft_doc::PathEffect; + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0), (700.0, 300.0)] { + let mut vertical = on_vertical_path("LLl", (0.0, 0.0), to, effect); + let v = layout(FontDb::global(), &vertical); + vertical.vertical = false; + let h = layout(FontDb::global(), &vertical); + for i in 0..3 { + let (a, b) = (v.glyphs[i].xf.as_coeffs(), h.glyphs[i].xf.as_coeffs()); + assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{effect:?} {to:?} glyph {i}: {a:?} against {b:?}"); + } + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let span = |i: usize| { + let along = v.glyphs[i].outline.elements().iter().filter_map(|el| el.end_point()).map(|p| p.to_vec2().dot(tangent)); + along.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), d| (lo.min(d), hi.max(d))) + }; + // Skew slants the letters, Stair Step keeps them level and Gravity fans them toward the + // path's center, so their ink can reach past the next letter's. + if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) { + assert!((0..2).all(|i| span(i).1 <= span(i + 1).0 + 1e-6), "{effect:?} {to:?}: {:?}", (0..3).map(span).collect::>()); + } + } + } +} + +/// On a vertical path, the column is centered on the path: the em boxes of upright glyphs, of letters +/// lying on their side and of a tate-chu-yoko block straddle it, their centers on the path line, on +/// a path running any way. +#[test] +fn on_a_vertical_path_the_column_is_centered_on_the_path() { + use vectorcraft_doc::PathEffect; + let Some(face) = FontDb::global().face("Source Sans 3", "Regular") else { panic!("the bundled face") }; + let (em, centre) = (face.units_per_em(), face.ideographic_centre()); + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§H12§", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x)); + // An upright glyph (the shaper centers a narrow one in its em box, as off a path) has its + // ink centered on the path. + for i in [0, 4] { + let mid = across(ink(&l, i).center()); + assert!(mid.abs() < 1.0, "{to:?}: upright glyph {i} is {mid} off the path"); + } + // A letter lying on its side: its em box's center is `centre` em above its baseline. + let mid = across(l.glyphs[1].xf * Point::new(0.0, -centre * em)); + assert!(mid.abs() < 1e-6, "{to:?}: the letter's em box is {mid} off the path"); + // The digits of the block stand side by side across the path, the block centered on it. + let block = (across(ink(&l, 2).center()) + across(ink(&l, 3).center())) * 0.5; + assert!(block.abs() < 1.5, "{to:?}: the block is {block} off the path"); + } +} + +/// On a vertical path, the caret and the selection span the column, half its em on each side of the +/// path, as the glyphs do. +#[test] +fn on_a_vertical_path_the_caret_and_selection_span_the_column() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (1000.0, 0.0), (-700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§L§", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x)); + let (top, bottom) = caret_position(&l, "§".len()); + assert!((across(top) - 10.0).abs() < 1e-6 && (across(bottom) + 10.0).abs() < 1e-6, "{to:?}: caret {top:?} {bottom:?}"); + for q in selection_quads(&l, 0, "§L§".len()) { + let (lo, hi) = q.iter().map(|p| across(*p)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a))); + assert!((lo + 10.0).abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{to:?}: highlight from {lo} to {hi}"); + } + } +} + +/// On a straight path running down from the anchor, vertical type sets every glyph where vertical +/// point type sets it: upright glyphs with a horizontal scale stay centered on the column, a baseline +/// shift moves an upright glyph across the column, every upright glyph keeps its own cell along the +/// path, and a combining mark stays on its upright letter. +#[test] +fn on_a_downward_vertical_path_glyphs_sit_where_vertical_point_type_sets_them() { + use crate::test_fonts::{VERTICAL_FAMILY, vertical_font}; + let run = |text: &str, style: CharStyle| TextRun { text: text.into(), style, inline: None }; + let runs = |plain: CharStyle| { + vec![ + run("§×", plain.clone()), + run("§", CharStyle { h_scale: 150.0, ..plain.clone() }), + run("§", CharStyle { baseline_shift: 3.0, ..plain.clone() }), + run("§", CharStyle { rotation: 30.0, ..plain.clone() }), + run("L", CharStyle { h_scale: 150.0, ..plain.clone() }), + run("L", CharStyle { baseline_shift: 3.0, ..plain.clone() }), + run("q\u{301}x", CharStyle { standard_vertical_roman: true, ..plain.clone() }), + run("§", plain), + ] + }; + let compare = |db: &FontDb, point: &TextObject, name: &str| { + let mut on_path = point.clone(); + on_path.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None }; + let (p, v) = (layout(db, point), layout(db, &on_path)); + assert_eq!(p.glyphs.len(), v.glyphs.len(), "{name}"); + let apart: Vec<_> = (p.glyphs.iter().zip(&v.glyphs).enumerate()) + .filter(|(_, (a, b))| a.xf.as_coeffs().iter().zip(b.xf.as_coeffs()).any(|(a, b)| (a - b).abs() > 1e-6)) + .map(|(i, (a, b))| format!("glyph {i}: {:?} on the path, {:?} in point type", b.xf.as_coeffs(), a.xf.as_coeffs())) + .collect(); + assert!(apart.is_empty(), "{name}: {apart:#?}"); + }; + let mut point = vertical(""); + point.runs = runs(CharStyle { size: 20.0, ..CharStyle::default() }); + compare(FontDb::global(), &point, "the bundled face"); + // A face with vertical metrics, whose cells hang from their vertical origins. + let db = FontDb::with_font_dirs(vec![]); + db.add_font(vertical_font(true).unwrap()); + point.runs = runs(CharStyle { size: 20.0, font_family: VERTICAL_FAMILY.into(), ..CharStyle::default() }); + compare(&db, &point, "vertical metrics"); + // The horizontally scaled upright glyph is wider across the column, centered where the plain one is. + point.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None }; + let l = layout(&db, &point); + let (plain, wide) = (ink(&l, 0), ink(&l, 2)); + assert!(wide.width() > plain.width() * 1.4 && (wide.center().x - plain.center().x).abs() < 1e-6, "{plain:?} {wide:?}"); +} + +/// With Skew on a curved vertical path, every glyph's axis along the path follows the tangent and +/// its axis across the path stays upright on the page: an upright glyph's top runs back along the +/// tangent and its own x axis points up the page, and a letter lying on its side keeps its top up +/// the page and its baseline along the tangent. +#[test] +fn on_a_curved_vertical_path_skew_keeps_the_axis_across_the_path_upright() { + use vectorcraft_doc::PathEffect; + let mut arc = kurbo::BezPath::new(); + arc.move_to((0.0, 0.0)); + arc.curve_to((40.0, 0.0), (80.0, 40.0), (80.0, 120.0)); + let mut t = vertical("§L§L§"); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None }; + t.path_effect = PathEffect::Skew; + let l = layout(FontDb::global(), &t); + let page_up = Vec2::new(0.0, -1.0); + for (i, g) in l.glyphs.iter().enumerate() { + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let (top, ahead) = top_and_ahead(&l, i); + if i % 2 == 0 { + assert!((top + tangent).hypot() < 1e-6 && (ahead - page_up).hypot() < 1e-6, "upright glyph {i}: {top:?} {ahead:?} at {tangent:?}"); + } else { + assert!((top - page_up).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "letter {i}: {top:?} {ahead:?} at {tangent:?}"); + } + } + let turns = l.glyphs.iter().map(|g| g.angle).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a))); + assert!(turns.1 - turns.0 > 0.3, "the glyphs sit on the curve: {turns:?}"); +} + +/// A tate-chu-yoko block whose characters differ in size stays one block: every character stands on +/// the block's baseline in the em of its first character, in vertical type and on a vertical path. +#[test] +fn a_tate_chu_yoko_block_of_mixed_sizes_shares_its_first_characters_em() { + let run = |text: &str, size: f64| TextRun { text: text.into(), style: CharStyle { size, ..CharStyle::default() }, inline: None }; + let mut t = vertical(""); + t.runs = vec![run("§", 20.0), run("1", 20.0), run("2", 10.0), run("§", 20.0)]; + let mut on_path = t.clone(); + on_path.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 1000.0)).to_path(0.1)), start: 0.0, end: None }; + for (name, t) in [("vertical", &t), ("on a path", &on_path)] { + let l = layout(FontDb::global(), t); + assert!(l.glyphs[2].advance == 0.0, "{name}: 12 is one block: {:?}", l.glyphs.iter().map(|g| g.advance).collect::>()); + let (one, two) = (ink(&l, 1), ink(&l, 2)); + assert!((one.y1 - two.y1).abs() < 0.5 && two.center().x > one.center().x, "{name}: on one baseline: {one:?} {two:?}"); + } +} + +/// On a vertical path, Tate-chu-yoko Settings Up/Down move a marked block toward the path's start +/// and Left/Right move it to the right of its upright characters (the path's left), with every +/// effect on a path running any way. +#[test] +fn on_a_vertical_path_tate_chu_yoko_settings_keep_their_directions() { + use vectorcraft_doc::PathEffect; + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0), (0.0, 1000.0), (-700.0, -700.0)] { + let at = |up: f64, right: f64| { + let marked = CharStyle { size: 20.0, tate_chu_yoko: true, tcy_up_down: up, tcy_left_right: right, ..CharStyle::default() }; + let mut t = on_vertical_path("", (0.0, 0.0), to, effect); + t.runs = vec![ + TextRun { text: "§".into(), style: CharStyle { size: 20.0, ..CharStyle::default() }, inline: None }, + TextRun { text: "12".into(), style: marked, inline: None }, + ]; + ink(&layout(FontDb::global(), &t), 1).center().to_vec2() + }; + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (plain, up, right) = (at(0.0, 0.0), at(4.0, 0.0), at(0.0, 3.0)); + assert!((up - plain + tangent * 4.0).hypot() < 1e-6, "{effect:?} {to:?}: Up/Down moved it {:?}", up - plain); + assert!((right - plain - left * 3.0).hypot() < 1e-6, "{effect:?} {to:?}: Left/Right moved it {:?}", right - plain); + } + } +} + +/// On a vertical path, a tate-chu-yoko block stands upright like the glyphs around it: its digits +/// read across the column, their tops toward the path's start, centered on the column. +#[test] +fn on_a_vertical_path_tate_chu_yoko_stands_upright() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, PathEffect::Rainbow)); + assert!(l.glyphs[1].outline.elements().len() > 1, "{to:?}: the 1 draws"); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (upright, _) = top_and_ahead(&l, 0); + for i in [1, 2] { + let (top, ahead) = top_and_ahead(&l, i); + assert!((top - upright).hypot() < 1e-6 && (ahead - left).hypot() < 1e-6, "{to:?} digit {i}: {top:?} {ahead:?}"); + } + let centre = |i: usize| ink(&l, i).center().to_vec2(); + let (one, two) = (centre(1), centre(2)); + assert!((two - one).dot(left) > 3.0 && (two - one).dot(tangent).abs() < 1.0, "{to:?}: 12 side by side: {one:?} {two:?}"); + let across = |v: Vec2| v.dot(left); + let block = (across(one) + across(two)) * 0.5; + assert!((block - across(centre(0))).abs() < 2.0, "{to:?}: the block is centered on the column: {block} against {}", across(centre(0))); + let (before, after) = ((centre(0)).dot(tangent), (centre(3)).dot(tangent)); + assert!(before < one.dot(tangent) && one.dot(tangent) < after, "{to:?}: the block stands between the marks"); + } + // On a curve the block stays whole: both digits turn alike and stand on one baseline. + let mut arc = kurbo::BezPath::new(); + arc.move_to((0.0, 0.0)); + arc.curve_to((0.0, 40.0), (20.0, 60.0), (60.0, 60.0)); + let mut t = vertical("§12§"); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None }; + let l = layout(FontDb::global(), &t); + let (a, b) = (l.glyphs[1].xf.as_coeffs(), l.glyphs[2].xf.as_coeffs()); + assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{a:?} against {b:?}"); + let (top, ahead) = top_and_ahead(&l, 1); + let (p1, p2) = (l.glyphs[1].xf * Point::ZERO, l.glyphs[2].xf * Point::ZERO); + assert!((p2 - p1).dot(top).abs() < 1e-6 && (p2 - p1).dot(ahead) > 3.0, "{p1:?} {p2:?}"); + // With every effect, on a path running either way, the block's top faces the path's start. + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, effect)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + for i in [1, 2] { + let (top, _) = top_and_ahead(&l, i); + assert!(top.dot(tangent) < -0.5, "{effect:?} {to:?} digit {i}: {top:?}"); + } + } + } +} + #[test] fn three_digits_stand_across_the_column_squeezed_into_one_em() { let l = layout(FontDb::global(), &vertical("§100§")); @@ -343,6 +641,20 @@ fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() { assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}"); } +/// Vertical type keeps its tracking in thousandths of the em itself at any Horizontal Scale +/// (`spacing_scale`): its advances down the column don't all take the scale. +#[test] +fn vertical_tracking_keeps_the_em_at_any_horizontal_scale() { + let step = |h_scale: f64, tracking: f64| { + let l = layout(FontDb::global(), &vertical_styled("§§", CharStyle { size: 20.0, h_scale, tracking, ..CharStyle::default() })); + ink(&l, 1).center().y - ink(&l, 0).center().y + }; + for h_scale in [100.0, 50.0] { + let more = step(h_scale, 200.0) - step(h_scale, 0.0); + assert!((more - 4.0).abs() < 1e-6, "{h_scale}%: 200/1000 of the 20 pt em: {more}"); + } +} + /// Baseline shift moves an upright glyph across the column, as it moves a letter lying on its /// side, at any Character Rotation, and leaves its neighbors where they are. The glyph turns about /// its shifted pen position (`xf * pivot`), and `xf * unturned` places the shifted glyph without diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index fa22c10fb..5606cb884 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -25,6 +25,8 @@ const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; /// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. const MAX_REVOLVE_FACES: usize = 163_840; +/// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's. +const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES; /// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. const CURVE_SAMPLES: usize = 16; const MIN_CURVE_SAMPLES: usize = 4; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 4d6e78188..45d7d310b 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -11,11 +11,12 @@ use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec2}; -use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale}; +use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_scale}; mod build; mod inflate; mod membrane; +mod outline; mod rings; #[cfg(test)] @@ -23,11 +24,13 @@ use build::{Camera, cuts, edge_distance, newell, section}; pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; #[cfg(test)] +use inflate::GRID_WORK; +#[cfg(test)] pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] -use membrane::{Edges, Membrane, cell_triangles}; +use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; use rings::signed_area; #[cfg(test)] use rings::{MAX_SOLID_POINTS, rings}; @@ -251,6 +254,9 @@ pub struct Facet { pub normal: [f64; 3], /// Smooth lighting on an inflated triangle, before clipping individual RGB channels. pub rgb_gradient: Option, + /// The facet's outline with the profile's curves kept as cubics, for [`Facet::path`]. The + /// contours stay the polygon that visibility and depth use. + pub outline: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, @@ -266,7 +272,11 @@ impl Facet { if perimeter > 0.0 { 2.0 * area / perimeter } else { 0.0 } } + /// The facet's outline: its curved outline when it has one, else its contours. pub fn path(&self) -> PathData { + if let Some(path) = &self.outline { + return path.clone(); + } let mut bp = BezPath::new(); for c in &self.contours { let Some(first) = c.first() else { continue }; @@ -319,3 +329,6 @@ mod tests; #[cfg(test)] mod tests_inflate_shading; + +#[cfg(test)] +mod tests_limits; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 9948c4796..66a22f80f 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,5 +1,14 @@ -use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings}; -use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; +use vectorcraft_geom::kurbo::ParamCurveDeriv; + +use super::outline::{self, Along, Budget, CURVE_TOLERANCE}; +use super::rings::{ + CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, Ring, Span, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, + solid_rings_traced, +}; +use super::{ + Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect, Revolve, V3, View, eye_distance, + perspective_scale, +}; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { @@ -87,8 +96,8 @@ impl Builder<'_> { } if let Some(mesh) = &mut self.mesh { mesh.push((contours, n.unit())); - if mesh.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if mesh.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } return Ok(()); } @@ -132,11 +141,12 @@ impl Builder<'_> { illumination: crate::illumination(&self.camera.view, n, self.camera.light), normal: [n.0, n.1, n.2], rgb_gradient: None, + outline: None, #[cfg(test)] view, }); - if self.facets.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if self.facets.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } Ok(()) } @@ -169,6 +179,19 @@ impl Builder<'_> { /// wall with `cuts` (see [`cuts`]) as `cuts[i]` quads along edge `i`. `outward` is the /// direction (before rotation) the band faces away from the solid, for orienting normals. pub(super) fn band(&mut self, a: &[V3], b: &[V3], cuts: &[usize], outward: impl Fn(usize) -> V3) -> Result<(), String> { + self.curved_band(a, b, cuts, outward, None) + } + + /// [`Builder::band`], with `curves` the pieces of curve the rings' edges follow and the + /// outlines at `a` and `b`: a quad whose edge is not cut keeps its edges along the profile curved. + pub(super) fn curved_band( + &mut self, + a: &[V3], + b: &[V3], + cuts: &[usize], + outward: impl Fn(usize) -> V3, + mut curves: Option, + ) -> Result<(), String> { let n = a.len().min(b.len()); // At 0 and 1, the corners themselves. let lerp = |p: V3, q: V3, s: f64| { @@ -196,12 +219,29 @@ impl Builder<'_> { if normal.dot(self.camera.rotate(outward(i))) < 0.0 { normal = V3(-normal.0, -normal.1, -normal.2); } + let before = self.facets.len(); self.face(vec![quad], normal, order)?; + if let Some(w) = curves.as_mut().filter(|_| pieces == 1) + && let Some(span) = w.spans.get(i).copied().flatten() + { + self.outline(before, |c| c.first().and_then(|quad| outline::wall(&span, quad, w.near, w.far, w.budget))); + } } } Ok(()) } + /// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the + /// outline `outline` makes from its projected contours. + pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec]) -> Option) { + if self.mesh.is_some() || self.facets.len() <= before { + return; + } + if let Some(f) = self.facets.last_mut() { + f.outline = outline(&f.contours); + } + } + /// How many faces the builder holds: facets, or faces kept for a mesh. pub(super) fn len(&self) -> usize { self.mesh.as_ref().map_or(self.facets.len(), Vec::len) @@ -214,6 +254,62 @@ impl Builder<'_> { } } +/// The curves a band's quads keep: the piece of curve each edge of its rings follows, and the +/// outlines at its two rings (`None` keeps that ring's edges straight). +pub(super) struct WallCurves<'a> { + pub(super) spans: &'a [Option], + pub(super) near: Option<&'a Along<'a>>, + pub(super) far: Option<&'a Along<'a>>, + pub(super) budget: &'a mut Budget, +} + +/// Where a point of the art goes in view space at one step: [`Camera::place`] after a [`Turn`], +/// at depth `z`, as one affine map worked out once for the step. +pub(super) struct Placing { + centre: Point, + at_centre: V3, + x: V3, + y: V3, +} + +impl Placing { + pub(super) fn new(camera: &Camera, centre: Point, turn: Turn, z: f64) -> Self { + let at = |p: Point| camera.place(turn.apply(p), z); + let at_centre = at(centre); + let x = at(Point::new(centre.x + 1.0, centre.y)).sub(at_centre); + let y = at(Point::new(centre.x, centre.y + 1.0)).sub(at_centre); + Self { centre, at_centre, x, y } + } + pub(super) fn place(&self, p: Point) -> V3 { + let (dx, dy) = (p.x - self.centre.x, p.y - self.centre.y); + V3( + self.at_centre.0 + self.x.0 * dx + self.y.0 * dy, + self.at_centre.1 + self.x.1 * dx + self.y.1 * dy, + self.at_centre.2 + self.x.2 * dx + self.y.2 * dy, + ) + } +} + +/// The point `w` points to the left of `span`'s curve at `t`, as the ring runs along it (toward +/// the filled side): where [`inset`] moves the ring's points, without its miters. +fn offset(span: &Span, t: f64, w: f64) -> Point { + let p = span.curve.eval(t); + if w == 0.0 { + return p; + } + let mut d = span.curve.deriv().eval(t).to_vec2(); + // A handle of zero length leaves no tangent at its end: the curve leaves toward its next point. + if d.hypot() < 1e-12 { + d = span.curve.p2 - span.curve.p0; + if t > 0.5 { + d = span.curve.p3 - span.curve.p1; + } + } + let d = if span.t1 < span.t0 { -d } else { d }; + let l = d.hypot(); + if l < 1e-12 { p } else { Point::new(p.x - d.y / l * w, p.y + d.x / l * w) } +} + /// How many pieces each edge of a twisted wall is drawn in, from two neighboring slices of the /// wall, `a` and `b` (partner rings): as many as make each piece about as long along the edge as /// the band between the slices is wide there, 1 to [`MAX_PIECES`]. Twisted walls pass in front of @@ -407,15 +503,26 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio if o.depth <= 0.0 { return plane_faces(path, rule, bounds, view, mesh); } - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let steps = steps(&o); + // One face for each edge of the outline in each band between two steps, and the two caps: + // counted before any point is sampled, and again on the rings, which can have more points + // once contours that meet are merged. + let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 }); + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, bands, caps)?; + let traced = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?; + let rings: Vec<&[Point]> = traced.iter().map(|r| r.points.as_slice()).collect(); + let edges = rings.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); + if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } let camera = Camera::new(view, bounds, o.depth); let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; - let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; // Every band of a twisted outline, bevels included, is cut as its walls' first slice is (with - // bevels and no walls, as its shallowest band is), within the facet limit. + // bevels and no walls, as its shallowest band is), within [`MAX_FACES`] facets. let depth_of = |s: &[Step]| s.get(1).zip(s.first()).map_or(0.0, |(b, a)| (b.t - a.t).abs()); let wall = steps .windows(2) @@ -429,7 +536,33 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio }) .collect(); fit(&mut pieces, steps.len().saturating_sub(1), MAX_FACES); - for (ring, pieces) in rings.iter().zip(&pieces) { + // The outline at each step, through the profile's curves: exact where the step is not inset, + // and within the sampling tolerance (which the inset rings themselves keep to) where it is. + let sampled = (size / 2000.0).max(0.05); + let maps: Vec = steps.iter().map(|s| Placing::new(&camera, bounds.center(), Turn::new(bounds, o.twist, o.taper, s.t), z(s.t))).collect(); + let at: Vec<_> = steps + .iter() + .zip(&maps) + .map(|(s, m)| { + let (w, camera) = (s.inset, &camera); + move |span: &Span, t: f64| camera.project(m.place(offset(span, t, w))) + }) + .collect(); + // A step that is not inset moves the art point by point, affinely without perspective. + let point_maps: Vec<_> = maps.iter().map(|m| |p: Point| camera.project(m.place(p))).collect(); + let along: Vec = steps + .iter() + .zip(&at) + .zip(&point_maps) + .map(|((s, at), map)| Along { + at, + map: (s.inset == 0.0).then_some(map as &dyn Fn(Point) -> Point), + affine: camera.eye.is_none(), + tolerance: if s.inset == 0.0 { CURVE_TOLERANCE } else { sampled }, + }) + .collect(); + let mut budget = Budget::default(); + for ((ring, traced), pieces) in rings.iter().zip(&traced).zip(&pieces) { let placed: Vec> = steps.iter().map(|s| ring_at(ring, s)).collect(); for (k, pair) in placed.windows(2).enumerate() { let ([a, c], Some(s0), Some(s1)) = (pair, steps.get(k), steps.get(k + 1)) else { continue }; @@ -442,15 +575,25 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio } else { 0.0 }; - b.band(a, c, pieces, |i| outward(ring, i, face))?; + // The edges a band shares with a cap follow the cap's curves; the edges between two bands + // stay straight, within the sampling tolerance. + let (near, far) = (along.get(k).filter(|_| k == 0), along.get(k + 1).filter(|_| k + 2 == steps.len())); + let curves = (near.is_some() || far.is_some()).then(|| WallCurves { spans: &traced.spans, near, far, budget: &mut budget }); + b.curved_band(a, c, pieces, |i| outward(ring, i, face), curves)?; } } if o.cap { // A cap turned toward the viewer covers the walls it overlaps, even those nearer than its // center: twisted walls, and the walls of a fill inside the solid of its stroke. - for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] { + let caps = [(steps.first(), along.first(), 1.0), (steps.last(), along.last(), -1.0)]; + for (s, along, n) in caps { let Some(s) = s else { continue }; + let before = b.len(); b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?; + if let Some(along) = along { + let traced: Vec<&Ring> = traced.iter().collect(); + b.outline(before, |c| outline::face(&traced, c, along, &mut budget)); + } } } Ok(b.finish()) @@ -465,15 +608,21 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul /// [`plane`], or with `mesh` its face before projection instead of its facet. pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; - let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); + let contours: Vec> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. let up = camera.rotate(V3(0.0, 0.0, 1.0)); let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up }; b.face(contours, n, Order::Last)?; + let m = Placing::new(&camera, bounds.center(), Turn::new(bounds, 0.0, 1.0, 0.0), 0.0); + let at = |span: &Span, t: f64| camera.project(m.place(span.curve.eval(t))); + let map = |p: Point| camera.project(m.place(p)); + let along = Along { at: &at, map: Some(&map), affine: camera.eye.is_none(), tolerance: CURVE_TOLERANCE }; + let rings: Vec<&Ring> = rings.iter().collect(); + b.outline(0, |c| outline::face(&rings, c, &along, &mut Budget::default())); Ok(b.finish()) } diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 51f9af911..20242697f 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,7 +1,12 @@ use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; -use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; -use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings}; -use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; +use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; +use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; +use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; + +/// The slices the sides are cut into: more to follow a twist or taper. +fn slices(o: &Inflate) -> usize { + if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 } +} /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. struct Puff { @@ -18,11 +23,14 @@ struct Puff { /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + // The sides take one face for each edge of the outline in each slice; the grid's faces are + // counted as they are built. + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); } - let size = bounds.width().max(bounds.height()); if size <= 0.0 { return Ok(None); } @@ -35,8 +43,11 @@ fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result = const { std::cell::Cell::new(0) }; + /// The grid work of the last solid this thread inflated in full, for the tests. + pub(super) static GRID_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the @@ -143,14 +156,41 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo // builds the topology of every outline ring near its cell, so a self-crossing outline cost its // crossings again in every cell it spans. Past it, cells take their clipped pieces. let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS); - // The shape's part of a rectangle, as rings. + // Every ring's bounds, and the crossings of the last few rows the cells' centres lie on (a + // row of cells has at most seven, at its three levels). + let ring_bounds: Vec> = + rings.iter().map(|r| Some(r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)))).collect(); + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let rows: std::cell::RefCell)>> = std::cell::RefCell::new(vec![]); + let filled_at = |p: Point| -> bool { + let mut rows = rows.borrow_mut(); + if let Some((_, xs)) = rows.iter().find(|(y, _)| *y == p.y.to_bits()) { + return filled(xs, p.x); + } + let xs = if edges.charge(points) { crossings(&rings, p.y) } else { vec![] }; + let inside = filled(&xs, p.x); + if rows.len() >= 8 { + rows.remove(0); + } + rows.push((p.y.to_bits(), xs)); + inside + }; + // The shape's part of a rectangle, as rings: only the rings whose bounds meet it are clipped, + // each point counted as work. let cut = |r: Rect| -> Vec> { // A cell no edge crosses is wholly inside the shape or wholly outside it. if !edges.touch(r) { let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; - return if filled(&rings, r.center()) { vec![corners] } else { vec![] }; + return if filled_at(r.center()) { vec![corners] } else { vec![] }; } - let pieces: Vec> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect(); + let pieces: Vec> = rings + .iter() + .zip(&ring_bounds) + .filter(|(_, b)| b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)) + .filter(|(ring, _)| edges.charge(ring.len())) + .map(|(ring, _)| clip(ring, r)) + .filter(|c| !c.is_empty()) + .collect(); if pieces.iter().map(|p| signed_area(p)).sum::() <= 1e-9 * r.area() { return vec![]; } @@ -185,6 +225,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } }) .collect(); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } let level = |i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } }; for j in 0..ny { @@ -201,6 +244,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo for sx in (bx0..bx1).step_by(step) { let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step)); let pieces = cut(r); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } if pieces.is_empty() { continue; } @@ -312,7 +358,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } if o.depth > 0.0 { // The sides, cut into slices that follow a twist or taper. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + let slices = slices(&o); let at = |ring: &[Point], k: usize| -> Vec { let t = k as f64 / slices as f64; let turn = Turn::new(bounds, o.twist, o.taper, t); @@ -320,11 +366,20 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo }; let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); + let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } for (ring, pieces) in rings.iter().zip(&pieces) { for k in 0..slices { b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; } } } + #[cfg(test)] + GRID_WORK.with(|w| w.set(edges.work())); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } Ok(b.finish()) } diff --git a/crates/three-d/src/solid/membrane.rs b/crates/three-d/src/solid/membrane.rs index 02d258531..e8a855e29 100644 --- a/crates/three-d/src/solid/membrane.rs +++ b/crates/three-d/src/solid/membrane.rs @@ -1,9 +1,35 @@ +use std::cell::Cell; + use super::build::segment_distance; -use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area}; +use super::rings::{MAX_PLANE_POINTS, TOO_COMPLEX_SURFACE, left, rings, signed_area}; use super::{FillRule, PathData, Point, Rect, Vec2}; +/// Most entries the edge index of one inflated shape holds: one for each bucket an edge's bounds +/// cover (64 MiB). +pub(super) const MAX_EDGE_ENTRIES: usize = 1 << 24; + +/// Most work the grid of one inflated shape takes: the index's entries, the edges its queries +/// look at, the crossings of the rows it fills and the ring points its cells clip, together. +pub(super) const MAX_GRID_WORK: usize = 1 << 28; + +#[cfg(test)] +thread_local! { + /// The grid work the next edge indexes of this thread may take, for the tests. + pub(super) static GRID_BUDGET: Cell = const { Cell::new(MAX_GRID_WORK) }; +} + +/// The grid work a new edge index may take. +fn grid_budget() -> usize { + #[cfg(test)] + return GRID_BUDGET.with(Cell::get); + #[cfg(not(test))] + MAX_GRID_WORK +} + /// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is -/// found among the buckets around it rather than among all the edges. +/// found among the buckets around it rather than among all the edges. It also counts the work +/// done with it (see [`MAX_GRID_WORK`]): once that is spent, its buckets read as empty, and the +/// caller refuses the shape. pub(super) struct Edges { edges: Vec<(Point, Point)>, normals: Vec<(Vec2, Vec2)>, @@ -11,10 +37,14 @@ pub(super) struct Edges { size: f64, n: usize, buckets: Vec>, + work: Cell, + budget: usize, } impl Edges { - pub(super) fn new(rings: &[Vec]) -> Self { + /// The index of `rings`' edges; an error when it would hold more than [`MAX_EDGE_ENTRIES`] + /// entries, counted before any is filed. + pub(super) fn new(rings: &[Vec]) -> Result { const N: usize = 64; let edges: Vec<(Point, Point)> = rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect(); @@ -37,16 +67,48 @@ impl Edges { let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9); let mut buckets = vec![vec![]; N * N]; let index = |v: f64, o: f64| (((v - o) / size).floor().max(0.0) as usize).min(N - 1); - for (k, (a, b)) in edges.iter().enumerate() { - let (i0, i1) = (index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)); - let (j0, j1) = (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0)); + let spans = |(a, b): &(Point, Point)| { + ((index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)), (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0))) + }; + let entries = edges.iter().map(spans).fold(0usize, |sum, ((i0, i1), (j0, j1))| sum.saturating_add((i1 - i0 + 1) * (j1 - j0 + 1))); + if entries > MAX_EDGE_ENTRIES { + return Err(TOO_COMPLEX_SURFACE.into()); + } + for (k, e) in edges.iter().enumerate() { + let ((i0, i1), (j0, j1)) = spans(e); for j in j0..=j1 { for i in i0..=i1 { buckets[j * N + i].push(k as u32); } } } - Self { edges, normals, origin: bounds.origin(), size, n: N, buckets } + Ok(Self { edges, normals, origin: bounds.origin(), size, n: N, buckets, work: Cell::new(entries), budget: grid_budget() }) + } + + /// Has the work done with the index gone past its budget? + pub(super) fn spent(&self) -> bool { + self.work.get() > self.budget + } + + /// The work done with the index so far. + #[cfg(test)] + pub(super) fn work(&self) -> usize { + self.work.get() + } + + /// Count `amount` more work; `false` once the budget is spent, when the work is not to be done. + pub(super) fn charge(&self, amount: usize) -> bool { + if self.spent() { + return false; + } + self.work.set(self.work.get().saturating_add(amount)); + !self.spent() + } + + /// The edges filed in bucket (`i`, `j`), counted as work; none once the budget is spent. + fn bucket(&self, i: isize, j: isize) -> &[u32] { + let bucket = self.buckets.get((j * self.n as isize + i) as usize).map_or(&[][..], Vec::as_slice); + if self.charge(bucket.len()) { bucket } else { &[] } } /// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis. @@ -58,15 +120,11 @@ impl Edges { /// looked at. pub(super) fn near(&self, p: Point, tolerance: f64) -> bool { self.touch(Rect::new(p.x - tolerance, p.y - tolerance, p.x + tolerance, p.y + tolerance)) && { - let n = self.n as isize; let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x)); let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] - .iter() - .filter_map(|k| self.edges.get(*k as usize)) - .any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) + self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)).any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) }) }) } @@ -75,10 +133,9 @@ impl Edges { /// Call `f` with each edge whose bounding box overlaps `r` (an edge in several of the /// buckets `r` covers comes more than once). fn each(&self, r: Rect, mut f: impl FnMut(Point, Point)) { - let n = self.n as isize; for j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) { for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) { - for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) { + for (a, b) in self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)) { if a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 { f(*a, *b); } @@ -89,12 +146,11 @@ impl Edges { /// Does any edge pass through `r` (its bounding box, at least, overlapping it)? pub(super) fn touch(&self, r: Rect) -> bool { - let n = self.n as isize; let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x)); let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] + self.bucket(i, j) .iter() .filter_map(|k| self.edges.get(*k as usize)) .any(|(a, b)| a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0) @@ -122,12 +178,15 @@ impl Edges { let mut best = f64::INFINITY; let mut nearest = (p, Vec2::ZERO); for r in 0..n { + if self.spent() { + break; + } for j in (cj - r)..=(cj + r) { for i in (ci - r)..=(ci + r) { if (j - cj).abs() != r && (i - ci).abs() != r || i < 0 || j < 0 || i >= n || j >= n { continue; } - for k in &self.buckets[(j * n + i) as usize] { + for k in self.bucket(i, j) { if let Some((a, b)) = self.edges.get(*k as usize) { let d = *b - *a; let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) }; @@ -152,9 +211,24 @@ impl Edges { } } -/// Is `p` filled by `rings` (nonzero winding, as they are oriented)? -pub(super) fn filled(rings: &[Vec], p: Point) -> bool { - rings.iter().filter(|r| contains(r, p)).count() % 2 == 1 +/// Where the edges of `rings` cross the line through `y`, in order: the points of the line +/// filled by `rings` (nonzero winding, as they are oriented) are those with an odd number of +/// crossings to their right ([`filled`]). An edge crosses where one end is above the line and +/// the other is not, as [`super::rings::contains`] counts it. +pub(super) fn crossings(rings: &[Vec], y: f64) -> Vec { + let mut xs: Vec = rings + .iter() + .flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len())) + .filter(|(a, b)| (a.y > y) != (b.y > y)) + .map(|(a, b)| a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)) + .collect(); + xs.sort_by(f64::total_cmp); + xs +} + +/// Is `x` filled on the line whose [`crossings`] are `xs`? +pub(super) fn filled(xs: &[f64], x: f64) -> bool { + (xs.len() - xs.partition_point(|v| *v <= x)) % 2 == 1 } /// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring @@ -203,13 +277,17 @@ impl Membrane { pub(super) fn solve(rings: &[Vec], edges: &Edges, origin: Point, step: f64, nx: usize, ny: usize) -> Self { let node = |i: usize, j: usize| Point::new(origin.x + i as f64 * step, origin.y + j as f64 * step); let idx = |i: usize, j: usize| j * nx + i; - // A node on the outline itself is held at zero with the outside. - let inside: Vec = (0..nx * ny) - .map(|k| { - let p = node(k % nx, k / nx); - filled(rings, p) && !edges.near(p, 1e-6 * step) - }) - .collect(); + // A node on the outline itself is held at zero with the outside. Each row of nodes is + // tested against the crossings of its line. + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let mut inside = Vec::with_capacity(nx * ny); + for j in 0..ny { + let xs = if edges.charge(points) { crossings(rings, node(0, j).y) } else { vec![] }; + inside.extend((0..nx).map(|i| { + let p = node(i, j); + filled(&xs, p.x) && !edges.near(p, 1e-6 * step) + })); + } // The nearest crossing of the outline from `p` along (dx, dy), within one step. let crossing = |p: Point, dx: f64, dy: f64| -> f64 { let mut best = step; diff --git a/crates/three-d/src/solid/outline.rs b/crates/three-d/src/solid/outline.rs new file mode 100644 index 000000000..07fb0ce58 --- /dev/null +++ b/crates/three-d/src/solid/outline.rs @@ -0,0 +1,196 @@ +//! Curved outlines for the facets of a solid: a cap, the flat plane of Rotate and the edges of a +//! wall along the profile follow the profile's curves as cubics, while each facet's polygon stays +//! the shape that visibility and depth are worked out on. + +use vectorcraft_geom::{BezPath, CubicBez, ParamCurve, PathData, Point}; + +use super::rings::{Ring, Span}; + +/// How far a fitted cubic may lie from the outline it follows, in points. +pub(super) const CURVE_TOLERANCE: f64 = 0.001; + +/// How many times one piece of curve is halved at most to fit it. A piece that still misses the +/// tolerance keeps its sampled straight edges. +const MAX_DEPTH: u32 = 8; + +/// Fitted cubics one solid's outlines hold at most. Past it, the remaining edges stay straight. +pub(super) const MAX_OUTLINE_CURVES: usize = 1 << 20; + +/// The fitted cubics of one solid: how many it holds so far, against [`MAX_OUTLINE_CURVES`]. +#[derive(Default)] +pub(super) struct Budget(usize); + +/// The outline of a solid at one step, as a point of the document for a point of a profile's +/// curve: `at(span, t)` is where the outline lies at `t` on `span`'s curve. `tolerance` is how far +/// a fitted cubic may lie from it. With `map`, the outline is the curve moved point by point by +/// that map, and a piece of curve is tried first as its control points moved by it: exact when +/// `affine`, otherwise kept when it lies within the tolerance. +pub(super) struct Along<'a> { + pub(super) at: &'a dyn Fn(&Span, f64) -> Point, + pub(super) map: Option<&'a dyn Fn(Point) -> Point>, + pub(super) affine: bool, + pub(super) tolerance: f64, +} + +/// `g` from `p` at `a` to `q` at `b` as cubics within `tolerance`, appended to `out`. False when it +/// does not fit within [`MAX_DEPTH`] halvings, or reaches a point that is not finite. +#[allow(clippy::too_many_arguments)] +fn fit(g: &dyn Fn(f64) -> Point, p: Point, q: Point, a: f64, b: f64, tolerance: f64, depth: u32, out: &mut Vec) -> bool { + // Second-order one-sided differences, taken inside the piece: a handle of zero length at an end + // of the source curve turns its normal, and so an inset outline's tangent, at that end. + let h = (b - a) * 1e-5; + if h == 0.0 || !h.is_finite() { + return false; + } + let (g0, g1, g2) = (g(a), g(a + h), g(a + 2.0 * h)); + let start = (g1 - g0) * 2.0 - (g2 - g0) * 0.5; + let (g0, g1, g2) = (g(b), g(b - h), g(b - 2.0 * h)); + let end = (g0 - g1) * 2.0 - (g0 - g2) * 0.5; + let (start, end) = (start / h, end / h); + let curve = CubicBez::new(p, p + start * ((b - a) / 3.0), q - end * ((b - a) / 3.0), q); + let points = [curve.p1, curve.p2]; + if points.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) { + return false; + } + let mut error = 0.0f64; + for u in [0.25, 0.5, 0.75] { + let want = g(a + (b - a) * u); + if !want.x.is_finite() || !want.y.is_finite() { + return false; + } + error = error.max(curve.eval(u).distance(want)); + } + if error <= tolerance { + out.push(curve); + return true; + } + if depth >= MAX_DEPTH { + return false; + } + let mid = (a + b) / 2.0; + let m = g(mid); + if !m.x.is_finite() || !m.y.is_finite() { + return false; + } + fit(g, p, m, a, mid, tolerance, depth + 1, out) && fit(g, m, q, mid, b, tolerance, depth + 1, out) +} + +/// The cubics from `p` to `q` along the piece of `span`'s curve from `t0` to `t1` at `step`, or +/// `None` when they do not fit, or would take the budget past [`MAX_OUTLINE_CURVES`]. +/// +/// `p` and `q` are the face's own corners, which a bevel's miter can move off the curve's offset +/// where the curve meets another edge at an angle. The difference is eased out from `p` by `fade.0` +/// and into `q` from `fade.1` (the ends of the first and last sampled edge), as the face's polygon +/// does over those edges, so the curves meet the face's corners. +#[allow(clippy::too_many_arguments)] +fn piece(step: &Along, span: &Span, t0: f64, t1: f64, fade: (f64, f64), p: Point, q: Point, budget: &mut Budget) -> Option> { + if let Some(map) = step.map { + let c = span.curve.subsegment(t0..t1); + let c = CubicBez::new(p, map(c.p1), map(c.p2), q); + let finite = [c.p1, c.p2].iter().all(|q| q.x.is_finite() && q.y.is_finite()); + let fits = || [0.25, 0.5, 0.75].into_iter().all(|u| c.eval(u).distance((step.at)(span, t0 + (t1 - t0) * u)) <= step.tolerance); + if finite && (step.affine || fits()) { + budget.0 = budget.0.saturating_add(1); + return (budget.0 <= MAX_OUTLINE_CURVES).then(|| vec![c]); + } + } + let (dp, dq) = (p - (step.at)(span, t0), q - (step.at)(span, t1)); + // 1 at the end, 0 from `to` on, with no slope at either. + let ease = |t: f64, from: f64, to: f64| { + let x = if to == from { 1.0 } else { ((t - from) / (to - from)).clamp(0.0, 1.0) }; + 1.0 - x * x * (3.0 - 2.0 * x) + }; + let g = |t: f64| (step.at)(span, t) + dp * ease(t, t0, fade.0) + dq * ease(t, t1, fade.1); + let mut out = vec![]; + if !fit(&g, p, q, t0, t1, step.tolerance, 0, &mut out) { + return None; + } + budget.0 = budget.0.saturating_add(out.len()); + (budget.0 <= MAX_OUTLINE_CURVES).then_some(out) +} + +/// Append contour `points` (closed), whose edges follow `ring`'s, to `path`: each run of edges along +/// one piece of curve as cubics, and the rest straight. False when no edge is curved. +fn contour(path: &mut BezPath, ring: &Ring, points: &[Point], step: &Along, budget: &mut Budget) -> bool { + let n = points.len(); + let Some(&first) = points.first() else { return false }; + if ring.spans.len() != n { + return false; + } + path.move_to(first); + let mut curved = false; + let mut i = 0; + while i < n { + let end = |k: usize| points.get((k + 1) % n).copied().unwrap_or(first); + let Some(span) = ring.spans.get(i).copied().flatten() else { + if i + 1 < n { + path.line_to(end(i)); + } + i += 1; + continue; + }; + // The run of edges that follow this curve one after the other. An inset outline is fitted + // one edge at a time, as `wall` fits it, so a cap and its walls share the same curves. + let (mut j, mut t1, mut last) = (i, span.t1, span.t0); + while step.map.is_some() + && let Some(Some(next)) = ring.spans.get(j + 1) + && next.curve == span.curve + && next.t0 == t1 + { + (j, t1, last) = (j + 1, next.t1, next.t0); + } + let (p, q) = (points.get(i).copied().unwrap_or(first), end(j)); + match piece(step, &span, span.t0, t1, (span.t1, last), p, q, budget) { + Some(curves) => { + curved = true; + for c in curves { + path.curve_to(c.p1, c.p2, c.p3); + } + } + // The edge back to the first point is drawn by `close_path`. + None => (i..=j).filter(|k| k + 1 < n).for_each(|k| path.line_to(end(k))), + } + i = j + 1; + } + path.close_path(); + curved +} + +/// The outline of a face made of `rings` at one `step` (a cap, or Rotate's plane), projected as +/// `points` (the face's contours, one for each ring and as long). `None` when no edge is curved. +pub(super) fn face(rings: &[&Ring], points: &[Vec], step: &Along, budget: &mut Budget) -> Option { + if rings.len() != points.len() || rings.iter().all(|r| r.spans.iter().all(Option::is_none)) { + return None; + } + let mut path = BezPath::new(); + let mut curved = false; + for (ring, points) in rings.iter().zip(points) { + curved |= contour(&mut path, ring, points, step, budget); + } + curved.then(|| PathData::from_bezpath(&path)) +} + +/// The outline of a wall's quad `quad` (`a`, `a'`, `b'`, `b` projected: the edge at `near` from +/// `a` to `a'`, then the edge at `far` back from `b'` to `b`), whose edges along the profile follow +/// `span`. An edge without its outline stays straight. `None` when neither edge is curved. +pub(super) fn wall(span: &Span, quad: &[Point], near: Option<&Along>, far: Option<&Along>, budget: &mut Budget) -> Option { + let [a, a2, b2, b] = <[Point; 4]>::try_from(quad).ok()?; + let front = near.and_then(|near| piece(near, span, span.t0, span.t1, (span.t1, span.t0), a, a2, budget)); + let back = far.and_then(|far| piece(far, span, span.t1, span.t0, (span.t0, span.t1), b2, b, budget)); + if front.is_none() && back.is_none() { + return None; + } + let mut path = BezPath::new(); + path.move_to(a); + match front { + Some(curves) => curves.iter().for_each(|c| path.curve_to(c.p1, c.p2, c.p3)), + None => path.line_to(a2), + } + path.line_to(b2); + match back { + Some(curves) => curves.iter().for_each(|c| path.curve_to(c.p1, c.p2, c.p3)), + None => path.line_to(b), + } + path.close_path(); + Some(PathData::from_bezpath(&path)) +} diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 81e67cc03..7aed5e419 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -1,8 +1,7 @@ -use super::{FillRule, ParamCurve, PathData, Point, Rect}; +use super::{FillRule, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect}; -/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel -/// bands and distance tests. -pub(super) const MAX_SOLID_POINTS: usize = 2048; +/// Outline points Extrude & Bevel and Inflate sample at most, and contours they take at most. +pub(super) const MAX_SOLID_POINTS: usize = 65536; /// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits. pub(super) const MAX_PLANE_POINTS: usize = 65536; @@ -10,13 +9,76 @@ pub(super) const MAX_PLANE_POINTS: usize = 65536; /// Pieces a twisted wall's strip is cut into along one edge at most. pub(super) const MAX_PIECES: usize = 64; +/// Pieces a curve takes at most, where its tolerance asks for them. +pub(super) const CURVE_PIECES: u32 = 32; + +/// The fewest pieces a curve of a solid over its budget is held to: a curve whose tolerance asks +/// for fewer takes fewer. +pub(super) const MIN_CURVE_PIECES: u32 = 4; + +const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; +pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + /// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from -/// its chords), 1 to 32. +/// its chords), 1 to [`CURVE_PIECES`]. fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { let d = |a: Point, b: Point, c: Point| (a.to_vec2() - 2.0 * b.to_vec2() + c.to_vec2()).hypot(); let bend = d(c.p0, c.p1, c.p2).max(d(c.p1, c.p2, c.p3)); let n = (0.75 * bend / tolerance).sqrt().ceil(); - if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } + if n.is_finite() { (n as u32).clamp(1, CURVE_PIECES) } else { CURVE_PIECES } +} + +/// The most pieces each curve of a solid's outline takes, counted on `path` before any point is +/// sampled: [`CURVE_PIECES`] when the outline fits within [`MAX_SOLID_POINTS`] points and +/// [`MAX_SOLID_FACES`] faces, else the largest number down to [`MIN_CURVE_PIECES`] that fits, the +/// same for every curve. A curve whose tolerance (see [`sample`]) asks for fewer pieces takes +/// fewer. The faces are counted as `per_edge` for each edge of the outline and `fixed` more. +/// +/// An error when the outline takes more than [`MAX_SOLID_POINTS`] points, or contours, with +/// every curve held to [`MIN_CURVE_PIECES`], and when its faces do not fit with that many. +pub(super) fn curve_pieces(path: &PathData, size: f64, per_edge: usize, fixed: usize) -> Result { + let tolerance = (size / 2000.0).max(0.05); + // How many curves ask for each number of pieces. + let mut curves = [0usize; CURVE_PIECES as usize + 1]; + let (mut lines, mut segments, mut open) = (0usize, 0usize, 0usize); + for (contours, sp) in path.subpaths.iter().enumerate() { + if contours >= MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // A subpath of one anchor draws nothing, as in [`sample`]. + if sp.anchors.len() < 2 { + continue; + } + if sp.anchors.iter().any(|a| [a.p, a.h_in, a.h_out].iter().any(|q| !q.x.is_finite() || !q.y.is_finite())) { + return Err("The shape contains invalid coordinates".into()); + } + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; + // Every segment takes a point at least. + segments = segments.saturating_add(count); + if segments > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + open += usize::from(!sp.closed); + for i in 0..count { + if sp.segment_is_line(i) { + lines += 1; + } else if let Some(n) = curves.get_mut(pieces(sp.segment(i), tolerance) as usize) { + *n += 1; + } + } + } + let points = |most: u32| -> usize { + curves.iter().enumerate().fold(lines, |sum, (asked, n)| sum.saturating_add(n.saturating_mul(asked.min(most as usize)))) + }; + if points(MIN_CURVE_PIECES) > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // An open contour is closed by one more edge. + let faces = |most: u32| points(most).saturating_add(open).saturating_mul(per_edge).saturating_add(fixed); + (MIN_CURVE_PIECES..=CURVE_PIECES) + .rev() + .find(|most| points(*most) <= MAX_SOLID_POINTS && faces(*most) <= MAX_SOLID_FACES) + .ok_or_else(|| TOO_COMPLEX_SURFACE.into()) } /// Contours thinner than this on average (twice their area over their perimeter) are dropped when @@ -37,16 +99,9 @@ pub(super) const DEBRIS: f64 = 1e-6; /// are. A profile the boolean refuses is built from its contours as drawn, as it was before /// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice /// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built. -pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { - let sampled = sample(path, size, limit)?; - let closed = closed_straight(path); - if !meets(&sampled, &closed) { - return orient(sampled, rule); - } - match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { - Ok(merged) => sample(&merged, size, limit).map(wound), - Err(_) => orient(sampled, rule), - } +/// Each curve takes at most `most` pieces, on the profile as drawn and on the merged outline. +pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result>, String> { + Ok(solid_rings_traced(path, rule, size, limit, most)?.into_iter().map(|r| r.points).collect()) } /// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must @@ -92,10 +147,10 @@ pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> /// The boolean's rings turned so the filled side is on each one's left. Its holes already run /// against their outlines, so only the direction of the whole is chosen: the one whose rings /// enclose positive area in all. -pub(super) fn wound(mut rings: Vec>) -> Vec> { - rings.retain(|r| signed_area(r).abs() > 1e-9); - if rings.iter().map(|r| signed_area(r)).sum::() < 0.0 { - rings.iter_mut().for_each(|r| r.reverse()); +pub(super) fn wound(mut rings: Vec) -> Vec { + rings.retain(|r| signed_area(r.points()).abs() > 1e-9); + if rings.iter().map(|r| signed_area(r.points())).sum::() < 0.0 { + rings.iter_mut().for_each(Turnable::turn_around); } rings } @@ -188,6 +243,71 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= touch } +/// Where one edge of a sampled ring lies on the curve it follows: the piece of `curve` from `t0` +/// to `t1` (`t1` below `t0` on a ring that runs against its curve). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Span { + pub(super) curve: vectorcraft_geom::CubicBez, + pub(super) t0: f64, + pub(super) t1: f64, +} + +/// A sampled ring with, for each edge (point `i` to point `i + 1`, the last back to the first), +/// the piece of curve it follows, or `None` for a straight edge. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Ring { + pub(super) points: Vec, + pub(super) spans: Vec>, +} + +/// A ring that [`orient`] and [`wound`] can turn around. +pub(super) trait Turnable { + fn points(&self) -> &[Point]; + fn turn_around(&mut self); +} + +impl Turnable for Vec { + fn points(&self) -> &[Point] { + self + } + fn turn_around(&mut self) { + self.reverse(); + } +} + +impl Turnable for Ring { + fn points(&self) -> &[Point] { + &self.points + } + fn turn_around(&mut self) { + // The ring keeps its first point, an anchor of the art, so that a run of edges along one + // curve doesn't start part way along it. Edge `j` of the turned ring is edge `n - 1 - j` + // of the ring, run the other way. + self.points.reverse(); + if !self.points.is_empty() { + self.points.rotate_right(1); + } + self.spans.reverse(); + for s in self.spans.iter_mut().flatten() { + std::mem::swap(&mut s.t0, &mut s.t1); + } + } +} + +/// [`solid_rings`] with the piece of curve each edge follows. +pub(super) fn solid_rings_traced(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result, String> { + let sampled = sample_traced(path, size, limit, most)?; + let closed = closed_straight(path); + let points: Vec> = sampled.iter().map(|r| r.points.clone()).collect(); + if !meets(&points, &closed) { + return orient(sampled, rule); + } + match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { + Ok(merged) => sample_traced(&merged, size, limit, most).map(wound), + Err(_) => orient(sampled, rule), + } +} + /// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer /// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves /// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take @@ -197,12 +317,17 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { /// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would /// if nesting alone decided (#1724). pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { - orient(sample(path, size, limit)?, rule) + orient(sample(path, size, limit, CURVE_PIECES)?, rule) } /// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as -/// its subpath does. -pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result>, String> { +/// its subpath does, each curve in at most `most` pieces. +pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result>, String> { + Ok(sample_traced(path, size, limit, most)?.into_iter().map(|r| r.points).collect()) +} + +/// [`sample`] with the piece of curve each edge follows. +fn sample_traced(path: &PathData, size: f64, limit: usize, most: u32) -> Result, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -210,36 +335,46 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result= 2) { let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; if segments > limit { - return Err("The shape is too complex for a 3D effect".into()); + return Err(TOO_COMPLEX_SHAPE.into()); } - let mut ring: Vec = vec![]; + // Each point with the piece of curve of the edge that ends at it. + let mut ring: Vec<(Point, Option)> = vec![]; if let Some(first) = sp.anchors.first() { - ring.push(first.p); + ring.push((first.p, None)); } for i in 0..segments { let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance) }; + let line = sp.segment_is_line(i); + let samples = if line { 1 } else { pieces(curve, tolerance).min(most.max(1)) }; count += samples as usize; if count > limit { - return Err("The shape is too complex for a 3D effect".into()); + return Err(TOO_COMPLEX_SHAPE.into()); } for k in 1..=samples { - ring.push(curve.eval(f64::from(k) / f64::from(samples))); + let (t0, t1) = (f64::from(k - 1) / f64::from(samples), f64::from(k) / f64::from(samples)); + ring.push((curve.eval(t1), (!line).then_some(Span { curve, t0, t1 }))); } } - if ring.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { + if ring.iter().any(|(p, _)| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { return Err("The shape contains invalid coordinates".into()); } - ring.dedup_by(|a, b| a.distance(*b) < 1e-9); - while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { - ring.pop(); + ring.dedup_by(|a, b| a.0.distance(b.0) < 1e-9); + // The last point back on the first closes the ring along the edge that ends at it. + while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.0.distance(b.0) < 1e-9) { + let closing = ring.pop().and_then(|(_, s)| s); + if let Some(first) = ring.first_mut() { + first.1 = closing; + } } + let points: Vec = ring.iter().map(|(p, _)| *p).collect(); // Rings with no area are kept here: a contour that crosses itself into lobes that cancel // (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat // ring, every point on one line, fills nothing under either rule and is left out now, so // that it doesn't send the profile through the boolean. - if ring.len() >= 3 && !flat(&ring) { - out.push(ring); + if points.len() >= 3 && !flat(&points) { + let n = points.len(); + let spans = (0..n).map(|i| ring.get((i + 1) % n).and_then(|(_, s)| *s)).collect(); + out.push(Ring { points, spans }); } } Ok(out) @@ -247,15 +382,15 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result>, rule: FillRule) -> Result>, String> { - out.retain(|r| signed_area(r).abs() > 1e-9); +fn orient(mut out: Vec, rule: FillRule) -> Result, String> { + out.retain(|r| signed_area(r.points()).abs() > 1e-9); // Which rings hold which: each ring against the others, its edges walked only where its // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair // whose bounds miss the point costs a step too: many small rings are a square of pairs). - let too_complex = || String::from("The shape is too complex for a 3D effect"); + let too_complex = || String::from(TOO_COMPLEX_SHAPE); let mut work = 0usize; - let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { - let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); + let mut within = |out: &[R], bounds: &[Rect], i: usize| -> Result, String> { + let p = out.get(i).and_then(|r| r.points().first()).copied().unwrap_or_default(); let mut held_by = vec![]; for (j, (o, b)) in out.iter().zip(bounds).enumerate() { work = work.saturating_add(1); @@ -265,18 +400,18 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S } continue; } - work = work.saturating_add(o.len()); + work = work.saturating_add(o.points().len()); if work > MAX_CONTAINMENT_EDGES { return Err(too_complex()); } - if contains(o, p) { + if contains(o.points(), p) { held_by.push(j); } } Ok(held_by) }; - let bounds_of = |out: &[Vec]| -> Vec { - out.iter().map(|r| r.iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() + let bounds_of = |out: &[R]| -> Vec { + out.iter().map(|r| r.points().iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() }; if rule == FillRule::NonZero { let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; @@ -284,8 +419,8 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S let mut edge = Vec::with_capacity(out.len()); for (i, r) in out.iter().enumerate() { // The winding number just outside the ring, and just inside it. - let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o)).sum(); - edge.push((outside != 0) != (outside + turn(r) != 0)); + let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o.points())).sum(); + edge.push((outside != 0) != (outside + turn(r.points()) != 0)); } let mut edge = edge.into_iter(); out.retain(|_| edge.next().unwrap_or(true)); @@ -294,8 +429,8 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S let bounds = bounds_of(&out); let holes: Vec = (0..out.len()).map(|i| within(&out, &bounds, i).map(|held| held.len() % 2 == 1)).collect::>()?; for (r, hole) in out.iter_mut().zip(holes) { - if (signed_area(r) > 0.0) == hole { - r.reverse(); + if (signed_area(r.points()) > 0.0) == hole { + r.turn_around(); } } Ok(out) diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 46d178287..af9322bc1 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -277,11 +277,14 @@ fn malformed_and_degenerate_input_is_bounded() { // Too many anchors: refused, not truncated. let mut many = BezPath::new(); many.move_to((0.0, 0.0)); - for i in 0..3000 { + for i in 0..70_000 { many.line_to((f64::from(i), f64::from(i % 7))); } many.close_path(); - assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err()); + assert_eq!( + extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap_err(), + "The shape is too complex for a 3D effect" + ); } #[test] @@ -313,6 +316,195 @@ fn a_wider_perspective_brings_the_eye_nearer() { assert!(close(last, 100.0 * eye / (eye - 50.0), 1e-6), "{last}"); } +/// A circle of four cubics about `c`. +fn circle(c: Point, r: f64) -> BezPath { + let k = 0.552_284_749_830_793_6 * r; + let mut b = BezPath::new(); + b.move_to((c.x + r, c.y)); + b.curve_to((c.x + r, c.y + k), (c.x + k, c.y + r), (c.x, c.y + r)); + b.curve_to((c.x - k, c.y + r), (c.x - r, c.y + k), (c.x - r, c.y)); + b.curve_to((c.x - r, c.y - k), (c.x - k, c.y - r), (c.x, c.y - r)); + b.curve_to((c.x + k, c.y - r), (c.x + r, c.y - k), (c.x + r, c.y)); + b.close_path(); + b +} + +/// A "D" with its counter, as outlined type draws one: straight stems, round bowls, and corners +/// where they meet. +fn letter_d() -> BezPath { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((40.0, 0.0)); + b.curve_to((95.0, 0.0), (95.0, 100.0), (40.0, 100.0)); + b.line_to((0.0, 100.0)); + b.close_path(); + b.move_to((20.0, 20.0)); + b.line_to((20.0, 80.0)); + b.line_to((40.0, 80.0)); + b.curve_to((70.0, 80.0), (70.0, 20.0), (40.0, 20.0)); + b.close_path(); + b +} + +/// A path's curved segments, segments, smooth anchors and anchors. +fn anatomy(p: &PathData) -> [usize; 4] { + let mut n = [0; 4]; + for sp in &p.subpaths { + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + n[0] += (0..count).filter(|i| !sp.segment_is_line(*i)).count(); + n[1] += count; + n[2] += sp.anchors.iter().filter(|a| a.kind == vectorcraft_geom::AnchorKind::Smooth).count(); + n[3] += sp.anchors.len(); + } + n +} + +/// `b` flattened to within 1e-6 pt, each point moved by `map`. +fn fine(b: &BezPath, map: impl Fn(Point) -> Point) -> Vec> { + let mut out: Vec> = vec![]; + vectorcraft_geom::kurbo::flatten(b, 1e-6, |el| match el { + vectorcraft_geom::kurbo::PathEl::MoveTo(p) => out.push(vec![map(p)]), + vectorcraft_geom::kurbo::PathEl::LineTo(p) => out.last_mut().into_iter().for_each(|r| r.push(map(p))), + _ => {} + }); + out +} + +/// How far `p` lies from the closed polygons `target` at most, sampled along each segment. +fn farthest(p: &PathData, target: &[Vec]) -> f64 { + let mut far = 0.0f64; + for sp in &p.subpaths { + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + for i in 0..count { + let c = sp.segment(i); + for k in 0..=32 { + let q = c.eval(f64::from(k) / 32.0); + assert!(q.x.is_finite() && q.y.is_finite(), "{q:?}"); + far = far.max(edge_distance(target, q)); + } + } + } + far +} + +/// Seen straight on, the front cap of an extruded circle or letter is the art: each curve stays +/// one cubic, smooth anchors stay smooth, and no point lies farther than the curve tolerance from +/// the art. The facet's polygon, which visibility uses, is still the sampled outline. +#[test] +fn a_cap_keeps_the_curves_of_the_art() { + for art in [circle(Point::new(50.0, 50.0), 40.0), letter_d()] { + let (p, b) = shape(&art); + let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap(); + assert_eq!(f.len(), 1); + let cap = f[0].path(); + assert_eq!(anatomy(&cap), anatomy(&p), "{cap:?}"); + assert!(f[0].contours.iter().map(Vec::len).sum::() > anatomy(&p)[3]); + let far = farthest(&cap, &fine(&art, |q| q)); + assert!(far <= outline::CURVE_TOLERANCE, "{far}"); + } +} + +/// Turned and in perspective, the front cap's curves are fitted to the projected circle within +/// the curve tolerance, every anchor stays smooth, and the walls' edges along it are curved too. +#[test] +fn a_cap_in_perspective_fits_the_projected_curves() { + let art = circle(Point::new(50.0, 50.0), 40.0); + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, ..Extrude::default() }; + for view in [View { rotation_x: 20.0, rotation_y: 30.0, perspective: 60.0, ..front() }, View { perspective: 120.0, ..front() }] { + let f = extrude(&p, FillRule::EvenOdd, b, o, view).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap().path(); + let [curves, segments, smooth, anchors] = anatomy(&cap); + assert!(curves >= 4 && curves == segments && smooth == anchors, "{:?}", anatomy(&cap)); + let camera = Camera::new(view, b, o.depth); + let projected = fine(&art, |q| camera.project(camera.place(q, o.depth / 2.0))); + let far = farthest(&cap, &projected); + assert!(far <= outline::CURVE_TOLERANCE + 1e-6, "{far}"); + if view.rotation_y != 0.0 { + assert!(f.iter().filter(|f| f.depth != f64::MAX).any(|f| anatomy(&f.path())[0] == 2), "the walls' edges along the circle are curved"); + } + } +} + +/// A cap inset by a bevel and the walls next to it draw the edge they share as the same curves, so +/// no gap or overlap opens between them. +#[test] +fn a_beveled_cap_and_its_walls_share_their_curves() { + for (r, width) in [(40.0, 4.0), (400.0, 40.0)] { + for art in [circle(Point::new(r, r), r), letter_d()] { + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, bevel: Some(Bevel { inside: true, width, height: width, ..Bevel::default() }), ..Extrude::default() }; + let f = extrude(&p, FillRule::EvenOdd, b, o, View { rotation_x: 20.0, rotation_y: 30.0, ..front() }).unwrap(); + let caps: Vec<&Facet> = f.iter().filter(|f| f.depth == f64::MAX).collect(); + let outlines: Vec> = caps.iter().flat_map(|f| fine(&f.path().to_bezpath(), |q| q)).collect(); + let on_cap = |q: Point| caps.iter().flat_map(|f| f.contours.iter().flatten()).any(|v| v.distance(q) < 1e-9); + let (mut far, mut shared) = (0.0f64, 0); + for wall in f.iter().filter(|f| f.depth != f64::MAX && f.outline.is_some()) { + let path = wall.path(); + for sp in &path.subpaths { + for i in (0..sp.anchors.len()).filter(|i| !sp.segment_is_line(*i)) { + let c = sp.segment(i); + if !on_cap(c.p0) || !on_cap(c.p3) { + continue; + } + shared += 1; + for k in 0..=16 { + far = far.max(edge_distance(&outlines, c.eval(f64::from(k) / 16.0))); + } + } + } + } + assert!(shared > 0, "no wall shares a curved edge with a cap"); + assert!(far <= 1e-4, "{far}"); + } + } +} + +/// A twisted, tapered back cap follows the turned, scaled art; a cap inset by a bevel stays +/// within the sampling tolerance of the solid; a tiny circle and a curve with a handle of zero +/// length keep their cubics. +#[test] +fn twisted_beveled_tiny_and_degenerate_caps_keep_their_curves() { + let art = circle(Point::new(50.0, 50.0), 40.0); + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, twist: 45.0, taper: 0.5, ..Extrude::default() }; + let behind = View { rotation_y: 180.0, ..front() }; + let f = extrude(&p, FillRule::EvenOdd, b, o, behind).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap().path(); + assert_eq!(anatomy(&cap)[0], 4); + let camera = Camera::new(behind, b, o.depth); + let back = fine(&art, |q| section(&[q], b, &o, 1.0).first().map_or(q, |q| camera.project(camera.place(*q, -o.depth / 2.0)))); + let far = farthest(&cap, &back); + assert!(far <= outline::CURVE_TOLERANCE + 1e-6, "{far}"); + + // Where a curve meets a straight edge at a corner, the bevel's miter moves the cap's corner off + // the curve's offset; the cap's curves still meet it. + let bevel = Extrude { bevel: Some(Bevel { inside: true, width: 4.0, height: 4.0, ..Bevel::default() }), ..Extrude::default() }; + for art in [art.clone(), letter_d()] { + let (p, b) = shape(&art); + let f = extrude(&p, FillRule::EvenOdd, b, bevel, front()).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap(); + // An offset curve is no cubic: it may take several, but no edge of a curve stays straight. + let ([curves, segments, ..], [art_curves, art_segments, ..]) = (anatomy(&cap.path()), anatomy(&p)); + assert!(curves >= art_curves && segments - curves == art_segments - art_curves, "{:?}", cap.path()); + let far = farthest(&cap.path(), &cap.contours); + assert!(far <= 2.0 * 0.05, "{far}"); + } + + let mut flat = PathData::from_bezpath(&art); + if let Some(a) = flat.subpaths.first_mut().and_then(|sp| sp.anchors.first_mut()) { + a.h_out = a.p; + } + for p in [PathData::from_bezpath(&circle(Point::new(1.0, 1.0), 0.01)), flat] { + let b = p.bounds().unwrap(); + let f = extrude(&p, FillRule::NonZero, b, Extrude::default(), front()).unwrap(); + let cap = f[0].path(); + assert_eq!(anatomy(&cap)[0], anatomy(&p)[0], "{cap:?}"); + let far = farthest(&cap, &fine(&p.to_bezpath(), |q| q)); + assert!(far <= outline::CURVE_TOLERANCE, "{far}"); + } +} + #[test] fn the_inflate_membrane_matches_known_solutions() { // ∇²φ = −1, zero on the outline: a disc of radius r peaks at r²/4, a square of side l at @@ -321,11 +513,11 @@ fn the_inflate_membrane_matches_known_solutions() { c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let disc = Membrane::solve(&r, &Edges::new(&r), b.origin(), b.width() / 48.0, 49, 49); + let disc = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), b.width() / 48.0, 49, 49); assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak); let (p, b) = shape(&square(0.0, 0.0, 100.0)); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let sq = Membrane::solve(&r, &Edges::new(&r), b.origin(), 100.0 / 48.0, 49, 49); + let sq = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak); // Smooth across the middle: no crease where a distance-to-the-edge height would have one // (the diagonals). The second difference along a diagonal stays small and even. @@ -374,8 +566,7 @@ fn bumps(count: usize) -> (PathData, Rect) { } /// Curves are followed within a tolerance rather than by sixteen points each: small curves take a -/// few points, so shapes of many curves (outlined type) fit; Rotate, which only turns the outline, -/// takes far more. +/// few points, so shapes of many curves (outlined type) fit, in a solid and in Rotate. #[test] fn small_curves_take_few_points_and_rotate_takes_large_outlines() { // 300 curves were 4,800 points at sixteen each, over the old limit of 1,024. @@ -383,10 +574,13 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok()); - // 4,000 curves: too many for a solid, not for Rotate. + // 4,000 curves fit a solid and Rotate alike; 70,000 fit neither. let (p, r) = bumps(4000); - assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex")); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1); + let (p, r) = bumps(70_000); + assert_eq!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect"); + assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap_err(), "The shape is too complex for a 3D effect"); // Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt // here (1/2000 of its 100 pt size is less). let mut c = BezPath::new(); @@ -421,7 +615,7 @@ fn twisted_pieces_stay_within_the_facet_limit() { fn the_edge_index_finds_the_nearest_edge() { let (p, r) = bumps(200); let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap(); - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); for k in 0..400 { let t = f64::from(k); let q = Point::new(-300.0 + (t * 37.0) % 1400.0, -50.0 + (t * 13.0) % 130.0); @@ -437,7 +631,7 @@ fn the_edge_index_looks_along_the_side_from_outside() { let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); // Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid). let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))]; - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); let q = Point::new(-10.0, 0.5); let (fast, slow) = (edges.distance(q), edge_distance(&rings, q)); assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}"); @@ -1019,7 +1213,7 @@ fn flat_far_and_too_large_merges() { let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect(); let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false); let with_line = PathData::new(squares.iter().cloned().chain([line]).collect()); - assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line))); + assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(&with_line))); assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6)); // Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the // test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas. @@ -1049,9 +1243,8 @@ fn flat_far_and_too_large_merges() { let b = wide.bounds().unwrap(); let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap(); assert_eq!(joined.iter().map(|f| f.contours.len()).sum::(), 2, "the squares joined, the lobe apart"); - // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced: - // past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so - // they can't be built as they are), and Rotate's 65,536 takes it. + // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced, + // within the 65,536 points Extrude & Bevel, Inflate and Rotate take. let mut bars = vec![]; for i in 0..24 { let x = i as f64 * 10.0; @@ -1061,9 +1254,18 @@ fn flat_far_and_too_large_merges() { let grid = PathData::new(bars); let b = grid.bounds().unwrap(); for rule in [FillRule::NonZero, FillRule::EvenOdd] { - assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); - assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); - assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate"); + // With a limit of 2,048 points the grid fits as drawn and its merged outline does not: its + // contours as drawn cross, so they can't be built as they are. + assert!(rings::sample(&grid, 240.0, 2048, rings::CURVE_PIECES).is_ok()); + assert_eq!( + rings::solid_rings(&grid, rule, 240.0, 2048, rings::CURVE_PIECES), + Err("The shape is too complex for a 3D effect".into()), + "{rule:?}" + ); + let expected = vectorcraft_pathops::area(&grid, rule); + assert!(close(area(&extrude(&grid, rule, b, Extrude::default(), front()).unwrap()), expected, 1e-6), "{rule:?}: Extrude & Bevel"); + assert!(!inflate(&grid, rule, b, Inflate::default(), front()).unwrap().is_empty(), "{rule:?}: Inflate"); + assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), expected, 1e-6), "{rule:?}: Rotate"); } } @@ -1076,7 +1278,7 @@ fn meets_within_twice_the_boolean_tolerance() { let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) }; - let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p)); + let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(p)); // Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on // the page (greater y) than the first's: snapped onto the first's heights, they are joined. let d = 0.99e-6; @@ -1159,7 +1361,7 @@ fn meets_within_twice_the_boolean_tolerance() { fn the_merged_rings_are_turned_as_a_whole() { let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)]; let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)]; - let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); + let turned = rings::wound::>(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0); let kept = rings::wound(vec![outline.clone(), hole.clone()]); assert_eq!(kept, vec![outline, hole]); diff --git a/crates/three-d/src/solid/tests_limits.rs b/crates/three-d/src/solid/tests_limits.rs new file mode 100644 index 000000000..bd7009623 --- /dev/null +++ b/crates/three-d/src/solid/tests_limits.rs @@ -0,0 +1,197 @@ +use vectorcraft_geom::{Anchor, SubPath}; + +use super::*; + +const SHAPE: &str = "The shape is too complex for a 3D effect"; +const SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + +/// A closed ring of `count` curved petals around a circle of `radius`: each curve leaves its anchor +/// outward, `lift` points out, and comes back to the next anchor, so a larger lift asks for more +/// pieces per curve. +fn petals(count: usize, radius: f64, lift: f64) -> PathData { + let chord = std::f64::consts::TAU * radius / count as f64; + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + let p = Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s); + let (out, along) = (Vec2::new(c, s) * lift, Vec2::new(-s, c) * (chord / 3.0)); + Anchor::with_handles(p, p + out - along, p + out + along) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// A closed polygon of `count` straight edges around a circle. +fn polygon(count: usize, radius: f64) -> PathData { + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + Anchor::corner(Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// Turned so that the walls and bevels show. +fn turned() -> View { + View { rotation_x: -18.0, rotation_y: -26.0, rotation_z: 8.0, ..View::default() } +} + +fn size(path: &PathData) -> f64 { + let b = path.bounds().unwrap(); + b.width().max(b.height()) +} + +/// How many points the outline samples to, each curve in at most `most` pieces. +fn points(path: &PathData, most: u32) -> usize { + rings::sample(path, size(path), usize::MAX, most).unwrap().iter().map(Vec::len).sum() +} + +fn built(facets: &[Facet]) -> bool { + !facets.is_empty() + && facets.len() <= MAX_SOLID_FACES + && facets.iter().flat_map(|f| f.contours.iter().flatten()).all(|p| p.x.is_finite() && p.y.is_finite()) +} + +#[test] +fn two_thousand_curves_extrude_with_a_bevel_and_inflate() { + let path = petals(2000, 500.0, 4.0); + let b = path.bounds().unwrap(); + // Every curve takes several pieces: far more points than 2,048, all of them kept. + let sampled = points(&path, rings::CURVE_PIECES); + assert!(sampled >= 3 * 2000, "{sampled}"); + assert_eq!(rings::curve_pieces(&path, size(&path), 2, 2), Ok(rings::CURVE_PIECES)); + let bevel = Extrude { bevel: Some(Bevel::default()), ..Extrude::default() }; + let extruded = extrude(&path, FillRule::NonZero, b, bevel, turned()).unwrap(); + assert!(built(&extruded) && extruded.len() > sampled, "{}", extruded.len()); + let inflated = inflate(&path, FillRule::NonZero, b, Inflate::default(), turned()).unwrap(); + assert!(built(&inflated), "{}", inflated.len()); +} + +#[test] +fn an_outline_at_the_point_limit_is_taken_and_one_past_it_is_refused_before_sampling() { + let at = polygon(MAX_SOLID_POINTS, 20_000.0); + assert_eq!(rings::curve_pieces(&at, size(&at), 1, 2), Ok(rings::CURVE_PIECES)); + assert!(built(&extrude(&at, FillRule::NonZero, at.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let past = polygon(MAX_SOLID_POINTS + 1, 20_000.0); + assert_eq!(rings::curve_pieces(&past, size(&past), 1, 2), Err(SHAPE.to_string())); + assert_eq!(extrude(&past, FillRule::NonZero, past.bounds().unwrap(), Extrude::default(), turned()), Err(SHAPE.to_string())); + assert_eq!(inflate(&past, FillRule::NonZero, past.bounds().unwrap(), Inflate::default(), turned()), Err(SHAPE.to_string())); +} + +#[test] +fn faces_at_the_limit_are_taken_and_one_past_it_is_refused_before_sampling() { + // 40,960 straight edges in four bands are exactly the face limit. + let path = polygon(MAX_SOLID_FACES / 4, 20_000.0); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 0), Ok(rings::CURVE_PIECES)); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 1), Err(SURFACE.to_string())); + // A rounded bevel gives 20,000 edges nine bands each, past the limit. + let path = polygon(20_000, 20_000.0); + let rounded = Extrude { bevel: Some(Bevel { shape: BevelShape::Rounded, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&path, FillRule::NonZero, path.bounds().unwrap(), rounded, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn curves_over_the_budget_take_fewer_pieces_down_to_four() { + let path = petals(2000, 500.0, 20.0); + let s = size(&path); + assert_eq!(points(&path, rings::CURVE_PIECES), 6 * 2000, "six pieces a curve within the tolerance"); + // 16 faces an edge: 12,000 points are too many, 10,000 at five pieces a curve fit. + assert_eq!(rings::curve_pieces(&path, s, 16, 0), Ok(5)); + assert_eq!(points(&path, 5), 5 * 2000); + // At four pieces a curve, 21 faces an edge are still too many. + assert_eq!(rings::curve_pieces(&path, s, 21, 0), Err(SURFACE.to_string())); + // Twisted 96°, the walls are 16 slices: built with five pieces a curve. + let b = path.bounds().unwrap(); + let twisted = extrude(&path, FillRule::NonZero, b, Extrude { twist: 96.0, hidden: true, ..Extrude::default() }, turned()).unwrap(); + assert!(built(&twisted) && twisted.len() > MAX_FACES, "{}", twisted.len()); + // Twisted 360°, 60 slices are too many even at four pieces a curve. + assert_eq!(extrude(&path, FillRule::NonZero, b, Extrude { twist: 360.0, ..Extrude::default() }, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn five_and_ten_thousand_curves_build_at_the_defaults() { + let five = petals(5000, 1250.0, 2.0); + assert!(built(&inflate(&five, FillRule::NonZero, five.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(built(&extrude(&five, FillRule::NonZero, five.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let ten = petals(10_000, 2500.0, 4.0); + let b = ten.bounds().unwrap(); + assert!(points(&ten, rings::CURVE_PIECES) > 10_000); + assert!(built(&extrude(&ten, FillRule::NonZero, b, Extrude::default(), turned()).unwrap())); + let deep = Extrude { twist: 360.0, bevel: Some(Bevel { shape: BevelShape::Rounded, repeat: 4, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&ten, FillRule::NonZero, b, deep, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn many_empty_contours_and_invalid_handles_are_refused_before_sampling() { + // Contours of one anchor and of none draw nothing, yet each is counted. + let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(1.0, i as f64))] }, false); + let square = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true); + let at = PathData::new((0..MAX_SOLID_POINTS - 1).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&at, 10.0, 1, 2), Ok(rings::CURVE_PIECES)); + let past = PathData::new((0..MAX_SOLID_POINTS).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&past, 10.0, 1, 2), Err(SHAPE.to_string())); + let b = Rect::new(0.0, 0.0, 10.0, 10.0); + assert_eq!(extrude(&past, FillRule::NonZero, b, Extrude::default(), turned()), Err(SHAPE.to_string())); + let mut bad = square; + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let bad = PathData::new(vec![bad]); + assert_eq!(rings::curve_pieces(&bad, 10.0, 1, 2), Err("The shape contains invalid coordinates".to_string())); + assert_eq!(inflate(&bad, FillRule::NonZero, b, Inflate::default(), turned()), Err("The shape contains invalid coordinates".to_string())); +} + +/// A comb of `teeth` teeth, 100 wide and 100 tall, on a strip along its foot: its outline +/// crosses every cell of the grid Inflate fills. +fn comb(teeth: usize) -> PathData { + let w = 100.0 / teeth as f64; + let mut points: Vec = (0..teeth) + .flat_map(|k| { + let x = k as f64 * w; + [Point::new(x, 0.0), Point::new(x, 100.0), Point::new(x + w / 2.0, 100.0), Point::new(x + w / 2.0, 0.0)] + }) + .collect(); + points.extend([Point::new(100.0, -1.0), Point::new(0.0, -1.0)]); + PathData::new(vec![SubPath::polyline(&points, true)]) +} + +/// Inflate `path` with the grid work its edge index may take set to `budget`. +fn inflated_within(path: &PathData, budget: usize) -> Result, String> { + GRID_BUDGET.with(|b| b.set(budget)); + let out = inflate(path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()); + GRID_BUDGET.with(|b| b.set(MAX_GRID_WORK)); + out +} + +#[test] +fn inflate_at_its_grid_work_budget_builds_and_one_past_it_is_refused() { + let path = comb(200); + assert!(built(&inflated_within(&path, MAX_GRID_WORK).unwrap())); + let work = GRID_WORK.with(std::cell::Cell::get); + assert!(work > 0); + assert!(built(&inflated_within(&path, work).unwrap())); + assert_eq!(GRID_WORK.with(std::cell::Cell::get), work, "the same work at its budget"); + assert_eq!(inflated_within(&path, work - 1), Err(SURFACE.to_string())); +} + +#[test] +fn the_edge_index_at_its_entry_limit_is_built_and_one_past_it_is_refused() { + // A diagonal across the whole grid of 64 by 64 buckets is filed in every bucket, each way. + let diagonal = vec![Point::new(0.0, 0.0), Point::new(64.0, 64.0)]; + assert_eq!(MAX_EDGE_ENTRIES, 2048 * 2 * 64 * 64); + let mut rings = vec![diagonal; 2048]; + assert!(Edges::new(&rings).is_ok_and(|e| e.work() == MAX_EDGE_ENTRIES)); + // A small triangle in one bucket: three entries more. + rings.push(vec![Point::new(0.0, 0.0), Point::new(0.5, 0.0), Point::new(0.0, 0.5)]); + assert_eq!(Edges::new(&rings).err(), Some(SURFACE.to_string())); +} + +#[test] +fn a_comb_that_crosses_every_cell_is_refused_once_its_grid_work_is_spent() { + // Two thousand curves take a small share of the budget. + let curves = petals(2000, 500.0, 4.0); + assert!(built(&inflate(&curves, FillRule::NonZero, curves.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(GRID_WORK.with(std::cell::Cell::get) < MAX_GRID_WORK / 16); + // 16,002 points, each clipped in every cell its ring meets. + let path = comb(4000); + assert_eq!(inflate(&path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()), Err(SURFACE.to_string())); +} diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 4ee448830..68c19cc67 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -548,8 +548,8 @@ impl Tool for SmartPenTool { } /// A path being drawn: Escape ends it. - fn ends_on_escape(&self, _cx: &ToolContext) -> bool { - self.path.is_some() + fn ends_on_escape(&self, cx: &ToolContext) -> bool { + self.active(cx).is_some() } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 53fbd3f3f..7a3d3de76 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -5,8 +5,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; use serde_json::{Value, json}; use vectorcraft_doc::{ - Node, - dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, PointRef, SegmentRef}, + Node, NodeId, + dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, FreePoint, Orientation, PointRef, SegmentRef}, }; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -69,6 +69,42 @@ pub struct DimensionTool { snapped: Option<(Point, Option)>, /// Whether the press under way began on an anchor a Linear click picks. press_on_anchor: bool, + /// The second point clicked for the Linear dimension being placed, and where the pointer was + /// for that click (a snap or Shift can move the point away from it): placed with the pointer + /// still at either, it is aligned. + second: Option<(Point, Point)>, +} + +/// What a linear dimension between `a` and `b` measures with the pointer at `p` while it is +/// placed (VectorCraft's own rule). Beside the points, where the nearest point of the line +/// through them lies between them, the dimension is aligned: it measures the distance between +/// them. Past either point it measures along an axis. The line through the points and the +/// perpendicular at that point bound a quarter on each side; the pointer's quarter holds either +/// the straight up or down direction, which gives the horizontal distance, or the straight left +/// or right direction, which gives the vertical distance. Points on one horizontal or vertical +/// line only give an aligned dimension. +fn orientation_at(a: Point, b: Point, p: Point) -> Orientation { + let u = b - a; + let len = u.hypot(); + if len < 1e-9 { + return Orientation::Aligned; + } + let along = (p - a).dot(u) / (len * len); + if (0.0..=1.0).contains(&along) { + return Orientation::Aligned; + } + // Out past the nearer point, and the side of the line the pointer is on. + let (out, side) = (if along > 1.0 { u } else { -u }, u.cross(p - a)); + let axes = [ + (Vec2::new(0.0, 1.0), Orientation::Horizontal), + (Vec2::new(0.0, -1.0), Orientation::Horizontal), + (Vec2::new(1.0, 0.0), Orientation::Vertical), + (Vec2::new(-1.0, 0.0), Orientation::Vertical), + ]; + let within = len * 1e-9; + axes.into_iter() + .find(|(axis, _)| axis.dot(out) > within && u.cross(*axis).abs() > within && u.cross(*axis) * side > 0.0) + .map_or(Orientation::Aligned, |(_, orientation)| orientation) } /// How near an anchor (screen pixels) a pick snaps to it. @@ -177,6 +213,30 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { (q.x.is_finite() && q.y.is_finite()).then_some(q) } +/// What a dimension between two picked points links to. A point on an anchor links to the anchor. +/// A point on a segment keeps its place along the segment. Any other point is held +/// against the anchors of the path under it, or, with no path under it, the path of the other +/// point, so that it moves with that path. None when neither point has a path to link to. +fn points_source(cx: &ToolContext, (a, ra): (Point, &Option), (b, rb): (Point, &Option)) -> Option { + let (edge_a, edge_b) = (pick(cx, a), pick(cx, b)); + let node = |r: &Option, e: &Option| r.as_ref().map(|r| r.node).or_else(|| e.as_ref().map(|e| e.node)); + let (path_a, path_b) = (node(ra, &edge_a), node(rb, &edge_b)); + let free = |p: Point, r: &Option, edge: &Option, own: Option, other: Option| { + if r.is_some() { + return None; + } + edge.as_ref().and_then(|e| FreePoint::on_segment(cx.doc, e, p)).or_else(|| own.or(other).and_then(|node| FreePoint::on(cx.doc, node, p))) + }; + let (free_a, free_b) = (free(a, ra, &edge_a, path_a, path_b), free(b, rb, &edge_b, path_b, path_a)); + (ra.is_some() || rb.is_some() || free_a.is_some() || free_b.is_some()).then(|| DimensionSource::Points { + a: ra.clone(), + b: rb.clone(), + vertex: None, + free_a, + free_b, + }) +} + impl DimensionTool { /// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in @@ -214,7 +274,18 @@ impl DimensionTool { } } fn recipe(&self, mode: DimensionMode, a: Point, b: Point, vertex: Point, source: Option) -> Dimension { - Dimension { mode, a, b, vertex, offset: 0.0, length: a.distance(b), options: self.style.clone(), source, moved: None } + Dimension { + mode, + a, + b, + vertex, + offset: 0.0, + length: a.distance(b), + options: self.style.clone(), + source, + orientation: Orientation::Aligned, + moved: None, + } } /// The dimension a target gives when dragged from. fn dimension_for(&self, target: &Target, p: Point) -> Option { @@ -247,6 +318,9 @@ impl DimensionTool { if let Some(source) = &d.source { p["source"] = json!(source); } + if d.orientation != Orientation::Aligned { + p["orientation"] = json!(d.orientation); + } p } fn preview(&mut self, cx: &ToolContext, p: Point) -> Vec { @@ -256,6 +330,18 @@ impl DimensionTool { { d.mode = if p.distance(*center) >= *radius { DimensionMode::Radius } else { DimensionMode::Diameter }; } + if d.mode == DimensionMode::Linear { + // The points the dimension measures: a dragged segment's ends, read from the art. + let ends = match &d.source { + Some(DimensionSource::Segment { edge }) => edge.endpoints(cx.doc), + _ => Some((d.a, d.b)), + }; + let again = self.second.is_some_and(|(at, pointer)| at.distance(p).min(pointer.distance(p)) <= cx.tol(ANCHOR_REACH)); + d.orientation = match ends { + Some((a, b)) if !again => orientation_at(a, b, p), + _ => Orientation::Aligned, + }; + } d.place(p); if d.resolve(cx.doc).is_err() { return vec![]; @@ -287,6 +373,7 @@ impl DimensionTool { self.began = false; self.snap.clear(); self.snapped = None; + self.second = None; } /// A plain click: picks a point or a line, or measures a circle's radius. fn click(&mut self, cx: &ToolContext, p: Point, target: Option, mods: Mods) -> Vec { @@ -301,9 +388,10 @@ impl DimensionTool { } self.points.push((at, reference)); if let [(a, ra), (b, rb)] = self.points.as_slice() { - let source = (ra.is_some() || rb.is_some()).then(|| DimensionSource::Points { a: ra.clone(), b: rb.clone(), vertex: None }); + let source = points_source(cx, (*a, ra), (*b, rb)); let mut d = self.recipe(DimensionMode::Linear, *a, *b, Point::ZERO, source); if d.resolve(cx.doc).is_ok() { + self.second = Some((*b, p)); self.pending = Some(d); } self.points.clear(); @@ -603,7 +691,7 @@ mod tests { let d = t.pending.as_ref().expect("both anchors were picked"); assert_eq!((d.a, d.b), (Point::new(100.0, 100.0), Point::new(200.0, 200.0))); let anchor = |anchor| Some(PointRef { node: id, subpath: 0, anchor }); - assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None })); + assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None, free_a: None, free_b: None })); // Dragged past the anchor's reach, the press drags out the dimension of a segment there. let mut t = DimensionTool::default(); t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); @@ -613,6 +701,66 @@ mod tests { assert!(matches!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 60.0)).last(), Some(Action::Commit))); } + /// A point picked on a segment, not on an anchor, keeps its place along the segment. A point + /// off the art is held against the anchors of the other point's path. Both then follow the + /// path, as the anchor at the other end does. + #[test] + fn points_off_anchors_are_held_against_the_path() { + use vectorcraft_doc::dimension::FreePoint; + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let click = |t: &mut DimensionTool, x: f64, y: f64| { + t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)); + }; + let mut t = DimensionTool::default(); + click(&mut t, 100.0, 100.0); + click(&mut t, 200.0, 150.0); + let pending = t.pending.as_ref().expect("both points were picked"); + let anchor = Some(PointRef { node: id, subpath: 0, anchor: 0 }); + let Some(DimensionSource::Points { a, b: None, vertex: None, free_a: None, free_b: Some(FreePoint::OnSegment { edge, t }) }) = + &pending.source + else { + panic!("{:?}", pending.source) + }; + assert_eq!((a, edge), (&anchor, &SegmentRef { node: id, subpath: 0, segment: 1 })); + assert!((t - 0.5).abs() < 1e-9, "{t}"); + let mut t = DimensionTool::default(); + click(&mut t, 400.0, 400.0); + click(&mut t, 200.0, 200.0); + let pending = t.pending.as_ref().expect("both points were picked"); + let Some(DimensionSource::Points { free_a: Some(free), b: Some(b), .. }) = &pending.source else { panic!("{:?}", pending.source) }; + let FreePoint::Anchors { node, anchors, across } = free else { panic!("{free:?}") }; + assert_eq!((*node, anchors.len(), *across, b.node, b.anchor), (id, 3, 0.0, id, 2)); + let at = free.position(&d).expect("the anchors that hold it exist"); + assert!(at.distance(pending.a) < 1e-9, "{at:?} {:?}", pending.a); + } + + /// A point that Smart Guides put in line with the path's center is held, where the guide put + /// it, against the anchors of the path at the other end. + #[test] + fn a_point_smart_guides_place_is_held_against_the_path() { + use vectorcraft_doc::dimension::FreePoint; + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + assert!(c.smart_guides); + let mut t = DimensionTool::default(); + for (x, y) in [(200.0, 200.0), (151.5, 330.0)] { + t.pointer(&c, &PointerEvent::new(PointerKind::Move, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)); + } + let pending = t.pending.as_ref().expect("both points were picked"); + assert!((pending.b.x - 150.0).abs() < 1e-9, "in line with the center: {:?}", pending.b); + let Some(DimensionSource::Points { a: Some(a), free_b: Some(free), .. }) = &pending.source else { panic!("{:?}", pending.source) }; + assert!(matches!(free, FreePoint::Anchors { node, .. } if *node == id), "{free:?}"); + assert_eq!(a.node, id); + let at = free.position(&d).expect("the anchors that hold it exist"); + assert!(at.distance(pending.b) < 1e-9, "{at:?} {:?}", pending.b); + } + /// The anchors of a live rectangle are picked the same way. #[test] fn a_press_on_a_live_shapes_anchor_picks_it() { @@ -655,6 +803,47 @@ mod tests { assert_eq!(t.points, vec![corner]); } + /// Beside the two points the dimension is aligned; past either point it is horizontal when + /// the pointer's quarter holds the straight up or down direction and vertical when it holds + /// the straight left or right direction. Points on one horizontal or vertical line, a pointer + /// on the line through the points, and two equal points give aligned. + #[test] + fn the_pointer_chooses_aligned_horizontal_or_vertical() { + use Orientation::{Aligned, Horizontal, Vertical}; + let at = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| orientation_at(Point::new(a.0, a.1), Point::new(b.0, b.1), Point::new(p.0, p.1)); + // A line rising to the right (y grows downwards), and the same points in the other order. + for (a, b) in [((100.0, 300.0), (200.0, 200.0)), ((200.0, 200.0), (100.0, 300.0))] { + for (p, want) in [ + ((120.0, 160.0), Aligned), + ((200.0, 300.0), Aligned), + ((150.0, 250.0), Aligned), + ((200.0, 200.0), Aligned), + ((100.0, 300.0), Aligned), + ((210.0, 120.0), Horizontal), + ((240.0, 150.0), Horizontal), + ((280.0, 220.0), Vertical), + ((240.0, 170.0), Vertical), + ((40.0, 310.0), Vertical), + ((90.0, 380.0), Horizontal), + ((250.0, 150.0), Aligned), + ] { + assert_eq!(at(a, b, p), want, "{a:?} to {b:?}, pointer at {p:?}"); + } + } + // A line falling to the right. + let (a, b) = ((100.0, 100.0), (300.0, 200.0)); + for (p, want) in + [((200.0, 50.0), Aligned), ((320.0, 300.0), Horizontal), ((400.0, 210.0), Vertical), ((60.0, 20.0), Horizontal), ((20.0, 90.0), Vertical)] + { + assert_eq!(at(a, b, p), want, "pointer at {p:?}"); + } + for (a, b) in [((100.0, 100.0), (300.0, 100.0)), ((100.0, 100.0), (100.0, 300.0)), ((100.0, 100.0), (100.0, 100.0))] { + for p in [(50.0, 50.0), (350.0, 50.0), (350.0, 350.0), (50.0, 350.0), (200.0, 60.0), (60.0, 200.0), (400.0, 100.0), (100.0, 400.0)] { + assert_eq!(at(a, b, p), Aligned, "{a:?} to {b:?}, pointer at {p:?}"); + } + } + } + #[test] fn placement_press_previews_without_a_prior_move() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 833f48225..292f1d25e 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -492,7 +492,7 @@ fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, us break; } if let Some(pd) = cx.doc.node(id).and_then(editable_path) - && let Some((si, ai, a)) = nearest_anchor(&pd, p, tol) + && let Some((si, ai, a)) = nearest_anchor(&pd, p, tol, cx.selection.partial(id)) { return Some((id, si, ai, a)); } @@ -524,7 +524,7 @@ fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, us return None; } let pd = cx.doc.node(id).and_then(editable_path)?; - nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a)) + nearest_anchor(&pd, p, tol, None).map(|(si, ai, a)| (id, si, ai, a)) }) } @@ -535,9 +535,14 @@ fn over_path_type_anchor(cx: &ToolContext, p: Point) -> bool { hit_anchor(cx, p, cx.point_tol()).is_some_and(|(id, ..)| cx.doc.node(id).is_some_and(is_path_type)) } -/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is). -fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> { - pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p))) +/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is). Of anchors +/// on top of each other, such as the two ends a cut leaves, one in `picked` comes first. +fn nearest_anchor(pd: &PathData, p: Point, tol: f64, picked: Option<&BTreeSet>) -> Option<(usize, usize, Point)> { + let unpicked = |si: usize, ai: usize| !picked.is_some_and(|set| set.contains(&(si, ai))); + pd.anchors() + .map(|(si, ai, a)| (si, ai, a.p)) + .filter(|(.., a)| a.distance(p) <= tol) + .min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)).then_with(|| unpicked(x.0, x.1).cmp(&unpicked(y.0, y.1)))) } /// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). @@ -557,7 +562,7 @@ fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec Vec<(NodeId, usize, usize, Anchor)> { let mut v = vec![]; for &id in &cx.selection.objects { - let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue }; + let Some(pd) = cx.doc.node(id).and_then(handle_path) else { continue }; let shown = cx.selection.partial(id); v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| { let mut a = *a; @@ -579,6 +584,33 @@ fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> { v } +/// The path whose direction handles Direct Selection shows and drags on `n` once it is selected: +/// a path's, or type on a path's path (#3565), in document space. +fn handle_path(n: &Node) -> Option> { + match &n.kind { + NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)), + NodeKind::Text(t) if is_path_type(n) => t.type_path().map(Cow::Owned), + _ => None, + } +} + +/// The direction handles of selected type on a path ([`handle_anchors`]), drawn by the tool: +/// the canvas draws only a path's own (#3565). +fn type_path_handles(cx: &ToolContext) -> Vec { + let mut out = vec![]; + for (id, _, _, a) in handle_anchors(cx) { + if !cx.doc.node(id).is_some_and(is_path_type) { + continue; + } + let color = cx.doc.layer_color(id); + for h in [a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6) { + out.push(Overlay::Line { a: a.p, b: h, color, dashed: false }); + out.push(Overlay::Handle { p: h, color }); + } + } + out +} + /// The end of a shown direction handle ([`handle_anchors`]) nearest to `p` within `tol`, and /// nearer to it than the handle's anchor (a short handle leaves its anchor to be picked): (path, /// subpath, anchor, is_out). @@ -897,8 +929,9 @@ impl Tool for DirectSelectionTool { let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift); self.snapped = snapped(q.to_vec2(), p.to_vec2(), ev.mods.shift, &guides); self.guides = guides; + let cmd = if cx.doc.node(id).is_some_and(is_path_type) { "text.setPathHandle" } else { "path.setHandle" }; vec![Action::Preview( - "path.setHandle".into(), + cmd.into(), json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": q.x, "y": q.y, "independent": ev.mods.alt}), )] } @@ -1001,6 +1034,7 @@ impl Tool for DirectSelectionTool { out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); } out.extend(frame_overlays(cx, &self.frame_anchors(cx))); + out.extend(type_path_handles(cx)); let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None }; out.extend(pathtype::overlays(cx, self.bracket_hover, drag)); } @@ -1116,7 +1150,7 @@ fn hovered_anchor_with_hit(cx: &ToolContext, p: Point, hit: Option<&Hit>) -> Opt let under = hit.map(|h| cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)).unwrap_or(h.leaf)); cx.selection.objects.iter().copied().filter(|id| in_scope(cx.doc, cx.isolation, *id)).chain(under).find_map(|id| { let path = cx.doc.node(id).and_then(editable_path)?; - nearest_anchor(&path, p, tol).map(|(.., a)| (id, a)) + nearest_anchor(&path, p, tol, None).map(|(.., a)| (id, a)) }) } diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 7948b4707..0ac443c73 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -40,7 +40,7 @@ fn a_press_where_a_mask_hides_a_selected_object_drags_it() { #[test] fn direct_and_group_selection_pick_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 100.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); @@ -62,7 +62,7 @@ fn direct_and_group_selection_pick_ruler_guides() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let (s, p) = (Selection::default(), paint()); @@ -848,7 +848,7 @@ fn crossed_segments_match_testing_a_path_for_each_segment() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -2334,3 +2334,38 @@ fn a_press_on_a_clipping_path_in_a_dynamic_instance_picks_it() { outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); assert_eq!(drawn, Some(Rect::new(310.0, 310.0, 320.0, 320.0))); } + +/// #3565: selected type on a path shows its path's direction handles, as a path selected whole +/// shows its own, and a press on one drags it with `text.setPathHandle` (Shift: 45° steps round +/// its anchor; Alt: that side only); none with Show handles when multiple anchors are selected off. +#[test] +fn type_on_a_path_shows_and_drags_its_paths_handles() { + let (mut d, text) = doc_with_path_type(); + let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(text).map(|n| &mut n.kind) else { panic!("type") }; + let vectorcraft_doc::TextKind::OnPath { path, .. } = &mut t.kind else { panic!("on a path") }; + path.subpaths[0].anchors[0].h_out = Point::new(100.0, 200.0); + path.subpaths[0].anchors[1].h_in = Point::new(400.0, 200.0); + let p = paint(); + let mut s = Selection::default(); + s.add(text); + let c = cx(&d, &s, &p); + let handles = |c: &ToolContext| -> Vec { + DirectSelectionTool::new(false).overlays(c).iter().filter_map(|o| if let Overlay::Handle { p, .. } = o { Some(*p) } else { None }).collect() + }; + assert_eq!(handles(&c), vec![Point::new(100.0, 200.0), Point::new(400.0, 200.0)]); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 201.0)), vec![Action::Begin("Reshape".into())]); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 210.0).with_mods(Mods { alt: true, ..Mods::default() })); + assert!( + matches!(&a[..], [Action::Preview(n, v)] if n == "text.setPathHandle" && v["id"] == text.0 && v["subpath"] == 0 && v["anchor"] == 0 && v["which"] == "out" && v["independent"] == true), + "{a:?}" + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 210.0)), vec![Action::Commit]); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 399.0, 201.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 120.0).with_mods(Mods { shift: true, ..Mods::default() })); + let Some(Action::Preview(n, v)) = a.first() else { panic!("{a:?}") }; + assert_eq!((n.as_str(), &v["anchor"], &v["which"]), ("text.setPathHandle", &json!(1), &json!("in"))); + assert!((v["x"].as_f64().unwrap() - 400.0).abs() < 1e-9, "Shift keeps it straight above its anchor: {v}"); + assert!(handles(&ToolContext { handles_multiple: false, ..cx(&d, &s, &p) }).is_empty()); +} diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 495e932a4..e4811418c 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -782,8 +782,10 @@ impl Tool for GestureTool { }; let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK }; // A tip that is not round shows the outline of the area it covers, each step of the drag - // as wide as the pen pressed there with Size set to Pressure. - if matches!(self.id, "eraser" | "blobBrush") && self.roundness < 100.0 && !self.smoothing { + // as wide as the pen pressed there with Size set to Pressure. So does a round Blob Brush + // tip: a line as wide as the brush would end square, where the stroke it adds is round at + // both ends (#4343). + if matches!(self.id, "eraser" | "blobBrush") && (self.roundness < 100.0 || self.id == "blobBrush") && !self.smoothing { let area = |pts: &[Point], size| { if self.id == "blobBrush" { blob_area(pts, size, self.angle, self.roundness) diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index c0ef91d24..a0fe48728 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -382,6 +382,36 @@ fn blob_brush_drag_shows_scrub_length_measurement_labels() { assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); } +/// #4343: while it is dragged, a round Blob Brush shows the outline of the area `path.blob` adds, +/// which reaches half the brush beyond the ends of the stroke (round tips), not a line as wide as +/// the brush that ends square at them. +#[test] +fn blob_brush_preview_is_the_outline_of_the_area_with_round_tips() { + use vectorcraft_geom::kurbo::Shape; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("blobBrush"); + t.size = 20.0; + assert_eq!(t.roundness, 100.0, "a round tip by default"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 300.0)); + let outlines: Vec<_> = + t.overlays(&cx).into_iter().filter_map(|o| if let Overlay::Path { path, width, .. } = o { Some((path, width)) } else { None }).collect(); + assert!(outlines.iter().all(|(_, width)| f64::from(*width) < t.size / 2.0), "no line as wide as the brush: {outlines:?}"); + // The stroke's outline is the widest one (the tip at the pointer is another). + let Some((path, _)) = outlines.iter().max_by(|a, b| a.0.bounding_box().width().total_cmp(&b.0.bounding_box().width())) else { + panic!("no outline") + }; + let b = path.bounding_box(); + assert!((b.x0 - 40.0).abs() < 0.5 && (b.x1 - 160.0).abs() < 0.5, "round tips reach half the size past the ends: {b:?}"); + assert!((b.y0 - 290.0).abs() < 0.5 && (b.y1 - 310.0).abs() < 0.5, "{b:?}"); + // Round, not square: the corner of the box a square-ended line would fill is outside the area. + assert!(!path.contains(vectorcraft_geom::Point::new(41.0, 291.0))); + assert!(path.contains(vectorcraft_geom::Point::new(45.0, 300.0))); +} + /// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its /// stroke sends both. #[test] diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 123c4b1ef..4f8e867a5 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -3,9 +3,10 @@ //! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down //! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click //! sets the end point of the rings; Space held during either drag moves the flare. The rest comes -//! from the tool's options (the Flare Tool Options, which last). Reshape: drag a point of a -//! selected path; the engine's `path.reshape` adds an anchor there if needed and moves the -//! neighbourhood smoothly. +//! from the tool's options (the Flare Tool Options, which last). Reshape: a click on a selected +//! path sets a focal point (adding an anchor on a segment), Shift-click adds or removes more, and +//! a drag from a focal point moves them all; the engine's `path.reshape` stretches the selected +//! anchors between them and the anchors that stay. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; @@ -268,27 +269,142 @@ impl Tool for FlareTool { } } -/// Reshape Tool: drag a point on a selected path. +/// Where a Reshape press landed on a selected path. +#[derive(Clone, Copy, Debug, PartialEq)] +enum ReshapeHit { + /// On an anchor: (subpath, anchor). + Anchor(usize, usize), + /// On a segment, away from its anchors: (subpath, the index of the anchor `path.reshape` adds + /// there). + Segment(usize, usize), +} + +impl ReshapeHit { + /// The anchor the press grabs, as `(subpath, anchor)` once any anchor has been added. + fn anchor(self) -> (usize, usize) { + match self { + Self::Anchor(si, ai) | Self::Segment(si, ai) => (si, ai), + } + } + + /// Where the anchor `r` is once the press has added its anchor: one further on after an + /// anchor added before it on its subpath. + fn renumber(self, r: (usize, usize)) -> (usize, usize) { + match self { + Self::Segment(si, added) if r.0 == si && r.1 >= added => (r.0, r.1.saturating_add(1)), + _ => r, + } + } +} + +/// A Reshape press: the path, the point on it, what it landed on and whether Shift was held. +#[derive(Clone, Debug)] +struct ReshapePress { + id: vectorcraft_doc::NodeId, + at: Point, + hit: ReshapeHit, + shift: bool, + /// The drag so far. + d: Vec2, + /// The other focal points the drag moves, found when it starts, before the preview moves them. + others: Vec<((usize, usize), Point)>, +} + +/// How near a focal point's stored position an anchor must be to be that focal point. An anchor +/// added on a segment lies where the curve's point was found, to within rounding. +const FOCAL_MATCH: f64 = 1e-6; + +/// A focal point: its `(subpath, anchor)` and where that anchor lies. +type FocalPoint = ((usize, usize), Point); + +/// Reshape Tool: a click on a selected path makes the anchor or the point on a segment there the +/// focal point (on a segment it adds an anchor), Shift-click adds or removes more, and a drag +/// moves the focal points with the drag and stretches the selected anchors around them. #[derive(Default)] pub struct ReshapeTool { - /// (path, grab point) while dragging. - grab: Option<(vectorcraft_doc::NodeId, Point)>, + /// The press, until the button is released. + press: Option, began: bool, /// Pointer and offset readout while a reshape drag is in progress. measure: Option<(Point, String)>, + /// The focal points the tool highlighted: their path, and each one's `(subpath, anchor)` and + /// where that anchor lies. An anchor the tool adds before one renumbers it. A focal point + /// whose anchor no longer lies there (another edit moved or removed it) shows nothing and + /// moves nothing. + focal: Option<(vectorcraft_doc::NodeId, Vec)>, } impl ReshapeTool { - /// The selected path under `p` (Reshape works on selected paths only, like Illustrator). - fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point)> { + /// The selected path under `p` (Reshape works on selected paths only), the point on it and + /// what the point is. + fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point, ReshapeHit)> { let tol = cx.tol(4.0); - let editable = cx.doc.editable(); - cx.selection.objects.iter().copied().filter(|id| editable(*id)).find_map(|id| { + Self::paths(cx).into_iter().find_map(|id| { let pd = cx.doc.node(id)?.path_data()?; - let (_, _, _, q, d) = pd.nearest(p)?; - (d <= tol).then_some((id, q)) + let (si, seg, _, q, d) = pd.nearest(p)?; + if d > tol { + return None; + } + // `path.reshape` takes the anchor within `tol` of the point on the path. + let anchor = pd + .subpaths + .get(si)? + .anchors + .iter() + .enumerate() + .map(|(i, a)| (i, a.p.distance(q))) + .filter(|&(_, dist)| dist <= tol) + .min_by(|a, b| a.1.total_cmp(&b.1)); + Some(match anchor { + Some((ai, _)) => (id, pd.subpaths.get(si)?.anchors.get(ai)?.p, ReshapeHit::Anchor(si, ai)), + // `SubPath::insert_anchor` puts the new anchor after the segment's first anchor. + None => (id, q, ReshapeHit::Segment(si, seg.saturating_add(1))), + }) }) } + + /// The paths Reshape works on: the selected paths that can be edited, in selection order. + fn paths(cx: &ToolContext) -> Vec { + let editable = cx.doc.editable(); + cx.selection.objects.iter().copied().filter(|&id| editable(id) && cx.doc.node(id).is_some_and(|n| n.path_data().is_some())).collect() + } + + /// The highlighted focal points of path `id` as `(subpath, anchor)`, with their positions: the + /// anchors of the path that lie where the tool stored them. None while Reshape does not work + /// on the path. + fn focal_points(&self, cx: &ToolContext, id: vectorcraft_doc::NodeId) -> Vec<((usize, usize), Point)> { + let Some((fid, points)) = &self.focal else { return vec![] }; + if *fid != id || !Self::paths(cx).contains(&id) { + return vec![]; + } + let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { return vec![] }; + let mut out: Vec<((usize, usize), Point)> = vec![]; + for &((si, ai), p) in points { + let here = pd.subpaths.get(si).and_then(|sp| sp.anchors.get(ai)).map(|a| a.p); + if let Some(q) = here.filter(|q| q.distance(p) <= FOCAL_MATCH) + && !out.iter().any(|(o, _)| *o == (si, ai)) + { + out.push(((si, ai), q)); + } + } + out + } + + /// The focal points other than the pressed point that a drag from `press` moves: all the + /// highlighted ones when the press is on one of them, else none (Shift only keeps the drag's + /// angle). + fn dragged_focal(&self, cx: &ToolContext, press: &ReshapePress) -> Vec<((usize, usize), Point)> { + let focal = self.focal_points(cx, press.id); + let pressed = |r: &(usize, usize)| matches!(press.hit, ReshapeHit::Anchor(si, ai) if (si, ai) == *r); + if focal.iter().any(|(r, _)| pressed(r)) { focal.into_iter().filter(|(r, _)| !pressed(r)).collect() } else { vec![] } + } + + /// `path.reshape`'s params for `press` dragged by `d`. + fn params(cx: &ToolContext, press: &ReshapePress, d: Vec2) -> Value { + let others: Vec<[usize; 2]> = press.others.iter().map(|&((si, ai), _)| [si, ai]).collect(); + // `focal` is sent when empty too: without it, `path.reshape` follows the earlier rule. + json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0), "focal": others }) + } } impl Tool for ReshapeTool { @@ -296,18 +412,19 @@ impl Tool for ReshapeTool { "reshape" } fn busy(&self) -> bool { - self.grab.is_some() + self.press.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { - self.grab = Self::hit(cx, ev.pos); + self.press = + Self::hit(cx, ev.pos).map(|(id, at, hit)| ReshapePress { id, at, hit, shift: ev.mods.shift, d: Vec2::ZERO, others: vec![] }); self.began = false; self.measure = None; vec![] } PointerKind::Drag => { - let Some((id, at)) = self.grab else { return vec![] }; + let Some(at) = self.press.as_ref().map(|p| p.at) else { return vec![] }; let mut d: Vec2 = ev.pos - at; if ev.mods.shift { d = vectorcraft_geom::constrain_angle_from(d, 45.0, cx.constrain_angle); @@ -318,25 +435,58 @@ impl Tool for ReshapeTool { return out; } self.began = true; + let others = self.press.as_ref().map(|p| self.dragged_focal(cx, p)).unwrap_or_default(); + if let Some(p) = self.press.as_mut() { + p.others = others; + } out.push(Action::Begin("Reshape".into())); } + let Some(press) = self.press.as_mut() else { return out }; + press.d = d; self.measure = cx.measurement_labels.then(|| (ev.pos + Vec2::new(cx.tol(12.0), cx.tol(12.0)), cx.offset_label(d.x, d.y))); - out.push(Action::Preview( - "path.reshape".into(), - json!({ "id": id.0, "x": at.x, "y": at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) }), - )); + out.push(Action::Preview("path.reshape".into(), Self::params(cx, press, d))); out } PointerKind::Up => { - self.grab = None; self.measure = None; - if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] } + let Some(press) = self.press.take() else { return vec![] }; + if std::mem::take(&mut self.began) { + // The focal points move with the whole drag, the grabbed point too, and are + // renumbered after an anchor added on a segment. + let mut points: Vec<((usize, usize), Point)> = press.others.iter().map(|&(r, p)| (press.hit.renumber(r), p + press.d)).collect(); + points.push((press.hit.anchor(), press.at + press.d)); + self.focal = Some((press.id, points)); + return vec![Action::Commit]; + } + // A click: the point becomes the focal point, or with Shift is added to them or, + // when it is one already, removed. + let mut points: Vec<((usize, usize), Point)> = if press.shift { self.focal_points(cx, press.id) } else { vec![] }; + let mut out = vec![]; + match press.hit { + ReshapeHit::Anchor(si, ai) => { + let before = points.len(); + points.retain(|&(r, _)| r != (si, ai)); + if points.len() == before { + points.push(((si, ai), press.at)); + } + } + ReshapeHit::Segment(..) => { + // A reshape by no distance adds the anchor and keeps the selection as it is. + out.push(Action::Exec("path.reshape".into(), Self::params(cx, &press, Vec2::ZERO))); + for (r, _) in &mut points { + *r = press.hit.renumber(*r); + } + points.push((press.hit.anchor(), press.at)); + } + } + self.focal = (!points.is_empty()).then_some((press.id, points)); + out } _ => vec![], } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - if key == ToolKey::Escape && self.grab.take().is_some() { + if key == ToolKey::Escape && self.press.take().is_some() { self.measure = None; if std::mem::take(&mut self.began) { return vec![Action::Cancel]; @@ -344,17 +494,30 @@ impl Tool for ReshapeTool { } vec![] } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.focal = None; + self.measure = None; + if self.press.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] } + } fn overlays(&self, cx: &ToolContext) -> Vec { + let mut out = vec![]; + // Each focal point: a filled anchor square inside a larger hollow one. + if let Some((id, _)) = &self.focal { + let color = cx.doc.layer_color(*id); + for (_, p) in self.focal_points(cx, *id) { + out.push(Overlay::Anchor { p, color, filled: false, size: 10.0 }); + out.push(Overlay::Anchor { p, color, filled: true, size: 5.0 }); + } + } if cx.measurement_labels && let Some((p, t)) = &self.measure { - vec![Overlay::Measure { p: *p, text: t.clone() }] - } else { - vec![] + out.push(Overlay::Measure { p: *p, text: t.clone() }); } + out } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - if self.grab.is_some() || Self::hit(cx, p).is_some() { Cursor::Move } else { Cursor::Arrow } + if self.press.is_some() || Self::hit(cx, p).is_some() { Cursor::Move } else { Cursor::Arrow } } } diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 9d530f4db..32941c5f7 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -428,8 +428,12 @@ impl HandleSnap { /// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`, /// and the guides showing why. pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec) { - let path = cx.doc.node(id).and_then(|n| n.path_data()); - if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) { + // A path's anchor, or one of type on a path's path (#3565), in document space. + let anchor = cx.doc.node(id).and_then(|n| match &n.kind { + vectorcraft_doc::NodeKind::Text(t) => t.type_path()?.subpaths.get(si)?.anchors.get(ai).copied(), + _ => n.path_data()?.subpaths.get(si)?.anchors.get(ai).copied(), + }); + if shift && let Some(a) = anchor { return (a.p + constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]); } if self.0.targets.is_none() { diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index e4039e6d9..41b9f517e 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -579,7 +579,7 @@ impl Targets { if cx.guides { self.rulers = cx .doc - .guides + .ruler_guides() .iter() .filter(|g| cx.doc.guide_shown(g)) .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) @@ -1194,6 +1194,47 @@ impl Targets { (d, ov, held.contains(&true)) } + /// [`Self::snap_moved_to_rulers`] for a selection a move Shift keeps to its line (through the + /// grabbed point `p`, along `dir`), its bounds now `r`: the edge or centre of `r` that a ruler + /// guide runs past within `tol` goes onto it by sliding along the line, the way needing the + /// shortest slide winning and at most twice `tol` (a line nearly parallel to a guide would + /// carry the selection far to meet it). Returns the shift along the line and the guides. + pub(crate) fn snap_moved_along_to_rulers(&self, p: Point, r: Rect, dir: Vec2, tol: f64) -> (Vec2, Vec) { + let len = dir.hypot(); + if !len.is_finite() || len <= 1e-9 { + return (Vec2::ZERO, vec![]); + } + let u = dir / len; + // The way along the line, where the guide is and where it starts, and whether it is upright. + let mut best: Option<(f64, f64, Point, bool)> = None; + for (vertical, c) in [(true, u.x), (false, u.y)] { + if c.abs() <= 1e-9 { + continue; + } + let vs = if vertical { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] }; + let (mid, half) = + if vertical { (Point::new(0.0, r.center().y), r.height() / 2.0) } else { (Point::new(r.center().x, 0.0), r.width() / 2.0) }; + for (t, from, _) in self.rulers.iter().filter(|g| g.vertical == vertical).filter_map(|g| g.line_at(mid, tol + half)) { + for v in vs { + let s = (t - v) / c; + if (t - v).abs() <= tol && s.abs() <= 2.0 * tol && best.is_none_or(|b| s.abs() < b.0.abs()) { + best = Some((s, t, from, vertical)); + } + } + } + } + let Some((s, t, from, vertical)) = best else { return (Vec2::ZERO, vec![]) }; + let moved = r + u * s; + let line = if vertical { + self.line(Point::new(t, from.y.min(moved.y0)), Point::new(t, from.y.max(moved.y1))) + } else { + self.line(Point::new(from.x.min(moved.x0), t), Point::new(from.x.max(moved.x1), t)) + }; + let mut ov: Vec = line.into_iter().collect(); + ov.extend(self.label(p + u * s, "align")); + (u * s, ov) + } + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for /// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own /// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 499410465..a4f3bc49f 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -22,6 +22,25 @@ fn snaps_to_anchor_and_alignment() { assert_eq!(p.y, 200.0); } +/// #4312: a Shift move slides the selection along its line onto a ruler guide, but not far to +/// meet one a nearly parallel line only reaches a long way off. +#[test] +fn a_slide_along_a_line_lands_the_bounds_on_a_ruler_guide_within_reach() { + let t = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: None }], ..Targets::default() }; + let r = Rect::new(248.0, 10.0, 298.0, 40.0); + let p = Point::new(270.0, 25.0); + // Level: the right edge is 2 short of the guide, which is the way along the line. + assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::new(2.0, 0.0)); + // On the diagonal it is 2.8 along. + let (d, ov) = t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 1.0), 4.0); + assert!((d - Vec2::new(2.0, 2.0)).hypot() < 1e-9 && !ov.is_empty(), "{d:?}"); + // Nearly upright, it would take about 118 along the line to meet the guide. + assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(0.017, 1.0), 4.0).0, Vec2::ZERO); + // A guide of an artboard needs the selection to run past it. + let bounded = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: Some((100.0, 200.0)) }], ..Targets::default() }; + assert_eq!(bounded.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::ZERO); +} + #[test] fn rect_snap_aligns_edges() { let (d, id) = doc_with_rect(); @@ -423,9 +442,9 @@ fn drawn_points_land_on_intersections() { let id = d.alloc_id(); d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap(); } - d.guides.push(vectorcraft_doc::Guide::new(true, 400.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 400.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let (s, p) = (Selection::default(), paint()); let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; let t = Targets::for_drawing(&d, &[], None, None).styled(&c).with_rulers(&c); @@ -593,8 +612,8 @@ fn bisection_finds_what_a_scan_finds() { #[test] fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); - d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 313.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 331.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let mut snap = DrawSnap::default(); diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index bc564fced..b70a160d8 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -243,8 +243,8 @@ impl Tool for PenTool { self.drag.is_some() || self.handle.is_some() || self.convert.is_some() } /// A path being drawn: Escape ends it. - fn ends_on_escape(&self, _cx: &ToolContext) -> bool { - self.path.is_some() + fn ends_on_escape(&self, cx: &ToolContext) -> bool { + self.active(cx).is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let tol = cx.tol(5.0).max(cx.point_tol()); diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 87822f070..97d8bed28 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -12,27 +12,34 @@ //! or locked layer, can't be picked. use serde_json::json; +use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; use crate::guides::Targets; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; -/// The ruler guide under `p`: the nearest within the selection tolerance (the newest of equals). +/// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection +/// tolerance (the newest of equals). pub fn guide_at(cx: &ToolContext, p: Point) -> Option { + guide_under(cx, p).map(|(i, _)| i) +} + +/// [`guide_at`], with the guide. +fn guide_under(cx: &ToolContext, p: Point) -> Option<(usize, Guide)> { if !cx.guides { return None; } let tol = cx.pick_tol(); let off = |pos: f64, vertical: bool| (if vertical { p.x } else { p.y } - pos).abs(); cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol)) - .map(|(i, g)| (i, off(g.pos, g.vertical))) - .filter(|(_, d)| *d <= tol) + .map(|(i, g)| (i, off(g.pos, g.vertical), g)) + .filter(|(_, d, _)| *d <= tol) .min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))) - .map(|(i, _)| i) + .map(|(i, _, g)| (i, g)) } /// What a selection tool's marquee `r` does to the ruler guides, after its action selecting the @@ -42,19 +49,20 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { pub fn marquee_select(cx: &ToolContext, r: Rect, enclosed: bool) -> Option { let mut guides = guides_in_rect(cx, r); if enclosed { - guides.retain(|i| cx.doc.guides.get(*i).is_some_and(|g| guide_inside(cx, r, g))); + guides.retain(|(_, g)| guide_inside(cx, r, g)); } + let guides: Vec = guides.into_iter().map(|(i, _)| i).collect(); (!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))) } -/// The editable ruler guides intersecting marquee rectangle `r`. -fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { +/// The editable ruler guides intersecting marquee rectangle `r`, with their `guide.list` indexes. +fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec<(usize, Guide)> { if !cx.guides { return vec![]; } cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g)) .filter(|(_, g)| { @@ -64,14 +72,13 @@ fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { r.min_y() <= g.pos && g.pos <= r.max_y() && cx.doc.guide_span(g).is_none_or(|(x0, x1)| r.max_x() >= x0 && r.min_x() <= x1) } }) - .map(|(i, _)| i) .collect() } /// Does ruler guide `g`, crossed by marquee `r`, run along its line within the marquee? An artboard /// guide does when the marquee spans its artboard; a canvas guide runs across the whole canvas and /// never does. -fn guide_inside(cx: &ToolContext, r: Rect, g: &vectorcraft_doc::Guide) -> bool { +fn guide_inside(cx: &ToolContext, r: Rect, g: &Guide) -> bool { let Some((a, b)) = cx.doc.guide_span(g) else { return false }; let (lo, hi) = if g.vertical { (r.min_y(), r.max_y()) } else { (r.min_x(), r.max_x()) }; lo <= a.min(b) && a.max(b) <= hi @@ -85,6 +92,8 @@ fn guide_cursor(vertical: bool) -> Cursor { #[derive(Clone, Copy, Debug)] struct Drag { index: usize, + /// The guide's node. + id: NodeId, vertical: bool, /// Where the guide was, and where it is now (x of a vertical guide, y of a horizontal one). from: f64, @@ -167,6 +176,8 @@ pub struct NewGuide { vertical: bool, /// The artboard (index) it belongs to: an artboard guide. artboard: Option, + /// Made only within its artboard ([`Self::within`]). + within: bool, snap: GuideSnap, /// The preview's orientation and position (none off the canvas), and the pointer. at: Option<(bool, f64)>, @@ -177,7 +188,24 @@ impl NewGuide { /// A vertical guide out of the left ruler, else a horizontal one out of the top ruler; an /// artboard guide of `artboard`, if any. pub fn new(cx: &ToolContext, vertical: bool, artboard: Option) -> Self { - Self { vertical, artboard, snap: GuideSnap::new(cx), at: None, pointer: Point::ZERO } + Self { vertical, artboard, within: false, snap: GuideSnap::new(cx), at: None, pointer: Point::ZERO } + } + + /// An artboard guide of `artboard` that shows, and is made, only where it lies within that + /// artboard: a vertical guide between its left and right edges, a horizontal one between its + /// top and bottom. With no such artboard, it never shows and nothing is made. + pub fn within(cx: &ToolContext, vertical: bool, artboard: usize) -> Self { + Self { within: true, ..Self::new(cx, vertical, Some(artboard)) } + } + + /// May the guide go at `at` (the x of a vertical guide, the y of a horizontal one)? + fn fits(&self, cx: &ToolContext, vertical: bool, at: f64) -> bool { + if !self.within { + return true; + } + let Some(r) = self.artboard.and_then(|i| cx.doc.artboards.get(i)).map(|a| a.rect) else { return false }; + let (lo, hi) = if vertical { (r.min_x(), r.max_x()) } else { (r.min_y(), r.max_y()) }; + lo <= at && at <= hi } /// The pointer at `ev`, over the canvas or not (`on_canvas`): the actions that show the guide @@ -185,9 +213,13 @@ impl NewGuide { pub fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent, on_canvas: bool) -> Vec { let mut out = vec![]; self.pointer = ev.pos; - if on_canvas { - let vertical = self.vertical != ev.mods.alt; - let at = self.snap.snap(cx, vertical, if vertical { ev.pos.x } else { ev.pos.y }, ev.mods.shift); + let place = on_canvas + .then(|| { + let vertical = self.vertical != ev.mods.alt; + (vertical, self.snap.snap(cx, vertical, if vertical { ev.pos.x } else { ev.pos.y }, ev.mods.shift)) + }) + .filter(|(vertical, at)| self.fits(cx, *vertical, *at)); + if let Some((vertical, at)) = place { if self.at.is_none() { out.push(Action::Begin("New Guide".into())); } @@ -239,11 +271,12 @@ impl GuideEdit { /// A press `ev` on a ruler guide: it gets selected and the drag starts. None (and nothing /// done) when no guide is there. pub fn press(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { - let index = guide_at(cx, ev.pos)?; - let g = cx.doc.guides.get(index)?; - let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&index)); + let (index, g) = guide_under(cx, ev.pos)?; + let id = g.id?; + let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&id)); self.drag = Some(Drag { index, + id, vertical: g.vertical, from: g.pos, at: g.pos, @@ -284,7 +317,7 @@ impl GuideEdit { vec![Action::Commit] } else if d.deselect { vec![Action::Exec("guide.select".into(), json!({ "indexes": [d.index], "toggle": true }))] - } else if d.key && cx.selection.key_guide == Some(d.index) { + } else if d.key && cx.selection.key_guide == Some(d.id) { // A click on the key guide again: no key. vec![Action::Exec("select.key".into(), json!({}))] } else if d.key { @@ -326,7 +359,7 @@ impl GuideEdit { if let Some(d) = &self.drag { return Some(guide_cursor(d.vertical).turned(cx.view_angle())); } - guide_at(cx, p).and_then(|i| cx.doc.guides.get(i)).map(|g| guide_cursor(g.vertical).turned(cx.view_angle())) + guide_under(cx, p).map(|(_, g)| guide_cursor(g.vertical).turned(cx.view_angle())) } /// While a guide is dragged: the smart guide it snapped to and its position by the pointer. diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs index 7fa192d9e..f80f31c77 100644 --- a/crates/tools/src/rulerguide/tests.rs +++ b/crates/tools/src/rulerguide/tests.rs @@ -10,10 +10,16 @@ fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { fn guides_doc() -> vectorcraft_doc::Document { let (mut d, _) = doc_with_rect(); - d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; + d.add_ruler_guide(&Guide::new(true, 300.0)); + d.add_ruler_guide(&Guide::new(false, 400.0)); d } +/// The node id of ruler guide `i` of `d` (its index in the guide commands). +fn gid(d: &vectorcraft_doc::Document, i: usize) -> vectorcraft_doc::NodeId { + d.ruler_guides()[i].id.unwrap() +} + #[test] fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { let (d, s, p) = (guides_doc(), Selection::default(), paint()); @@ -37,7 +43,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); // The tool sees the guide selected from now on. let mut s = s; - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let c = cx(&d, &s, &p); let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); assert_eq!(a[0], Action::Begin("Move Guide".into())); @@ -52,7 +58,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let drag_to = |c: &ToolContext, x: f64, mods: Mods| { let mut g = GuideEdit::default(); @@ -148,6 +154,36 @@ fn a_new_guide_is_made_on_release_over_the_canvas_only() { assert_eq!(g.pointer(&c, &ev(PointerKind::Up, -5.0, 40.0, m), false), vec![Action::Cancel]); } +/// A guide made only within its artboard ([`NewGuide::within`]) shows and is made where its +/// position, on the axis Alt gives it, lies within the artboard (edges included). Outside it, or +/// with an artboard the document doesn't have, it goes as it does off the canvas. +#[test] +fn a_guide_within_its_artboard_is_made_only_inside_it() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let m = Mods::default(); + let alt = Mods { alt: true, ..m }; + let preview = |vertical: bool, pos: f64| Action::Preview("guide.add".into(), json!({"vertical": vertical, "pos": pos, "artboard": 0})); + let mut g = NewGuide::within(&c, true, 0); + // Right of the 500 pt artboard: nothing, then its right edge. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 520.0, 40.0, m), true), vec![]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 500.0, 40.0, m), true), vec![Action::Begin("New Guide".into()), preview(true, 500.0)]); + // Alt makes it horizontal: y 40 is inside, y 600 is not. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 700.0, 40.0, alt), true), vec![preview(false, 40.0)]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 300.0, 600.0, alt), true), vec![Action::Cancel]); + assert!(g.overlays(&c).is_empty()); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 300.0, 600.0, alt), true), vec![]); + // Released inside: made. + let mut g = NewGuide::within(&c, false, 0); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 900.0, 0.0, m), true), vec![Action::Begin("New Guide".into()), preview(false, 0.0)]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 900.0, 0.0, m), true), vec![Action::Commit]); + // No artboard 3: never shown. + let mut g = NewGuide::within(&c, true, 3); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 100.0, 40.0, m), true), vec![]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 100.0, 40.0, m), true), vec![]); +} + #[test] fn new_ruler_guide_alt_switches_axis_and_uses_the_release_position() { let (d, _) = doc_with_rect(); @@ -223,7 +259,7 @@ fn new_ruler_guide_alt_snaps_on_the_effective_axis() { fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; let moved_to = |c: &ToolContext, x: f64| { @@ -259,8 +295,9 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); - d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 150.0) }); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); @@ -275,7 +312,7 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { fn shift_click_toggles_and_alt_drag_copies() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0, 1]); + s.set_guides([gid(&d, 0), gid(&d, 1)]); let p = paint(); let c = cx(&d, &s, &p); let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); @@ -305,20 +342,20 @@ fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { }; let mut s = Selection::default(); s.set([rect]); - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let make = Action::Exec("select.key".into(), json!({"guide": 0})); assert_eq!(click(&s, Mods::default()), vec![make]); - s.key_guide = Some(0); + s.key_guide = Some(gid(&d, 0)); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); let shift = Mods { shift: true, ..Default::default() }; assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); // Not selected yet: the first click selects it. s.set([rect]); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); // A drag moves it. - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let c = cx(&d, &s, &p); let mut g = GuideEdit::default(); g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); @@ -331,7 +368,7 @@ fn dropped_on_its_ruler_the_guide_is_deleted() { let d = guides_doc(); let p = paint(); let mut s = Selection::default(); - s.set_guides([1]); + s.set_guides([gid(&d, 1)]); // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; @@ -426,7 +463,7 @@ fn ruler_guide_shift_move_uses_document_origin() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for copy in [false, true] { let mut guide = GuideEdit::default(); @@ -467,7 +504,7 @@ fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { let paint = paint(); for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let mods = Mods { shift: true, ..Default::default() }; let mut new = NewGuide::new(&c, vertical, None); @@ -491,7 +528,7 @@ fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for (c, pointer, expected) in [ (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), @@ -530,7 +567,7 @@ fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); let check = |overlays: Vec| { @@ -574,6 +611,7 @@ fn ruler_guide_readouts_count_from_the_active_artboard_with_artboard_rulers() { show_video_safe_areas: false, video_ruler_pixel_aspect_ratio: 1.0, color: None, + guide_label: None, }); doc.ruler_origin = Point::new(5.0, 5.0); let (paint, selection) = (paint(), Selection::default()); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fe666bd67..8dbdb461e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -368,6 +368,14 @@ impl MoveSnap { if q != start + d { d = q - start; guides = ov; + } else if guides.is_empty() + && let Some(b) = self.bounds + { + // Nothing else lined up: the bounds slide along the line onto a ruler guide, as + // they do without Shift (#4312). + let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.tol(cx.snap_tolerance)); + d += adj; + guides = ov; } } if cx.snap_to_pixel diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 2b1151742..9069cda93 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -502,7 +502,7 @@ fn click_selects_object() { #[test] fn a_press_on_a_ruler_guide_picks_it_over_the_art() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -524,7 +524,7 @@ fn a_press_on_a_ruler_guide_picks_it_over_the_art() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let s = Selection::default(); @@ -679,7 +679,7 @@ fn box_cursors_turn_with_the_view() { let mut s = Selection::default(); s.add(id); let p = paint(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let mut c = cx(&d, &s, &p); c.screen = turned_view(90.0); assert!((c.view_angle() - 90.0).abs() < 1e-9); @@ -752,7 +752,7 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() { #[test] fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 173.4)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -770,6 +770,30 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0); } +/// #4312: a Shift move keeps to its line while its edges and centre land on a ruler guide, as a +/// move without Shift does (#5746). +#[test] +fn a_shift_move_lands_the_edges_on_a_ruler_guide() { + let (mut d, id) = doc_with_rect(); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 333.0)); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + // Level; the left edge (100) would end 2 short of the guide, the pointer far from it. + let (m, _) = shift_moved(&c, (150.0 + 231.0, 150.0)); + assert!(moved_by(m, (233.0, 0.0)), "{m:?}"); + // The right edge, 1 past the guide. + let (m, _) = shift_moved(&c, (150.0 + 134.0, 150.0)); + assert!(moved_by(m, (133.0, 0.0)), "{m:?}"); + // Out of reach, or with Snap to Point off, the move stays as dragged. + let (m, _) = shift_moved(&c, (150.0 + 221.0, 150.0)); + assert!(moved_by(m, (221.0, 0.0)), "{m:?}"); + let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }; + let (m, _) = shift_moved(&off, (150.0 + 231.0, 150.0)); + assert!(moved_by(m, (231.0, 0.0)), "{m:?}"); +} + /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as /// gathered before the previews moved it); released, the move doesn't. #[test] @@ -1094,7 +1118,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 151.5, 350.0, 201.5)); d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 420.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 420.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -1108,7 +1132,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { assert!(moved_by(m, (270.5, 270.5)), "{m:?}"); // Smart Guides on, the selection alone with a ruler guide 1.5 below the grabbed point. let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(false, 330.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 330.5)); let mut s = Selection::default(); s.add(id); let (m, _) = shift_moved(&cx(&d, &s, &p), (329.0, 329.0)); @@ -1235,7 +1259,7 @@ fn a_click_on_a_selected_object_makes_it_the_key() { let mut one = Selection::default(); one.add(rect); assert!(click(&one, 150.0, 150.0).is_empty()); - one.guides = vec![0]; + one.guides = vec![vectorcraft_doc::NodeId(999)]; assert_eq!(click(&one, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); } @@ -1458,8 +1482,8 @@ fn directional_marquee_uses_screen_direction_and_a_dead_zone() { fn e_during_a_marquee_takes_only_the_guides_inside() { let (mut d, id) = doc_with_rect(); let board = d.artboards[0].id; - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); - d.guides.push(vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1482,7 +1506,7 @@ fn e_during_a_marquee_takes_only_the_guides_inside() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1499,7 +1523,7 @@ fn marquee_selects_ruler_guides() { #[test] fn marquee_selects_only_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 20.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 20.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1516,7 +1540,7 @@ fn marquee_selects_only_guide() { #[test] fn marquee_ignores_locked_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let mut cx_locked = cx(&d, &s, &p); @@ -1745,7 +1769,7 @@ fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0)); d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 230.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -2412,7 +2436,7 @@ fn a_thin_shapes_centre_point_gives_the_handles_the_pointer() { #[test] fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { let (mut d, id) = doc_with_live_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -2426,3 +2450,30 @@ fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { let drag = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "guide.move")), "the guide drags: {down:?} {drag:?}"); } + +/// #6437: in Outline view, where an image is drawn as its frame, a click inside it on the edge of +/// a path behind selects that path, with the image selected or not. In Preview the image takes +/// the click. +#[test] +fn outline_view_click_through_an_image_selects_the_path_behind() { + use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeKind}; + let mut d = Document::new(500.0, 500.0); + let layer = d.layers[0].id; + let (back, image) = (d.alloc_id(), d.alloc_id()); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)); + d.insert(Some(layer), 0, Node::path(back, square, Appearance::default_art())).unwrap(); + let xf = vectorcraft_geom::Affine::translate((150.0, 150.0)); + let im = ImageObject { key: "k".into(), width: 100, height: 100, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(layer), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); + let selects = |a: &[Action], id: NodeId| a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0]))); + let p = paint(); + for picked in [vec![], vec![image]] { + let mut s = Selection::default(); + s.set(picked.clone()); + let outline = ToolContext { outline: true, ..cx(&d, &s, &p) }; + let a = SelectionTool::default().pointer(&outline, &ev(PointerKind::Down, 200.0, 180.0)); + assert!(selects(&a, back), "outline, {picked:?} selected: {a:?}"); + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 200.0, 180.0)); + assert!(!selects(&a, back), "preview, {picked:?} selected: {a:?}"); + } +} diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 8b092d8ad..69bbf9051 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -41,9 +41,9 @@ fn has_area(r: Rect) -> bool { fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) { let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]); let tol = cx.tol(5.0); + let guides = cx.doc.ruler_guides(); let nearest = |vertical: bool, v: f64| { - cx.doc - .guides + guides .iter() .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol)) .min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs())) @@ -390,7 +390,7 @@ mod tests { #[test] fn the_slice_tool_drags_out_a_slice_in_one_undo_step() { let (mut d, _) = doc_with_rect(); - d.guides.push(Guide::new(true, 403.0)); + d.add_ruler_guide(&Guide::new(true, 403.0)); let s = Selection::default(); let p = paint(); let mut c = cx(&d, &s, &p); @@ -557,7 +557,7 @@ mod tests { assert!(near(resize(&neither, (60.0, 25.0), (97.0, 25.0), none), Rect::new(10.0, 10.0, 97.0, 40.0))); // A ruler guide at x = 98 takes the side dragged 1 px from it, over the rectangle's edge // 1 px the other way, with Smart Guides on or off. - d.guides.push(Guide::new(true, 98.0)); + d.add_ruler_guide(&Guide::new(true, 98.0)); for c in [cx(&d, &sel, &p), ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &sel, &p) }] { assert!(near(resize(&c, (60.0, 25.0), (99.0, 25.0), none), Rect::new(10.0, 10.0, 98.0, 40.0))); } diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index e3a009056..cccb1ebca 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -281,7 +281,7 @@ mod tests { #[test] fn shift_drag_meets_a_ruler_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 50.0)); let s = Selection::default(); let p = paint(); let mut cx = cx(&d, &s, &p); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 81c601c1c..7b69fbfb8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -88,6 +88,7 @@ impl VectorcraftApp { selection_box: None, selection_bounds: None, content_bounds: None, + plugin_keys: Default::default(), cursors: Default::default(), view_moved: None, hover_cursor: None, @@ -126,6 +127,7 @@ impl VectorcraftApp { updates_rt: Default::default(), frame_rate: Default::default(), canvas_rect: None, + control_rotate_press: None, hover_doc: None, titlebar_from_env: false, custom_titlebar: false, @@ -339,8 +341,13 @@ impl VectorcraftApp { screen_color::poll(self, ctx); self.poll_finished_work(); crate::script_runs::poll(self); - if self.session.script_running() && !self.session.script_waiting() { - ctx.request_repaint(); + crate::scripts_menu::poll(self, ctx); + if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() { + // A script in `$.sleep` goes on when its time is up; until then the app can idle. + match self.session.script_sleep_left() { + Some(left) => ctx.request_repaint_after(left), + None => ctx.request_repaint(), + } } #[cfg(not(target_arch = "wasm32"))] self.tick_link_updates(ctx, now); @@ -368,9 +375,7 @@ impl VectorcraftApp { unsaved::finish_pending(self); if self.session.script_running() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - if self.ui.dialog.is_none() { - self.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, serde_json::json!({}))); - } + dialogs::script_run::ask_to_close(self); } else if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.status(picks::busy()); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index d5ca8a196..d97d11181 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -73,6 +73,7 @@ fn substitute_warnings_follow_current_font_inventory() { impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { + let touch_workspace = self.session.prefs.touch_workspace; // A file dialog it shows off the UI thread runs it again with the path picked. let result = if self.picks.is_entry_free() { let entry = picks::Entry::Command(id.to_string(), params.clone()); @@ -80,6 +81,9 @@ impl VectorcraftApp { } else { self.run_now(id, params) }; + if id == "prefs.import" && result.is_ok() { + crate::prefs_dialog::imported(self, touch_workspace); + } placing::settle_swatch_paste(self); result } @@ -404,6 +408,10 @@ impl VectorcraftApp { if let Err(e) = switched { self.ui.status = e.to_string(); } + if before != self.session.tool_id() { + // A control gesture cannot resume after another tool has owned the pointer. + self.control_rotate_press = None; + } // A newly chosen Artboard tool starts on the active artboard (the view's). if before != "artboard" && self.session.tool_id() == "artboard" diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 6aa3eab76..f88367cb6 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -117,6 +117,7 @@ impl VectorcraftApp { smart_guides: self.ui.view.smart_guides, guides: self.ui.view.guides, snap_to_grid: self.ui.view.snap_to_grid, + pixel_preview: self.ui.view.pixel_preview, snap_to_pixel: self.ui.view.snap_to_pixel, // View › Hide Edges: the canvas draws neither the bounding box (with the type widget) // nor the Live Corners widgets, and the tools do not answer the pointer on them. diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 1e69fc57d..38b790ecc 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -58,6 +58,9 @@ pub struct CanvasCache { pub selection_bounds: Option<((u64, u64), Option)>, /// [`VectorcraftApp::content_bounds`] for (document uid, revision). pub content_bounds: Option<((u64, u64), Option)>, + /// [`CacheKey::plugins`] by document uid, with the plug-in revision + /// ([`vectorcraft_render::effects::plugin_revision`]) it was last checked at. + pub plugin_keys: std::collections::HashMap, /// The tools' cursors as OS cursor bitmaps. pub cursors: cursors::Images, /// The view the canvas last drew and when it last changed (egui's input time): while it moves, @@ -119,6 +122,11 @@ pub struct CacheKey { pub highlight_substituted_fonts: bool, /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. pub placed: u64, + /// The plug-in revision ([`vectorcraft_render::effects::plugin_revision`]) of the last plug-in + /// install or removal, or of the last time deferred plug-in effect stages gave results, after + /// which the document drew a plug-in effect ([`vectorcraft_render::draws_plugin_effects`]): a + /// frame drawn before it draws those effects as the plug-ins installed then did. 0 until then. + pub plugins: u64, /// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown /// with hard edges. None: the art is rendered for the screen. pub pixel: Option<[i64; 4]>, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a784941eb..50136f1a9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -77,6 +77,7 @@ use tool_overlays::{ print_tiling_overlay, rotate_view_dial, shaper_widget_overlay, slice_overlay, spelling_overlay, thread_overlays, }; pub(crate) use view::animated_zoom; +pub(crate) use view::drag_rotation; pub use view::fit; use view::scroll_bars; #[cfg(test)] @@ -292,6 +293,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); + let effects = st.plugin_effects.clone(); let mask_view = st.shown_mask(); paint_paper(app, st, ui, &painter, &xf, rect, (&t, app.outline_view())); @@ -315,7 +317,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; - let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images)); + let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); + let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images, plugins)); // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); @@ -347,8 +350,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) { let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); let opts = frame_options(app, &key, mask_view, smooth_images); - draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel }); + draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel }); } + report_deferred(app, &effects); // The status bar reports each image that a renderer (this thread's or the worker's) could not // decode, once per image in the process. let undecoded = vectorcraft_render::newly_undecodable(doc.images.values()); @@ -564,7 +568,7 @@ fn cache_key( t: &Tokens, v: &View, (w, h, ppp): (u32, u32, f32), - (pixel, smooth_images): (Option<[i64; 4]>, bool), + (pixel, smooth_images, plugins): (Option<[i64; 4]>, bool, u64), ) -> CacheKey { // Paths made into guides are drawn as the ruler guides are (Preferences › Guides & Grid). let guide_look = LineLook::guides(&app.session.prefs, t); @@ -589,6 +593,7 @@ fn cache_key( highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts, placed: vectorcraft_render::placed_document::generation(), + plugins, pixel, smooth_images, isolated: st.isolation.map(|id| id.0), @@ -601,6 +606,39 @@ fn cache_key( } } +/// [`CacheKey::plugins`] of document `st`, one of the documents open in `session`. `keys` holds each +/// document's value and the plug-in revision it was checked at, so a document is looked through only +/// once after each change of the plug-in revision. +fn plugin_key( + keys: &mut std::collections::HashMap, + session: &vectorcraft_engine::Session, + st: &vectorcraft_engine::DocState, +) -> u64 { + let revision = vectorcraft_render::effects::plugin_revision(); + match keys.get(&st.uid) { + Some(&(checked, key)) if checked == revision => key, + seen => { + let key = if vectorcraft_render::draws_plugin_effects(&st.doc) { revision } else { seen.map_or(0, |&(_, key)| key) }; + keys.retain(|uid, _| session.documents().iter().any(|d| d.uid == *uid)); + keys.insert(st.uid, (revision, key)); + key + } + } +} + +/// Once the first view of a document has been drawn, the status bar says how many plug-in effect +/// stages it left deferred, if any. +fn report_deferred(app: &mut VectorcraftApp, effects: &vectorcraft_plugins::deferred::Deferrals) { + if let Some(n) = effects.take_report().filter(|n| *n > 0) { + app.status(crate::i18n::msg_n( + n as u64, + "Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.", + "Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them.", + &[], + )); + } +} + /// Start the render worker, and upload the frame it finished. fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) { if !app.canvas.worker_started { @@ -624,6 +662,9 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate: return; } upload(app, ctx, done.img, done.key.pixel.is_some()); + if let Some(d) = app.session.documents().iter().find(|d| d.uid as usize == done.key.doc) { + d.plugin_effects.first_view_done(); + } app.canvas.key = Some(done.key); app.canvas.shown = Some((done.doc, done.opts)); app.perf.render_ms = done.ms; @@ -706,6 +747,8 @@ fn frame_options( struct Frame<'a> { key: &'a CacheKey, doc: &'a std::sync::Arc, + /// The document's deferred plug-in effect stages: the frame renders inside their view. + effects: &'a vectorcraft_plugins::deferred::Deferrals, size: (u32, u32), view: Affine, opts: vectorcraft_render::RenderOptions, @@ -717,7 +760,7 @@ struct Frame<'a> { pub(crate) const FIRST_FRAME_GRACE: std::time::Duration = std::time::Duration::from_millis(16); fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { - let Frame { key, doc, size: (w, h), view, opts, pixel } = frame; + let Frame { key, doc, effects, size: (w, h), view, opts, pixel } = frame; // Frames that would be slow on this thread (the last one took over 8 ms) go // to the worker. An interaction's frames stay on the worker once it drew the frame shown: // its renderer keeps the rasters of the art's effects that make those frames fast, and this @@ -745,7 +788,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { app.canvas.renderer.let_go_of_measured(); } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { - let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let renderer = &mut app.canvas.renderer; + let part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); let color = color_image(&part); let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; tex.set_partial([x0 as usize, y0 as usize], color, options); @@ -767,6 +811,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { view, opts, scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects: effects.clone(), may_copy, may_keep: true, }); @@ -778,7 +823,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } _ => { let t0 = now_ms(); - let img = app.canvas.renderer.render(doc, w, h, view, &opts); + let renderer = &mut app.canvas.renderer; + let img = effects.view(|| renderer.render(doc, w, h, view, &opts)); + effects.first_view_done(); upload(app, ui.ctx(), img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.canvas.shown = Some((doc.clone(), opts.clone())); @@ -1064,16 +1111,20 @@ fn guide_lines(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egu } } -/// The ruler guides of document `st`, its selected guides in the selection colour. +/// The ruler guides of document `st`, its selected guides in the selection colour. An artboard +/// guide of an artboard with a guide label ([`vectorcraft_doc::Artboard::guide_label`]) is drawn in +/// the label's color, in the guide preference's style; the others in the guide preference's color. fn guides_of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, (painter, xf, t): (&egui::Painter, &Xf, &Tokens), rect: egui::Rect) { let doc = &st.doc; let look = LineLook::guides(&app.session.prefs, t); let picked = LineLook { color: t.selection, ..look }; - let selected: &[usize] = &st.selection.guides; + let labels = guide_labels(doc); + let selected: &[vectorcraft_doc::NodeId] = &st.selection.guides; let key = st.selection.key_guide; let view = view_bounds(xf); // Guides on a hidden layer hide with it. - for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { + for g in doc.ruler_guides().iter().filter(|g| doc.guide_shown(g)) { + let Some(i) = g.id else { continue }; let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); // An artboard guide runs across its artboard, a canvas guide across the window (the part // of its document line that the canvas shows, turned with the view). @@ -1085,7 +1136,8 @@ fn guides_of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, (painter, ends } }; - if selected.contains(&i) { picked } else { look }.guide(painter, a, b); + let own = g.artboard.and_then(|id| labels.get(&id)).map_or(look, |&color| LineLook { color, ..look }); + if selected.contains(&i) { picked } else { own }.guide(painter, a, b); // The key guide (Align to Key Object, #2143): thicker, as a key object's outline is. if key == Some(i) { painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color)); @@ -1093,6 +1145,17 @@ fn guides_of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, (painter, } } +/// The guide label colors of `doc`'s artboards that have one, by artboard id. +pub(super) fn guide_labels(doc: &vectorcraft_doc::Document) -> std::collections::HashMap { + doc.artboards + .iter() + .filter_map(|a| { + let [r, g, b] = a.guide_label?.rgb(); + Some((a.id, Color32::from_rgb(r, g, b))) + }) + .collect() +} + /// The selection's visuals and the tools' overlays, to be drawn. fn selection_visuals( app: &mut VectorcraftApp, diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs index 60d8b0075..8eda1db21 100644 --- a/crates/ui-egui/src/canvas/fill.rs +++ b/crates/ui-egui/src/canvas/fill.rs @@ -95,6 +95,7 @@ pub(super) fn fill_uncovered( let (_, _, view) = fill_view(&done.key); app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex }); } + let effects = app.session.documents().iter().find(|d| d.uid as usize == key.doc).map(|d| d.plugin_effects.clone()).unwrap_or_default(); let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return }; if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) { let (w, h, view) = fill_view(key); @@ -106,6 +107,7 @@ pub(super) fn fill_uncovered( view, opts, scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects, may_copy: false, may_keep: false, }); diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 09749fc12..7f000ee78 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -178,7 +178,11 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { /// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's /// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left /// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else, -/// it makes none. The corner drags the rulers' origin or resets it on a double-click. +/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the +/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the +/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the +/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a +/// double-click. pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option { let [top, left, corner] = ruler_rects(full); // Right-click on a ruler or the origin box: the document units, to swap between them as @@ -189,6 +193,40 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c // (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it. let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG); resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); + // egui counts a double-click by position and time alone, so a click on the canvas right + // at the ruler and one on the ruler would pass for one: the pair counts only when the + // ruler's own click before it came within the double-click delay. + let last_click = egui::Id::unique(if vertical { "ruler-left-last-click" } else { "ruler-top-last-click" }); + let now = ui.input(|i| i.time); + let first_here = + ui.data(|d| d.get_temp::(last_click)).is_some_and(|t| now - t <= ui.ctx().options(|o| o.input_options.max_double_click_delay)); + if resp.clicked_by(egui::PointerButton::Primary) { + ui.data_mut(|d| d.insert_temp(last_click, now)); + } + if resp.double_clicked_by(egui::PointerButton::Primary) + && first_here + && let Some(p) = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())) + { + // The same document point the drag path reads. A ruler reads the canvas's coordinates + // turned with the view, so the guide is the one whose line crosses the ruler's axis: + // the vertical one when that axis (the screen's x for the top ruler, y for the left, + // in document directions) is nearer the document's x, else the horizontal one. At 0° + // that is a vertical guide from the top ruler; at 90° a horizontal one; between, the + // nearer axis, whose line still runs through the point and never along the ruler. + let at = xf.to_doc(p); + let (sn, cs) = xf.rot.sin_cos(); + let axis = if vertical { (sn, cs) } else { (cs, -sn) }; + let across = axis.0.abs() >= axis.1.abs(); + let mut params = json!({"vertical": across, "pos": if across { at.x } else { at.y }}); + if let Some(ab) = app.session.ruler_guide_artboard() { + params["artboard"] = json!(ab); + } + // The new guide shows even if guides were hidden. + app.ui.view.guides = true; + if let Err(e) = app.run("guide.add", params) { + app.status(e.to_string()); + } + } let kind = if resp.drag_stopped() { PointerKind::Up } else if resp.dragged() { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5fbc2b903..43e5df0bc 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -337,14 +337,8 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) } } Drag::RotateView { start_angle, start_rot, snaps } => { - let c = rect.center(); - let a = (p.y - c.y).atan2(p.x - c.x) as f64; - let mut deg = start_rot + (a - start_angle).to_degrees(); - if snaps && m.shift { - deg = (deg / 15.0).round() * 15.0; - } if let Some(vm) = app.view_mut() { - vm.rotation = vectorcraft_geom::normalize_deg(deg); + vm.rotation = super::view::drag_rotation(rect.center(), p, start_angle, start_rot, snaps && m.shift); } } Drag::ZoomBox { start } => { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index fb9872a77..fcda53def 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -106,6 +106,7 @@ pub(super) fn mark_options(app: &VectorcraftApp, curve_points_from_tool: bool) - } } +/// Draw selection marks and widgets, keeping the box but hiding dot marks during Halftone Preview. pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool, marks: &mut SelectionMarks) { // The Selection tool's bounding box (rotated with the objects after a rotation), also shown by // the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It @@ -140,7 +141,8 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: None => b, }); let st = app.session.active(); - let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); + let show_marks = !crate::dialogs::halftone::previewing(app); + let big = show_marks && st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); let big_bounds = if big { app.selection_bounds() } else { None }; let app = &*app; let Some(st) = app.session.active() else { return }; @@ -152,24 +154,27 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: let options = mark_options(app, curve_points_from_tool); let grow = options.look.grow; let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 }; - // Too many paths to outline: the selection's bounds stand for them. - if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { - stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); + // The temporary halftone selects its dots; their marks obscure the art (#6369). + if show_marks { + // Too many paths to outline: the selection's bounds stand for them. + if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { + stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); + } + // The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has + // too many paths to outline. + let key = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))); + let (key_bounds, key_outline) = match key { + Some((k, n)) if too_many_anchors([n]) => (Some((k, n)), None), + Some((k, _)) => (None, Some(k)), + None => (None, None), + }; + if let Some((k, n)) = key_bounds + && let Some(b) = n.geometric_bounds_with(&symbols) + { + stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); + } + marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects }); } - // The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has - // too many paths to outline. - let key = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))); - let (key_bounds, key_outline) = match key { - Some((k, n)) if too_many_anchors([n]) => (Some((k, n)), None), - Some((k, _)) => (None, Some(k)), - None => (None, None), - }; - if let Some((k, n)) = key_bounds - && let Some(b) = n.geometric_bounds_with(&symbols) - { - stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); - } - marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects }); // The spine of each selected blend (or of the blend a selected key object belongs to). let mut spines = vec![]; for id in &st.selection.objects { diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 13b56e6f1..c8dc00753 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -12,8 +12,8 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key, - dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, pos2, rulers, shows_artboards, textured_quad, vec2, - view_rects, + dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, plugin_key, pos2, rulers, shows_artboards, + textured_quad, vec2, view_rects, }; use crate::doc_windows::Win; use crate::thumb_worker::ThumbWorker; @@ -303,7 +303,8 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) // View › Pixel Preview, as the canvas draws it. let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; - let mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images)); + let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); + let mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images, plugins)); // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index b5f8dac66..eb038f079 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -151,7 +151,14 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { match kind { TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), - TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), + TextKind::OnPath { .. } => { + items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())); + // Dynamic Text turns on or off, as the Type menu's item does; left out for type in a thread + // (). + if crate::menus::enabled(app, "type.dynamicText") { + items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); + } + } } } else if is_image { // Offset Path and Simplify don't apply to an image. @@ -233,6 +240,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { for (label, icon, cmd, params) in &items { let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 047ee9fa7..efe59c127 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1192,7 +1192,7 @@ fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // From the top ruler: a horizontal guide where the button was released. let to = pos2(rect.center().x, rect.top() + 120.0); drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to); @@ -1208,6 +1208,73 @@ fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing"); } +/// An artboard's guide label colors its own guides only: a canvas guide, a guide of an artboard +/// without a label and a guide of a deleted artboard keep the guide color preference, and a +/// selected labeled guide takes the selection color. Hide Guides hides labeled guides too. +#[test] +fn artboard_guide_label_colors_only_its_own_guides() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("artboard.new", &json!({})).unwrap(); + app.session.execute("artboard.new", &json!({})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 0, "guideLabel": "Red"})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 2, "guideLabel": "#00ff00"})).unwrap(); + // Vertical guides at x 100 (artboard 1, Red), 520 (artboard 2, no label) and 940 (artboard + // 3, whose artboard is then deleted), and a horizontal canvas guide at y 150. + for (pos, artboard) in [(100, json!(0)), (520, json!(1)), (940, json!(2))] { + app.session.execute("guide.add", &json!({"vertical": true, "pos": pos, "artboard": artboard})).unwrap(); + } + app.session.execute("guide.add", &json!({"vertical": false, "pos": 150})).unwrap(); + // Deleting artboard 3 deletes its guide; a guide still naming its id (as a file might) is an + // orphan, drawn across the canvas. + let gone = app.session.active().unwrap().doc.artboards[2].id; + app.session.execute("artboard.setProps", &json!({"index": 2, "guideLabel": null})).unwrap(); + app.session.execute("artboard.delete", &json!({"index": 2})).unwrap(); + app.session.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + { + let st = app.session.active_mut().unwrap(); + let doc = std::sync::Arc::make_mut(&mut st.doc); + let last = doc.ruler_guides().last().and_then(|g| g.id).unwrap(); + assert!(doc.edit_ruler_guide(last, |g| g.artboard = Some(gone))); + } + app.ui.view.guides = true; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let t = Tokens::get(&ctx); + let pref = LineLook::guides(&app.session.prefs, &t).color; + let colors = |app: &mut VectorcraftApp, at: f64, vertical: bool| -> Vec { + shapes(app, &ctx) + .iter() + .filter_map(|s| match s { + Shape::LineSegment { points, stroke } => { + let (a, b) = (xf.to_doc(points[0]), xf.to_doc(points[1])); + let (u, v) = if vertical { (a.x, b.x) } else { (a.y, b.y) }; + ((u - at).abs() < 0.01 && (v - at).abs() < 0.01).then_some(stroke.color) + } + _ => None, + }) + .collect() + }; + let red = Color32::from_rgb(0xff, 0x4f, 0x4f); + assert_eq!(colors(&mut app, 100.0, true), [red], "the labeled artboard's guide"); + assert_eq!(colors(&mut app, 520.0, true), [pref], "an artboard without a label"); + assert_eq!(colors(&mut app, 150.0, false), [pref], "a canvas guide"); + assert!(colors(&mut app, 940.0, true).is_empty(), "deleted with its artboard"); + assert_eq!(colors(&mut app, 300.0, true), [pref], "a guide whose artboard is gone"); + assert_ne!(red, pref); + // Selected, it is drawn in the selection color; deselected, in the label's again. + app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + assert_eq!(colors(&mut app, 100.0, true), [t.selection], "selected"); + app.session.execute("guide.select", &json!({"indexes": []})).unwrap(); + assert_eq!(colors(&mut app, 100.0, true), [red], "deselected"); + // A new label shows on the next frame. + app.session.execute("artboard.setProps", &json!({"index": 0, "guideLabel": "Black"})).unwrap(); + assert_eq!(colors(&mut app, 100.0, true), [Color32::BLACK]); + app.ui.view.guides = false; + assert!(colors(&mut app, 100.0, true).is_empty(), "Hide Guides hides it"); +} + /// #4646: a guide dragged out of a ruler shows as a line across the canvas while it is dragged, /// also with Preferences › Performance › Real-time Drawing and Editing off (only its position /// label showed then). @@ -1237,7 +1304,7 @@ fn a_guide_dragged_out_of_a_ruler_shows_while_dragged() { }; assert!(shapes(&mut app, &ctx).iter().any(across), "real-time drawing {real_time}: the guide shows while dragged"); frame(&mut app, &ctx, vec![button(at, false)]); - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1, "real-time drawing {real_time}: released, it is made"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1, "real-time drawing {real_time}: released, it is made"); } } @@ -1316,7 +1383,7 @@ fn new_ruler_guides_follow_alt_through_preview_release_and_undo() { frame(&mut app, &ctx, vec![button(from, true, initially_alt)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, source_vertical != alt); let expected = if source_vertical != alt { pos.x } else { pos.y }; @@ -1396,7 +1463,7 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, !source_vertical); let artboard = (tool == "artboard").then_some(before.artboards[0].id); @@ -1407,7 +1474,53 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { assert_eq!(history(&app), before_history); app.session.execute("edit.redo", &json!({})).unwrap(); assert!(app.session.active().unwrap().doc.art_bounds().is_some()); - assert!(app.session.active().unwrap().doc.guides.is_empty()); + assert!(app.session.active().unwrap().doc.ruler_guides().is_empty()); + } + } +} + +/// With Preferences › Guides & Grid › Artboard Guides with Cmd/Ctrl + Ruler Drag on, Cmd/Ctrl held +/// when a drag leaves either ruler makes a guide of the active artboard, even when the key is let +/// go before the release; a guide outside the artboard is not made. With the preference off, the +/// same drag makes a canvas guide. +#[test] +fn cmd_ruler_drags_make_artboard_guides_with_the_preference_on() { + use egui::{Event, Modifiers}; + for source_vertical in [false, true] { + for on in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "cmdRulerDragArtboardGuides", "value": on})).unwrap(); + app.ui.view.rulers = true; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let from = + if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; + let drag = |app: &mut VectorcraftApp, to: Point| { + let at = xf.to_screen(to); + assert!(rect.contains(at)); + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame(app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), Event::PointerMoved(from)]); + frame(app, &ctx, vec![button(from, true, Modifiers::COMMAND)]); + frame(app, &ctx, vec![Event::PointerMoved(at)]); + frame(app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(at), button(at, false, Modifiers::NONE)]); + }; + let board = app.session.active().unwrap().doc.artboards[0].id; + drag(&mut app, Point::new(80.0, 120.0)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.ruler_guides().len(), 1, "vertical {source_vertical}, on {on}"); + assert_eq!(st.doc.ruler_guides()[0].vertical, source_vertical); + assert_eq!(st.doc.ruler_guides()[0].artboard, on.then_some(board), "vertical {source_vertical}, on {on}"); + // Beyond the artboard's right or bottom edge. + let beyond = xf.to_doc(rect.right_bottom() - egui::vec2(2.0, 2.0)); + assert!(beyond.x > 400.0 && beyond.y > 300.0, "{beyond:?}"); + drag(&mut app, beyond); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), if on { 1 } else { 2 }, "vertical {source_vertical}, on {on}"); } } } @@ -1434,7 +1547,7 @@ fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // 3 px off the middle of the top and bottom sides: onto the line through them. let mid = xf.to_screen(Point::new(200.0, 150.0)); drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0)); @@ -1483,7 +1596,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // Two guides out of the rulers. let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y)); drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h); @@ -1494,7 +1607,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { drag(&mut app, h, to); let st = app.session.active().unwrap(); assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]); - assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![])); + assert_eq!((vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), st.selection.objects.clone()), (vec![0], vec![])); assert_eq!(st.history.undo.last().unwrap().label, "Move Guide"); let picked = Tokens::get(&ctx).selection; let s = shapes(&mut app, &ctx); @@ -1567,6 +1680,73 @@ fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() { assert_eq!(cmd_drag(&mut app, inside, inside), "selection"); } +/// #6369: only the selection box remains over the temporary halftone, and the original +/// selection's marks come back when Preview is turned off or the dialog is cancelled. +#[test] +fn halftone_preview_hides_dot_outlines_and_keeps_selection_bounds() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.ui.view.bounding_box = true; + let original = app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let original = vectorcraft_doc::NodeId(original); + app.run("paint.setFill", json!({"color": "#000000"})).unwrap(); + let canvas = egui::Context::default(); + let dialog = egui::Context::default(); + let dialog_frame = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = dialog.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let has_interior_marks = |app: &mut VectorcraftApp| { + frame(app, &canvas, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let corners = app.selection_box().unwrap().corners().map(|p| xf.to_screen(p)); + let inside = egui::Rect::from_points(&corners).shrink(10.0); + let st = app.session.active().unwrap(); + let color = c32(st.doc.layer_color(st.selection.objects[0])); + let points = drawn_in(app, &canvas, color, 1.0); + assert!(corners.iter().all(|p| points.contains(p)), "the outer selection box is still drawn"); + points.iter().any(|p| inside.contains(*p)) + }; + assert!(has_interior_marks(&mut app), "the selected ellipse shows its outline"); + + app.run("ui.menuDialog", json!({"command": "object.vectorHalftone"})).unwrap(); + dialog_frame(&mut app); + assert!(app.session.in_interaction()); + assert_ne!(app.session.active().unwrap().selection.objects, vec![original], "the halftone is previewed and selected"); + assert!(!has_interior_marks(&mut app), "selection marks cover the halftone dots"); + + let set_preview = |app: &mut VectorcraftApp, on| { + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(on)); + dialog_frame(app); + }; + set_preview(&mut app, false); + assert_eq!(app.session.active().unwrap().selection.objects, vec![original]); + assert!(has_interior_marks(&mut app), "turning Preview off restores the outline"); + + set_preview(&mut app, true); + assert!(!has_interior_marks(&mut app), "turning Preview back on hides the dot marks again"); + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.active().unwrap().selection.objects, vec![original]); + assert!(has_interior_marks(&mut app), "Cancel restores the outline"); + + app.run("ui.menuDialog", json!({"command": "object.move"})).unwrap(); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + dialog_frame(&mut app); + assert!(app.session.in_interaction()); + assert!(has_interior_marks(&mut app), "another dialog's preview keeps selection marks"); + crate::dialogs::cancel(&mut app); + + app.run("ui.menuDialog", json!({"command": "object.vectorHalftone"})).unwrap(); + dialog_frame(&mut app); + assert!(!has_interior_marks(&mut app)); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert!(has_interior_marks(&mut app), "OK restores ordinary selection marks on the applied dots"); +} + /// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds, /// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one /// still shows its outline and anchors. @@ -2215,6 +2395,52 @@ fn undecodable_images_are_marked_and_reported_once() { assert_eq!(app.ui.status, "", "said once"); } +/// The plug-in effects a document's first view leaves out past its budget are reported in the +/// status bar once, in the singular or the plural, and Effect › Refresh Deferred Effects is +/// enabled until it has run them. +#[test] +fn the_first_view_reports_the_plug_in_effects_it_deferred() { + use std::sync::atomic::{AtomicU64, Ordering}; + use vectorcraft_plugins::wat as tpl; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.canvas.worker_started = true; // render on this thread + let module = tpl::module(&tpl::manifest("test.ui.deferred", "effect", "{}"), tpl::ECHO, "", 1); + app.session.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(module).unwrap())})).unwrap(); + // Each reading of the clock is 3 s later: the first run's result comes back after the budget, + // and no later run starts. + let step = std::sync::Arc::new(AtomicU64::new(3_000)); + let ctx = egui::Context::default(); + for (count, said) in [ + (2, "Opened with 2 plug-in effects deferred. Effect › Refresh Deferred Effects runs them."), + (1, "Opened with 1 plug-in effect deferred. Effect › Refresh Deferred Effects runs it."), + ] { + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Art the plug-in has not been given before: a result it gave is kept and drawn at once. + for i in 0..count { + let x = 31.0 + 97.0 * f64::from(i) + 13.0 * f64::from(count); + let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 47, "width": 23, "height": 29})).unwrap()["id"].clone(); + app.session.execute("effect.apply", &json!({"ids": [id], "effect": "plugin.test.ui.deferred"})).unwrap(); + } + app.session.execute("select.none", &json!({})).unwrap(); + let (now, by) = (std::sync::Arc::new(AtomicU64::new(0)), step.clone()); + let clock: vectorcraft_plugins::deferred::Clock = + std::sync::Arc::new(move || Some(now.fetch_add(by.load(Ordering::Relaxed), Ordering::Relaxed))); + app.session.doc_mut().unwrap().plugin_effects = vectorcraft_plugins::deferred::Deferrals::with_clock(clock); + step.store(3_000, Ordering::Relaxed); + app.status(""); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.status, said); + assert!(crate::menus::enabled(&app, "effect.refreshDeferred")); + app.status(""); + app.session.doc_mut().unwrap().revision += 1; + frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.status, "", "said once"); + step.store(0, Ordering::Relaxed); + app.session.execute("effect.refreshDeferred", &json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "effect.refreshDeferred"), "nothing is left deferred"); + } +} + /// A drag of an object with raster effects (#1257): the drag's frames render while /// interacting and draw the object's drop shadow and filtered content from the rasters of the /// frame before; the frame after the drag renders without `interacting`, on the render worker, @@ -3656,6 +3882,34 @@ fn snap_to_grid_and_pixel_preview_hide_the_hover_outline() { assert!(outlines(&mut app) > 0, "Pixel Preview off again"); } +/// View › Pixel Preview turns Smart Guides off in the tools: a square moved to 3 pt below another +/// square's top lines up with it only while Pixel Preview is off. +#[test] +fn pixel_preview_stops_smart_guides_snapping_a_move() { + for pixel in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + if pixel { + app.run("view.pixelPreview", json!({})).unwrap(); + } + assert!(app.ui.view.smart_guides); + assert_eq!(app.ui.view.pixel_preview, pixel); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + let view = app.view_info(); + let mut at = |kind: PointerKind, y: f64| crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 425.0, y), view); + at(PointerKind::Down, 325.0); + for y in [300.0, 250.0, 200.0, 150.0, 128.0] { + at(PointerKind::Drag, y); + } + at(PointerKind::Up, 128.0); + let top = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().y0; + assert_eq!(top, if pixel { 103.0 } else { 100.0 }, "Pixel Preview {pixel}"); + } +} + /// #2452: while the view scrolls under a resting pointer, the art under it isn't hit-tested: /// no hover outline, and the tool's last cursor stays. Once the view has been still for /// [`HOVER_STILL_SECS`], the outline comes back without the pointer moving. @@ -8990,6 +9244,20 @@ fn the_task_bar_offers_the_artboard_tools_actions() { assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]); } +/// Screen readers name the Contextual Task Bar's text buttons by their labels. +#[test] +fn screen_readers_name_the_task_bars_text_buttons() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap(); + app.select_tool("selection"); + let tree = tree(|ui| show(&mut app, ui)); + for label in ["Edit Path", "Offset Path", "Simplify", "Duplicate"] { + assert_eq!(named(&tree, label).role(), egui::accesskit::Role::Button, "{label}"); + } +} + /// A selected group's Contextual Task Bar offers Ungroup and Isolate. While an opacity mask is /// edited, when Enter Isolation Mode is off, a group in the mask art gets Ungroup without Isolate. #[test] @@ -9244,7 +9512,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); let check = |app: &VectorcraftApp, switched: bool, expected: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); let vertical = source_vertical != switched; assert_eq!(guides[0].vertical, vertical); @@ -9254,7 +9522,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { assert_eq!(history(&app).0, original_history.0, "preview has no undo entry"); if cancel { // Cancellation is a baseline positive control, independent of snap correctness. - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1); frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); assert_eq!(app.session.active().unwrap().doc, before); assert_eq!(app.session.active().unwrap().selection, selection); @@ -9275,7 +9543,17 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { check(&app, alt, release); let st = app.session.active().unwrap(); assert_eq!(st.doc.ruler_origin_for(0), origin); - assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork"); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&before), "the rectangle tool draws no artwork"); assert_eq!(st.selection, selection); assert_eq!(history(&app), (original_history.0 + 1, 0, true)); assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); @@ -9353,8 +9631,9 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4); - assert_eq!(app.session.active().unwrap().selection.guides, vec![0]); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), vec![0]); // Observe the real selected-guide field; no expected strings are inserted into UI state. let fields = egui::Context::default(); crate::theme::install_fonts(&fields); @@ -9384,7 +9663,7 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { assert_eq!(app.session.active().unwrap().history.undo.len(), undo); frame(&mut app, &ctx, vec![button(to, false)]); assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.active().unwrap().doc, before); } @@ -10479,6 +10758,7 @@ fn pan_job(doc: &std::sync::Arc, key: &CacheKey, (dx, view: Affine::translate((dx, dy)), opts: vectorcraft_render::RenderOptions::default(), scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects: Default::default(), may_copy: true, may_keep: true, } @@ -10523,6 +10803,48 @@ fn a_whole_pixel_pan_copies_the_pixels_that_stay_on_screen() { } } +/// Installing, replacing or removing a plug-in draws a document with a plug-in effect again, and a +/// pan from the frame drawn before the change renders whole instead of copying its pixels. The key +/// of a document without a plug-in effect does not follow the plug-ins. The other tests share the +/// plug-in registry, and the plug-in is removed after. +#[test] +fn a_plug_in_change_draws_the_document_s_plug_in_effects_again() { + use vectorcraft_plugins::wat as tpl; + let id = "test.canvas.redraw"; + let module = vectorcraft_format::base64_encode(&wat::parse_str(tpl::module(&tpl::manifest(id, "effect", "{}"), tpl::ECHO, "", 1)).unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("plugin.install", json!({"dataBase64": module})).unwrap(); + app.session.execute("file.new", &json!({"width": 256, "height": 128})).unwrap(); + let rect = app.run("shape.rectangle", json!({"x": 16, "y": 16, "width": 64, "height": 96})).unwrap()["id"].clone(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + // Other keys' fields (placed documents' bitmaps) follow what other tests do; this one's don't. + let plugins = |app: &VectorcraftApp| app.canvas.key.as_ref().expect("a frame was rendered").plugins; + let plain = plugins(&app); + app.run("plugin.install", json!({"dataBase64": module})).unwrap(); + shapes(&mut app, &ctx); + assert_eq!(plugins(&app), plain, "no plug-in effect, so nothing to draw again"); + app.run("effect.apply", json!({"effect": vectorcraft_plugins::effect::effect_id(id), "ids": [rect]})).unwrap(); + shapes(&mut app, &ctx); + let before = app.canvas.key.clone().expect("a frame was rendered"); + app.run("plugin.install", json!({"dataBase64": module})).unwrap(); + shapes(&mut app, &ctx); + let after = plugins(&app); + assert_ne!(after, before.plugins, "drawn again once the plug-in was replaced"); + // The worker's frame from before the change gives no pixels to the next pan. + let doc = app.session.active().unwrap().doc.clone(); + let at_100 = CacheKey { zoom: 1.0, rot: 0.0, ppp: 1.0, pixel: None, ..before }; + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + render_job(&mut r, &mut kept, &pan_job(&doc, &at_100, (0.0, 0.0))); + let pan = pan_job(&doc, &CacheKey { plugins: after, ..at_100.clone() }, (10.0, 0.0)); + assert!(!render_job(&mut r, &mut kept, &pan).reused, "copied from the frame before the change"); + app.run("plugin.remove", json!({"id": id})).unwrap(); + shapes(&mut app, &ctx); + assert_ne!(plugins(&app), after, "drawn again once the plug-in was removed"); +} + /// A frame does not copy from the last one when the view moves by part of a pixel or over half the /// frame, when copying is not allowed, or when a listed key field, render option, zoom, rotation, /// canvas size or device scale differs. Each such frame has the job's size. The session test @@ -10576,6 +10898,7 @@ fn a_frame_renders_whole_when_anything_but_a_whole_pixel_pan_changed() { ("outline key", other_key(&|k| k.outline = true)), ("Pixel Preview", other_key(&|k| k.pixel = Some([0, 0, 256, 128]))), ("placed bitmaps", other_key(&|k| k.placed += 1)), + ("plug-ins", other_key(&|k| k.plugins += 1)), ("outline", other_opts(&|o| o.outline = true)), ("proof", other_opts(&|o| o.proof = Some(Default::default()))), ("overprint preview", other_opts(&|o| o.overprint_preview = true)), @@ -11053,6 +11376,151 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() { assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged"); } +/// #5239: a double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler +/// at its x, a horizontal one on the left ruler at its y, as one undo step, shown even when +/// guides were hidden. The first click of the pair adds nothing. +#[test] +fn double_clicking_a_ruler_adds_a_guide_at_that_position() { + // The ruler to click, the guide's orientation and its position, the pointer at that position. + for (top, vertical, pos) in [(true, true, 120.0), (false, false, 80.0)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.rulers = true; + app.ui.view.guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 2.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let at = if top { + pos2(xf.to_screen(Point::new(pos, 0.0)).x, rect.top() - RULER / 2.0) + } else { + pos2(rect.left() - RULER / 2.0, xf.to_screen(Point::new(0.0, pos)).y) + }; + let guides = |app: &mut VectorcraftApp| { + let list = app.session.execute("guide.list", &json!({})).unwrap(); + list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect::>() + }; + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = steps(&app); + let click = |app: &mut VectorcraftApp| { + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + click(&mut app); + assert_eq!(guides(&mut app), vec![], "a single click adds none"); + click(&mut app); + let got = guides(&mut app); + assert_eq!(got.len(), 1, "top ruler: {top}, {got:?}"); + assert!(got[0].0 == vertical && (got[0].1 - pos).abs() < 0.01, "top ruler: {top}, want vertical {vertical} at {pos}: {got:?}"); + assert!(app.ui.view.guides, "the new guide is shown"); + assert_eq!(steps(&app), before + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(guides(&mut app), vec![]); + } +} + +/// A fresh app with a 400 x 300 document, rulers on, the view at zoom 2 turned `rotation` degrees, +/// and its first frame drawn. +fn ruler_guide_app(rotation: f64) -> (VectorcraftApp, egui::Context) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.rulers = true; + app.ui.view.guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 2.0; + view.rotation = rotation; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + (app, ctx) +} + +/// One primary click at `at`, as three frames (move, press, release). +fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, ctx, vec![button(true)]); + frame(app, ctx, vec![button(false)]); +} + +/// The ruler guides as (vertical, position). +fn guide_list(app: &mut VectorcraftApp) -> Vec<(bool, f64)> { + let list = app.session.execute("guide.list", &json!({})).unwrap(); + list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect() +} + +/// #5239 review: egui counts a double-click by position and time, not by widget, so a click on +/// the canvas right at a ruler followed at once by a click on the ruler is not a double-click on +/// the ruler. No guide; two clicks on the ruler still make one. +#[test] +fn a_canvas_click_then_a_ruler_click_adds_no_guide() { + for (top, canvas_first) in [(true, true), (false, true), (true, false), (false, false)] { + let (mut app, ctx) = ruler_guide_app(0.0); + let rect = app.canvas_rect.unwrap(); + // Two points less than 6 pt apart, either side of the ruler's inner edge. + let (on_canvas, on_ruler) = if top { + (pos2(rect.center().x, rect.top() + 1.0), pos2(rect.center().x, rect.top() - 1.0)) + } else { + (pos2(rect.left() + 1.0, rect.center().y), pos2(rect.left() - 1.0, rect.center().y)) + }; + click_at(&mut app, &ctx, if canvas_first { on_canvas } else { on_ruler }); + click_at(&mut app, &ctx, on_ruler); + let want = usize::from(!canvas_first); + assert_eq!(guide_list(&mut app).len(), want, "top ruler: {top}, first click on the canvas: {canvas_first}"); + } +} + +/// #5239 review: the rulers read the canvas's coordinates turned with the view, so a guide made +/// by a double-click runs across the ruler's axis in any view: it passes through the clicked +/// point and crosses the ruler's edge at it, never along it. At 90° the top ruler reads the +/// document's y, so its guide is horizontal; the left ruler reads x, so its guide is vertical. +/// A view turned part of the way takes the document axis nearer to the ruler's. +#[test] +fn a_ruler_double_click_follows_a_rotated_view() { + // Rotation (degrees), top ruler's guide is vertical, left ruler's guide is vertical. + for (rotation, top_vertical, left_vertical) in [ + (0.0, true, false), + (90.0, false, true), + (-90.0, false, true), + (180.0, true, false), + (270.0, false, true), + (30.0, true, false), + (60.0, false, true), + ] { + for top in [true, false] { + let (mut app, ctx) = ruler_guide_app(rotation); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let at = + if top { pos2(rect.center().x + 37.0, rect.top() - RULER / 2.0) } else { pos2(rect.left() - RULER / 2.0, rect.center().y + 23.0) }; + click_at(&mut app, &ctx, at); + click_at(&mut app, &ctx, at); + let got = guide_list(&mut app); + let label = format!("rotation {rotation}, top ruler: {top}: {got:?}"); + assert_eq!(got.len(), 1, "{label}"); + let (vertical, pos) = got[0]; + assert_eq!(vertical, if top { top_vertical } else { left_vertical }, "{label}"); + // Through the clicked point. + let doc = xf.to_doc(at); + assert!((pos - if vertical { doc.x } else { doc.y }).abs() < 0.01, "{label}: through {doc:?}"); + // Its direction on screen is not along the ruler: the line crosses the ruler's edge. + let along = if vertical { Point::new(doc.x, doc.y + 1.0) } else { Point::new(doc.x + 1.0, doc.y) }; + let dir = (xf.to_screen(along) - xf.to_screen(doc)).normalized(); + let across = if top { dir.y.abs() } else { dir.x.abs() }; + assert!(across > 0.5, "{label}: the guide runs along the ruler ({dir:?})"); + } + } +} + /// An edit to a 3D effect renders the part it changed on this thread, measured by the renderer /// that drew the frame shown ([`vectorcraft_render::Renderer::changed`]): it draws the part with /// the 3D art it made to measure it. diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 558bf011d..60cb6687b 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -1,6 +1,16 @@ use super::drag::{drag_id, drop_press}; use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2}; +/// The mouse and control-channel Rotate View gestures use the same angle and Shift snapping. +pub(crate) fn drag_rotation(center: Pos2, point: Pos2, start_angle: f64, start_rotation: f64, snap: bool) -> f64 { + let angle = (point.y - center.y).atan2(point.x - center.x) as f64; + let mut degrees = start_rotation + (angle - start_angle).to_degrees(); + if snap { + degrees = (degrees / 15.0).round() * 15.0; + } + vectorcraft_geom::normalize_deg(degrees) +} + /// Fit the view (View → Fit Artboard / Fit All / Actual Size, Arrange Tools › Zoom to Selection). pub fn fit(app: &mut VectorcraftApp, how: &str) { let Some(rect) = app.canvas_rect else { diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 7d85a07be..02f34438a 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -1,4 +1,4 @@ -use super::status_bar::ANIMATED_ZOOM_HINT; +use super::status_bar::{ANIMATED_ZOOM_HINT, clipped_label}; use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets}; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { @@ -6,6 +6,8 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { "dimension" => &[ ("Drag", true), (" out from a highlighted edge to place its dimension | ", false), + ("Click", true), + (" two points, then move and click to place an aligned, horizontal or vertical dimension | ", false), ("Esc", true), (" cancels | Double-click the tool for options", false), ], @@ -247,7 +249,7 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } - ui.label(job); + clipped_label(ui, job); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 899508021..1ed8df873 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -80,13 +80,48 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } }) .unwrap_or(""); - ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + // The tool name leaves the status message its least width and ends in an ellipsis first. + let room = (ui.available_width() - MESSAGE_MIN - ui.spacing().item_spacing.x).max(0.0); + ui.scope(|ui| { + ui.set_max_width(room); + clipped_label(ui, egui::RichText::new(tool).size(11.5).color(t.text_dim)); + }); status_readouts(app, ui, &t); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } +/// The least width the status message keeps when the window is narrow. +const MESSAGE_MIN: f32 = 180.0; + +/// A label that ends in an ellipsis when it is wider than the room left in `ui`. Only a cut label +/// shows its full text in a tooltip; screen readers always receive the full text. +pub(super) fn clipped_label(ui: &mut Ui, text: impl Into) -> egui::Response { + let galley = text.into().into_galley(ui, Some(egui::TextWrapMode::Truncate), ui.available_width(), egui::FontSelection::Default); + let full = galley.elided.then(|| egui::text::LayoutJob { + sections: galley.job.sections.clone(), + text: galley.job.text.clone(), + language: galley.job.language.clone(), + ..Default::default() + }); + let response = ui.add(egui::Label::new(galley).show_tooltip_when_elided(false)); + match full { + Some(job) => full_text_tooltip(response, job), + None => response, + } +} + +/// Show `text` on hover in a tooltip that wraps to fit inside the window. +fn full_text_tooltip(response: egui::Response, text: impl Into) -> egui::Response { + let text = text.into(); + response.on_hover_ui(|ui| { + let margins = ui.spacing().menu_margin.sum().x + 2.0 * ui.spacing().item_spacing.x; + ui.set_max_width(ui.spacing().tooltip_width.min(ui.ctx().content_rect().width() - margins).max(0.0)); + ui.add(egui::Label::new(text).wrap()); + }) +} + /// The most recently measured status widths, scoped to this UI and display settings. pub(super) struct StatusReadoutWidths { pub(super) key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32), @@ -125,7 +160,6 @@ fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc /// Allocate the remaining row once, then paint each field inside its own bounded rectangle. /// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot. pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { - const MESSAGE_MIN: f32 = 180.0; let mono = theme::mono(10.5); let proportional = egui::FontId::proportional(10.5); let unit = app.session.general_unit(); @@ -229,7 +263,7 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); if elided { - response.on_hover_text(text); + full_text_tooltip(response, text); } } diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index 4d5f75821..506b149fe 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -136,7 +136,7 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option, room: f32) /// strip shows is the highlighted one. A tab dragged more than [`crate::doc_windows::TEAR_OFF`] /// above or below the strip floats its view in a window under the pointer; one dragged along the /// strip moves its document among the others (`document.move`, #5663), a line marking where it -/// will go. +/// will go. A middle-click on a tab closes it, as its × does (#6506). pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { /// The width of the » button. const MORE: f32 = 30.0; @@ -200,7 +200,9 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text); hovering_art |= crate::document_drag::hover_tab(app, &resp, win, xr.expand(3.0)); - if xresp.clicked() { + // A middle-click anywhere on the tab closes it as the × does (#6506), so closing a + // modified document's last view still asks to save first. + if xresp.clicked() || resp.middle_clicked() || xresp.middle_clicked() { close = Some(win); } else if resp.clicked() { activate = Some(win); diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 15c856cd8..745c477c7 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -64,6 +64,18 @@ use crate::theme::Tokens; use serde_json::json; use vectorcraft_engine::Session; +/// `layers` without their ruler guides: the art. +fn art_of(layers: &[Arc]) -> Vec { + layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect() +} + #[test] fn help_bar_and_status_bar_render_independently() { for (help, status, screen_mode) in [(true, true, 0), (false, true, 0), (true, false, 0), (false, false, 0), (true, true, 3)] { @@ -163,22 +175,7 @@ fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, wi /// Collect status-bar text, positions, clips and elision state with explicit pointer events. fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> Vec { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some(StatusText { - text: t.galley.job.text.clone(), - at: t.pos, - clip: s.clip_rect, - bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), - elided: t.galley.elided, - }), - _ => None, - }) - .collect() + bar_frame(app, ctx, width, None, events, super::status_bar).0 } /// Find a required text record by prefix, failing the test when it is absent. @@ -373,6 +370,127 @@ fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() { assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right()); } +/// A function that draws one of the bars at the bottom of the window. +type DrawBar = fn(&mut crate::VectorcraftApp, &mut egui::Ui); + +/// The texts (positions, clips and elision state) and the screen-reader tree of one frame of +/// `draw` in a window `width` points wide, at `time` with `events`. +fn bar_frame( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + time: Option, + events: Vec, + draw: DrawBar, +) -> (Vec, Vec) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); + let input = egui::RawInput { screen_rect: Some(screen), time, events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + let texts = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(StatusText { + text: t.galley.job.text.clone(), + at: t.pos, + clip: s.clip_rect, + bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), + elided: t.galley.elided, + }), + _ => None, + }) + .collect(); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (texts, nodes) +} + +/// The texts of `draw` with the pointer away, the screen-reader tree, and the texts after the +/// pointer has rested on the first text that starts with `prefix` for longer than the tooltip delay. +fn bar_hover( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + prefix: &str, + draw: DrawBar, +) -> (Vec, Vec, Vec) { + let t = ctx.input(|i| i.time) + 1.0; + bar_frame(app, ctx, width, Some(t), vec![egui::Event::PointerGone], draw); + let (texts, nodes) = bar_frame(app, ctx, width, Some(t + 0.1), vec![], draw); + let at = status_text(&texts, prefix); + let pos = at.clip.intersect(at.bounds).center(); + bar_frame(app, ctx, width, Some(t + 1.0), vec![egui::Event::PointerMoved(pos)], draw); + bar_frame(app, ctx, width, Some(t + 3.0), vec![], draw); + let (hovered, _) = bar_frame(app, ctx, width, Some(t + 3.1), vec![], draw); + (texts, nodes, hovered) +} + +/// Checks the field whose full text is `full`: in a window `narrow` wide it ends in an ellipsis +/// inside the window, screen readers get the full text, and resting the pointer on it shows the +/// full text in a tooltip no wider than the window; in a window `wide` wide it shows in full with no +/// tooltip. +fn assert_clipped_text_shows_on_hover(app: &mut crate::VectorcraftApp, ctx: &egui::Context, full: &str, narrow: f32, wide: f32, draw: DrawBar) { + let prefix: String = full.chars().take(4).collect(); + let (texts, nodes, hovered) = bar_hover(app, ctx, narrow, &prefix, draw); + let field = status_text(&texts, &prefix); + assert_eq!(field.text, full); + assert!(field.elided, "{narrow}: the text is clipped: {field:?}"); + assert!(field.bounds.right() <= narrow, "{narrow}: the ellipsis is inside the window: {field:?}"); + assert!(nodes.iter().any(|n| n.value() == Some(full)), "{narrow}: screen readers receive the full text"); + let tip: Vec<_> = hovered.iter().filter(|t| t.text == full && !t.elided).collect(); + assert_eq!(tip.len(), 1, "{narrow}: the tooltip shows the full text: {hovered:?}"); + assert!(tip[0].bounds.left() >= 0.0 && tip[0].bounds.right() <= narrow, "{narrow}: the tooltip wraps within the window: {tip:?}"); + + let (texts, _, hovered) = bar_hover(app, ctx, wide, &prefix, draw); + assert!(!status_text(&texts, &prefix).elided, "{wide}: the text fits"); + assert_eq!(hovered.iter().filter(|t| t.text == full).count(), 1, "{wide}: no tooltip: {hovered:?}"); +} + +/// A status message too long for the bar ends in an ellipsis and shows in full on hover, in a +/// tooltip that wraps within the window (#6622). +#[test] +fn a_clipped_status_message_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + let long = format!("Saved recovery data for {}illustration.vectorcraft", "a-long-folder-name/".repeat(12)); + app.ui.status = long.clone(); + assert_clipped_text_shows_on_hover(&mut app, &ctx, &long, 700.0, 20000.0, super::status_bar); + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let (texts, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Saved", super::status_bar); + assert!(!status_text(&texts, "Saved").elided); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}"); +} + +/// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on +/// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider +/// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0. +#[test] +fn a_clipped_help_bar_hint_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + app.select_tool("dimension"); + let hint: String = super::hint_segments(app.session.tool_id()).into_iter().map(|(s, _)| s).collect(); + for narrow in [800.0, 400.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, &hint, narrow, 3000.0, super::hint_bar); + } +} + +/// In a narrow status bar the tool name ends in an ellipsis, shows in full on hover, and leaves +/// the status message its room (#6622). 640 and 500 points are the smallest main window (800 +/// points) at UI Scaling 1.25 and 1.6. +#[test] +fn a_clipped_tool_name_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + for narrow in [640.0, 500.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, "Selection", narrow, 1400.0, super::status_bar); + let (texts, _) = bar_frame(&mut app, &ctx, narrow, None, vec![], super::status_bar); + let tool = status_text(&texts, "Selection"); + let message = status_text(&texts, "Saved recovery data"); + assert!(message.clip.left() >= tool.bounds.right() && message.clip.width() >= 24.0, "{narrow}: the message keeps room: {texts:?}"); + } +} + /// Busy-job transitions preserve message placement, labels and accessibility. #[test] fn background_activity_keeps_the_status_message_origin() { @@ -505,6 +623,65 @@ fn a_tabs_close_box_sits_where_the_platform_puts_it() { assert!(super::CLOSE_BEFORE_TITLE == cfg!(target_os = "macos")); } +/// A middle-click on a document tab closes it as the × does (#6506): a saved document closes, and +/// a modified one asks to save first instead of losing its changes. +#[test] +fn a_middle_click_on_a_tab_closes_it_as_the_close_box_does() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + for _ in 0..3 { + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + } + // The second document has unsaved changes; the third stays the current one. + app.session.set_active(1); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.session.set_active(2); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 200.0)); + let frame = |app: &mut crate::VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui)); + out.textures_delta.clear(); + let mut titles = vec![]; + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape + && t.visual_bounding_rect().width() > 30.0 + { + titles.push(t.visual_bounding_rect()); + } + } + titles.sort_by(|a, b| a.left().total_cmp(&b.left())); + titles + }; + let middle_click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| { + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(app, vec![egui::Event::PointerMoved(at), press(true)]); + frame(app, vec![press(false)]) + }; + let titles = frame(&mut app, vec![]); + assert_eq!(titles.len(), 3, "{titles:?}"); + // The modified document's tab: the save prompt comes up and the document stays open. + let titles = middle_click(&mut app, titles[1].center()); + assert_eq!(app.session.documents().len(), 3, "a modified document is not closed without asking"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::unsaved::KIND), "a modified document asks first"); + app.ui.dialog = None; + // The first, saved document's tab: it closes, and the others stay open. + let uid = |app: &crate::VectorcraftApp, i: usize| app.session.documents()[i].uid; + let (second, third) = (uid(&app, 1), uid(&app, 2)); + middle_click(&mut app, titles[0].center()); + assert_eq!(app.session.documents().len(), 2, "the middle-click closed the first document"); + assert_eq!((uid(&app, 0), uid(&app, 1)), (second, third)); + assert!(app.ui.dialog.is_none(), "a saved document closes without asking"); + // A middle-click on the × itself closes too: the third, saved document's. + let titles = frame(&mut app, vec![]); + assert_eq!(titles.len(), 2, "{titles:?}"); + let title = titles[1]; + let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 }; + middle_click(&mut app, egui::pos2(x, title.center().y)); + assert_eq!(app.session.documents().len(), 1, "the middle-click on the × closed the third document"); + assert_eq!(uid(&app, 0), second); + assert!(app.ui.dialog.is_none(), "a saved document closes without asking"); +} + /// One headless frame of the status bar; returns the artboard navigator's buttons, left to /// right: first, previous, next, last. fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { @@ -1497,8 +1674,8 @@ fn artboard_document_tab_roundtrip_properties_delete_uses_shown_board() { assert_eq!(selected_before_delete, json!([1])); assert_eq!(active_before_delete, json!(1)); assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "the original first board survives"); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()], "only the deleted board's guide is removed"); - assert_eq!(st.doc.layers, before.layers, "artwork and symbol instances stay unchanged"); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()], "only the deleted board's guide is removed"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols, "symbol definitions stay unchanged"); assert_eq!(st.selection, selection); assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual Properties control"); @@ -1794,9 +1971,9 @@ fn delete_shown_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" ); assert_eq!(st.doc.as_ref(), expected, "only Shown and its attached guide may be removed from the full document"); - assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "all artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()]); assert_eq!(&st.selection, selection); assert_eq!(st.uid, *uid); assert_eq!(st.revision, revision + 1); @@ -1856,13 +2033,14 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { let history_before = history_of(st); let history_limits = (st.history.limit, st.history.byte_budget); assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); - assert_eq!(before.guides.len(), 2); + assert_eq!(before.ruler_guides().len(), 2); assert!(!selection.objects.is_empty()); assert!(history_before.1.is_empty()); assert!(!st.is_dirty()); let mut expected = before.as_ref().clone(); expected.artboards = vec![before.artboards[0].clone()]; - expected.guides = vec![before.guides[0].clone()]; + let keep = before.ruler_guides()[0].id; + expected.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.id == keep); let base = Baseline { uid, revision, before, selection, history_before, history_limits, expected }; close_b_tab(&mut app, &ctx, &base); delete_shown_board(&mut app, &ctx, &base); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dce986df6..b02c3b6e1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -17,8 +17,9 @@ //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` or `{fields: [{field, value}…]}` (all or none; setting a field -//! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]) / -//! `ui.dialog.confirm` / `ui.dialog.cancel` +//! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]; +//! `action: confirm | cancel` then confirms or cancels that dialog in the same request and replies +//! `{dialog, result}`) / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG //! comes back as `pngBase64` as well) //! - `ui.render {path?, scale?, artboard?, data?, traceViews?}`: render an artboard headlessly @@ -87,6 +88,22 @@ fn dialog_fields(p: &Value) -> Result, String> { .collect() } +/// What `ui.dialog.set {action}` does with the dialog once its fields are set. +enum DialogAnswer { + Confirm, + Cancel, +} + +/// The `action` of a `ui.dialog.set`: none, `confirm` or `cancel`. It is checked before any field is set. +fn dialog_action(p: &Value) -> Result, String> { + match p.get("action") { + None => Ok(None), + Some(v) if v == "confirm" => Ok(Some(DialogAnswer::Confirm)), + Some(v) if v == "cancel" => Ok(Some(DialogAnswer::Cancel)), + Some(_) => Err("`action` must be confirm or cancel".into()), + } +} + fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), String> { if additional > MAX_SYNTHETIC_EVENTS.saturating_sub(app.synthetic.len()) { return Err(format!("synthetic input queue limit ({MAX_SYNTHETIC_EVENTS} events); retry after pending input drains")); @@ -232,8 +249,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // the layout. if let Some(fields) = ui.as_object_mut() { fields.remove("floating_panels"); + leave_out_unreadable_paths(fields); } - json!({ + let mut inspected = json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.script_report(), "tool": app.session.tool_id(), @@ -277,8 +295,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "nativeMenuBar": app.services.native_menu.is_some(), // Saves and exports still being written in the background (Background Save / Export). "background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::>(), - // Documents still being read off the UI thread (File › Open, Open Recent): their paths. - "opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::>(), + // Documents still being read off the UI thread (File › Open, Open Recent): their paths, without + // the ones outside the read root. + "opening": app.opening.pending.iter().map(|p| p.path.as_str()).filter(|p| readable(p)).collect::>(), // Files still being read off the UI thread for Place (the Place dialog's OK, drops): their // names, in the order they are placed. "placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::>(), @@ -288,7 +307,173 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), - }) + }); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut inspected, &ids); + inspected +} + +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel +/// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels +/// shown as icons or collapsed, and the older floating panels' record. +const PANEL_FIELDS: [&str; 7] = + ["open_panel", "docking", "docking_hidden", "docking_hidden_groups", "docking_icons", "docking_collapsed", "floating_panels"]; + +/// The ids of the loaded libraries (`loaded/`) that the UI state refers to and whose file the +/// request may not read, each with the id a reply shows instead: `loaded/`, as a library +/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel +/// and the Color Guide's limit, and by a panel id (`library::loaded/`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a +/// workspace's name) is not read. When another of those ids already shows that name, the next free one +/// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that +/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. +fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { + let mut ids = std::collections::BTreeSet::new(); + let ui = serde_json::to_value(&app.ui).unwrap_or_default(); + for field in PANEL_FIELDS { + collect_library_panel_ids(&ui[field], &mut ids); + } + for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { + for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { + collect_library_panel_ids(layout, &mut ids); + } + } + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + add_library_id(id, &mut ids); + } + let mut hidden = Vec::new(); + let mut taken = std::collections::BTreeSet::new(); + for id in ids { + let path = &id["loaded/".len()..]; + let name = vectorcraft_engine::cmd::fileio::file_name(path); + if name != path && !readable(path) { + hidden.push((id, name)); + } else { + taken.insert(id); + } + } + let mut hidden = 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 longest first, so that an id that begins another one does not replace part of it. + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + hidden +} + +/// Adds `id` to `ids` when it is a loaded library's id (`loaded/`). +fn add_library_id(id: &str, ids: &mut std::collections::BTreeSet) { + if id.len() > "loaded/".len() && id.starts_with("loaded/") { + ids.insert(id.to_string()); + } +} + +/// Adds to `ids` the loaded library id of every library panel id (`library::loaded/`) +/// that `v` holds as a string or a key. +fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet) { + let add = |s: &str, ids: &mut std::collections::BTreeSet| { + if let Some((_, id)) = s.strip_prefix(crate::panels::library_panel::PREFIX).and_then(|rest| rest.split_once(':')) { + add_library_id(id, ids); + } + }; + match v { + Value::String(s) => add(s, ids), + Value::Array(items) => items.iter().for_each(|item| collect_library_panel_ids(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, ids); + collect_library_panel_ids(item, ids); + } + } + _ => {} + } +} + +/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's +/// id, in its panel's id (`library:swatches:loaded/`) and in keys. +fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { + if ids.is_empty() { + return; + } + let hide = |s: &mut String| { + for (id, shown) in ids { + if s.contains(id.as_str()) { + *s = s.replace(id.as_str(), shown); + } + } + }; + match v { + Value::String(s) => hide(s), + Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Object(fields) => { + for (mut key, mut item) in std::mem::take(fields) { + hide(&mut key); + hide_library_ids(&mut item, ids); + fields.insert(key, item); + } + } + _ => {} + } +} + +/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. +const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not paths. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// May the request being served read `path`? Under an automation read root (a control client's +/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent +/// file or a listed script outside it. +fn readable(path: &str) -> bool { + vectorcraft_engine::file_access::check_read(path).is_ok() +} + +/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, +/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside +/// the read root. The UI state itself is not changed. +fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { + if let Some(Value::Array(files)) = ui.get_mut("recent_files") { + files.retain(|f| f.as_str().is_some_and(readable)); + } + if let Some(Value::Array(panels)) = ui.get_mut("library_panels") { + for panel in panels.iter_mut().filter_map(Value::as_object_mut) { + if panel.get("path").and_then(Value::as_str).is_some_and(|p| !readable(p)) { + panel.remove("path"); + } + } + } + if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { + leave_out_unreadable_folders(prefs); + } + if let Some(Value::Object(dialog)) = ui.get_mut("dialog") + && dialog.get("kind").and_then(Value::as_str) == Some("preferences") + && let Some(Value::Object(fields)) = dialog.get_mut("fields") + { + leave_out_unreadable_folders(fields); + } +} + +/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An +/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. +fn leave_out_unreadable_folders(prefs: &mut serde_json::Map) { + for key in PREF_FOLDERS { + let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + continue; + } + prefs.remove(key); + } } fn key_from(name: &str) -> Option { @@ -331,7 +516,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), + "ui.docking" => { + let mut docking = crate::panel_docking::inspect(app, ctx); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut docking, &ids); + ok(docking) + } "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), @@ -401,6 +591,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Ok(fields) => fields, Err(e) => return err(e), }; + let action = match dialog_action(p) { + Ok(action) => action, + Err(e) => return err(e), + }; // Every field is checked before any is set, so a request sets all of them or none. if let Some((f, _)) = fields.iter().find(|(f, _)| !crate::dialogs::accepts_field(d, f)) { let names = crate::dialogs::field_names(d).join(", "); @@ -419,7 +613,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques return err(e); } } - ok(app.ui.dialog.as_ref().and_then(|d| serde_json::to_value(d).ok()).unwrap_or_default()) + let dialog = app.ui.dialog.as_ref().and_then(|d| serde_json::to_value(d).ok()).unwrap_or_default(); + // `action` answers the dialog the fields were just set in, within this request. + match action { + None => ok(dialog), + Some(DialogAnswer::Confirm) => match crate::dialogs::confirm(app) { + Ok(result) => ok(json!({"dialog": dialog, "result": result})), + Err(e) => err(e), + }, + Some(DialogAnswer::Cancel) => { + crate::dialogs::cancel(app); + ok(json!({"dialog": dialog, "result": null})) + } + } } None => err("no dialog open"), }, @@ -610,10 +816,116 @@ pub(crate) fn save_screenshot_with_limit( #[cfg(test)] mod tests; +/// The window and coordinate mapping where a control-channel Rotate View drag began. +#[derive(Clone, Copy)] +pub(crate) struct RotateViewPress { + window: (u64, u32), + document_resets: u64, + xf: Xf, + start_angle: f64, + start_rotation: f64, +} + +/// Rotate View belongs to the canvas, not the engine's placeholder tool. Keep its press mapping +/// across requests so document-coordinate drags do not move again as the view turns underneath. +fn rotate_view_pointer( + app: &mut VectorcraftApp, + kind: PointerKind, + position: vectorcraft_geom::Point, + screen: bool, + shift: bool, +) -> Result<(), String> { + let window = app.session.active().zip(crate::doc_windows::current(app)).map(|(d, w)| (d.uid, w.1)).ok_or("no document")?; + let document_resets = app.session.tool_resets(); + let stored = if kind == PointerKind::Down { None } else { app.control_rotate_press }; + if kind == PointerKind::Drag && stored.is_none() { + return Err("Rotate View needs a down event before a drag".into()); + } + let mut press = match stored { + Some(press) => press, + None => { + let rect = app.canvas_rect.ok_or("no canvas yet")?; + let view = app.view().ok_or("no document")?; + RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + } + }; + // A gesture cannot turn a different document/view or resume after its document was reset. + if (press.window, press.document_resets) != (window, document_resets) { + return Err("the Rotate View gesture's window or document changed".into()); + } + let point = if screen { egui::pos2(position.x as f32, position.y as f32) } else { press.xf.to_screen(position) }; + let center = press.xf.rect.center(); + if !point.is_finite() || !center.is_finite() { + return Err("Rotate View needs finite canvas coordinates".into()); + } + match kind { + PointerKind::Down => { + press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64; + app.control_rotate_press = Some(press); + } + PointerKind::Drag => { + app.view_mut().ok_or("no document")?.rotation = + crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift); + } + PointerKind::Up => { + app.control_rotate_press = None; + } + PointerKind::Move | PointerKind::DoubleClick => {} + } + Ok(()) +} + +/// A Rotate View packet accepts only the documented boolean modifier fields. +fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> { + let Some(value) = value else { return Ok(()) }; + let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) }; + if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) { + return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into()); + } + Ok(()) +} + +/// Reject malformed coordinates and modifiers before any event can change the angle or press. +fn validate_rotate_view_events(params: &Value, events: &[Value]) -> Result<(), String> { + validate_rotate_view_mods(params.get("mods"))?; + for event in events { + for axis in ["x", "y"] { + if event.get(axis).and_then(Value::as_f64).filter(|value| value.is_finite()).is_none() { + return Err(format!("Rotate View `{axis}` must be a finite number")); + } + } + validate_rotate_view_mods(event.get("mods"))?; + if event.get("space").is_some_and(|space| !matches!(space.as_str(), Some("doc" | "screen"))) { + return Err("Rotate View `space` must be doc or screen".into()); + } + } + Ok(()) +} + /// `ui.pointer`: pointer events through the tools. fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let rotating = app.session.tool_id() == "rotateView"; + let before = if rotating { app.view().map(|view| view.rotation) } else { None }; + let outcome = pointer_events_inner(app, ctx, req); + if rotating && matches!(&outcome, Outcome::Done(reply) if reply.get("ok").and_then(Value::as_bool) == Some(false)) { + // A failed packet must neither leave a partial turn nor allow a later drag to resume it. + app.control_rotate_press = None; + if let (Some(before), Some(view)) = (before, app.view_mut()) { + view.rotation = before; + } + ctx.request_repaint(); + } + outcome +} + +/// Apply a pointer packet after Rotate View validation; other tools retain their existing routing. +fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let rotating = app.session.tool_id() == "rotateView"; + if rotating && let Err(error) = validate_rotate_view_events(p, events) { + return err(error); + } let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); @@ -636,6 +948,13 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); let screen = e.get("space").and_then(Value::as_str) == Some("screen"); + let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); + if rotating { + if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { + return err(e); + } + continue; + } let pos = if screen { match xf { Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), @@ -644,7 +963,6 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe } else { vectorcraft_geom::Point::new(x, y) }; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); // A tilt given in screen axes leans the same way on the page as on the screen. let pen = PointerEvent::json_pen(e).map(|q| match xf { Some(xf) if screen => crate::canvas::pose_on_page(q, &xf), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 6d46e6c0f..0a9c042a1 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -254,6 +254,38 @@ fn dialog_set_refuses_a_field_list_over_the_limit() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!(last)), "the whole list is stored, in order"); } +/// `ui.dialog.set {fields, action}` confirms or cancels the dialog it set the fields in, within the +/// same request, and replies `{dialog, result}`. A bad `action` or a refused field sets nothing and +/// leaves the dialog open. +#[test] +fn dialog_set_with_an_action_answers_the_same_dialog() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let open = |app: &mut VectorcraftApp| app.ui.dialog = Some(crate::state::Dialog::new("note", json!({"text": "a"}))); + let text = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.fields["text"].clone()); + open(&mut app); + for params in [ + json!({"fields": [{"field": "text", "value": "b"}], "action": "ok"}), + json!({"fields": [{"field": "text", "value": "b"}], "action": true}), + json!({"fields": [{"field": "text", "value": "b"}], "action": null}), + json!({"fields": [{"field": "text", "value": "b"}, {"field": "nope", "value": 1}], "action": "confirm"}), + ] { + let r = input_request(&mut app, "ui.dialog.set", params.clone()); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert_eq!(text(&app), Some(json!("a")), "{params}: the dialog stays open as it was"); + } + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [{"field": "text", "value": "b"}], "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"], json!({"dialog": {"kind": "note", "fields": {"text": "b"}}, "result": null})); + assert!(app.ui.dialog.is_none(), "confirmed in the same request"); + open(&mut app); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [{"field": "text", "value": "c"}], "action": "cancel"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"], json!({"dialog": {"kind": "note", "fields": {"text": "c"}}, "result": null})); + assert!(app.ui.dialog.is_none(), "canceled in the same request"); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [], "action": "confirm"})); + assert_eq!(r["error"], "no dialog open"); +} + /// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked /// for, at the scale asked for; the first without one. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fd0373b72..c13a230a9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,6 +26,7 @@ mod command; pub mod composite_fonts; pub mod confirm; pub mod corners; +pub mod custom_dictionary; pub mod dimension; mod document_setup; mod duotone; @@ -321,6 +322,7 @@ registry! { BrushDelete: [brush_delete::KIND] => brush_delete::SPEC, Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC, Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC, + ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC, NewSwatch: [new_swatch::KIND] => new_swatch::SPEC, NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, GraphData: [graph_data::KIND] => graph_data::SPEC, @@ -408,6 +410,7 @@ registry! { ScriptRun: [script_run::KIND] => script_run::SPEC, ScriptGrowth: [script_run::GROWTH_KIND] => script_run::GROWTH_SPEC, ScriptClose: [script_run::CLOSE_KIND] => script_run::CLOSE_SPEC, + ScriptNotice: [script_run::NOTICE_KIND] => script_run::NOTICE_SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, @@ -432,6 +435,7 @@ registry! { NewBrush: [new_brush::KIND] => new_brush::SPEC, EditSelection: [edit_selection::KIND] => edit_selection::SPEC, EditViews: [edit_views::KIND] => edit_views::SPEC, + EditCustomDictionary: [custom_dictionary::KIND] => custom_dictionary::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, LayerOptions: [layer_options::KIND] => layer_options::SPEC, LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC, @@ -502,6 +506,9 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == layer_options::KIND { layer_options::note_set(d, &field, &value); } + if d.kind == "preferences" { + crate::prefs_dialog::note_set(d, &field, &value); + } let dxf = (d.kind == dxf_options::KIND).then(|| field.clone()); d.fields.insert(field, value); if let Some(field) = dxf { @@ -582,6 +589,9 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == script_run::GROWTH_KIND { app.session.script_request_stop(); } + if d.kind == script_run::NOTICE_KIND { + script_run::notice_cancel(app, &d); + } // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs index 0ce0de935..1854eba4b 100644 --- a/crates/ui-egui/src/dialogs/artboard_options.rs +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -1,7 +1,8 @@ //! Artboard Options (double-click with the Artboard tool, double-click the tool, or the Artboards //! panel's options icon or menu): the artboard's fields, sizes parsed with units, its position //! from a reference point, its background color (`color`: a color as the paint commands take -//! it, or null for none), Show Center Mark and Show Cross Hairs (`showCenterMark`, +//! it, or null for none), its guide label (`guideLabel`: the color its own guides are drawn in, a +//! layer color's name or `#rrggbb`, or null for none), Show Center Mark and Show Cross Hairs (`showCenterMark`, //! `showCrossHairs`), the display marks the canvas draws on the artboard //! (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html), //! and its video display options, Show Video Safe Areas and Video Ruler Pixel Aspect Ratio @@ -15,7 +16,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; -use vectorcraft_doc::Unit; +use vectorcraft_doc::{LAYER_COLORS, LayerColor, Unit}; use vectorcraft_engine::cmd::color_value; use vectorcraft_geom::{Point, Rect}; @@ -32,7 +33,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { confirm, // A new artboard's dialog opens without these; its swatch, checkboxes and Video Ruler Pixel // Aspect Ratio field add them. The Global checkboxes' fields come from the preferences. - inputs: |_| &["color", "showCenterMark", "showCrossHairs", "showVideoSafeAreas", PIXEL_ASPECT, FADE, UPDATE_WHILE_DRAGGING], + inputs: |_| &["color", GUIDE_LABEL, "showCenterMark", "showCrossHairs", "showVideoSafeAreas", PIXEL_ASPECT, FADE, UPDATE_WHILE_DRAGGING], ..DialogSpec::FORM }; @@ -49,7 +50,7 @@ fn placed(d: &Dialog) -> Rect { /// as the Transform panel and the Control bar show an artboard's. A new Width or Height keeps the /// reference point where it was, as their sizes do. /// Then Background Color: the Fill list (None, White, Black; Custom for another color) and a swatch -/// that picks a color of its own, the Show Center Mark, Show Cross Hairs and Show Video Safe Areas +/// that picks a color of its own, the Guide Label row ([`guide_label_row`]), the Show Center Mark, Show Cross Hairs and Show Video Safe Areas /// checkboxes, and the Video Ruler Pixel Aspect Ratio field. Last, under Global, the Fade Region /// Outside Artboard and Update While Dragging checkboxes. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { @@ -116,6 +117,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if let Some(c) = set { d.fields.insert("color".into(), c.as_ref().map_or(Value::Null, crate::panels::color_json)); } + guide_label_row(ui, d); ui.add_space(8.0); form::check(ui, d, "showCenterMark", tl!("Show Center Mark")); form::check(ui, d, "showCrossHairs", tl!("Show Cross Hairs")); @@ -137,6 +139,64 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The field of the guide label, as `artboard.setProps` and `artboard.new` take it: a layer +/// color's name, `#rrggbb`, or null for none. +pub(super) const GUIDE_LABEL: &str = "guideLabel"; + +/// The size of the color chip before each name in the Guide Label list. +const CHIP: egui::Vec2 = egui::vec2(16.0, 10.0); + +/// The Guide Label row: a list of None, the layer colors (each after a chip of its color) and +/// Custom, and, while the artboard has a label, a swatch that picks a color of its own. Custom +/// keeps the label's color as a `#rrggbb` value. +fn guide_label_row(ui: &mut egui::Ui, d: &mut Dialog) { + let now = d.fields.get(GUIDE_LABEL).and_then(Value::as_str).and_then(LayerColor::from_label_text); + let shown = match now { + None => "None", + Some(LayerColor::Preset(i)) => LAYER_COLORS.get(usize::from(i)).map_or("Custom", |(n, _)| *n), + Some(LayerColor::Custom(_)) => "Custom", + }; + let mut set = None; + ui.horizontal(|ui| { + let row = form::row_label(ui, tl!("Guide Label:")); + ui.label_inputs_by(row.id, |ui| { + let border = crate::theme::Tokens::get(ui.ctx()).input_border; + let rows = std::iter::once(("None", None)).chain(LAYER_COLORS.iter().map(|(n, c)| (*n, Some(*c)))).chain([("Custom", None)]); + let picked = crate::widgets::combo(ui, "ab-options-guide-label", tl!(shown), 140.0, |ui| { + let mut chosen = None; + for (i, (name, rgb)) in rows.enumerate() { + let chip = egui::IdSalt::new(("ab-guide-label-chip", i)); + let r = egui::Button::selectable(name == shown, (egui::Atom::custom(chip, CHIP), tl!(name))).atom_ui(ui); + if let (Some(rect), Some([cr, cg, cb])) = (r.rect(chip), rgb) { + ui.painter().rect_filled(rect, 1.0, egui::Color32::from_rgb(cr, cg, cb)); + ui.painter().rect_stroke(rect, 1.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Inside); + } + if r.response.clicked() { + chosen = Some(i); + } + } + chosen + }); + match picked { + Some(0) => set = Some(None), + Some(i) if i <= LAYER_COLORS.len() => set = u8::try_from(i - 1).ok().map(|i| Some(LayerColor::Preset(i))), + Some(_) => set = Some(Some(LayerColor::Custom(now.map_or(LAYER_COLORS[0].1, LayerColor::rgb)))), + None => {} + } + if let Some(c) = now { + let mut rgb = c.rgb(); + let swatch_name = format!("{} {}", tl!("Guide Label:"), tl!("Swatches")); + if ui.name_inputs(swatch_name, |ui| ui.color_edit_button_srgb(&mut rgb)).changed() { + set = Some(Some(LayerColor::Custom(rgb))); + } + } + }); + }); + if let Some(c) = set { + d.fields.insert(GUIDE_LABEL.into(), c.map_or(Value::Null, |c| json!(c.label_text()))); + } +} + /// The preference and field of Fade Region Outside Artboard: while the Artboard tool is active, /// the canvas draws everything outside the artboards darker. const FADE: &str = "fadeRegionOutsideArtboard"; @@ -292,8 +352,10 @@ pub fn open(app: &mut VectorcraftApp) -> Result { let a = st.doc.artboards.get(index).ok_or("no artboard")?; let r = a.rect; let color = a.color.as_ref().map(crate::panels::color_json); + let guide_label = a.guide_label.map(LayerColor::label_text); let fields = json!({ "index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "color": color, + GUIDE_LABEL: guide_label, "showCenterMark": a.show_center_mark, "showCrossHairs": a.show_cross_hairs, "showVideoSafeAreas": a.show_video_safe_areas, PIXEL_ASPECT: a.video_ruler_pixel_aspect_ratio, }); @@ -374,7 +436,7 @@ pub fn open_new(app: &mut VectorcraftApp) -> Result { } /// OK for a new artboard: `artboard.new` with the fields (an empty name gives the default one), -/// the background color picked, the display marks ticked and the Video Ruler Pixel Aspect Ratio +/// the background color and guide label picked, the display marks ticked and the Video Ruler Pixel Aspect Ratio /// typed, and the new artboard becomes the active one. fn confirm_new(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut p = json!({}); @@ -388,6 +450,9 @@ fn confirm_new(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(c) = d.fields.get("color") { p["color"] = c.clone(); } + if let Some(c) = d.fields.get(GUIDE_LABEL).filter(|c| !c.is_null()) { + p[GUIDE_LABEL] = c.clone(); + } for k in ["showCenterMark", "showCrossHairs", "showVideoSafeAreas"] { if d.bool(k) { p[k] = json!(true); diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 86f8fe453..d1ca76cc5 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -251,7 +251,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { "v_scale" => tl!("Vertical Scale"), "baseline_shift" => tl!("Baseline Shift"), "rotation" => tl!("Character Rotation"), - "fill_tint" | "stroke_tint" => tl!("Tint"), + "fill_tint" => tl!("Fill Tint"), + "stroke_tint" => tl!("Stroke Tint"), "stroke_width" => tl!("Weight"), _ => return Err("unknown character style number field".into()), }; @@ -661,18 +662,22 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { ("kerning", tl!("Kerning")), ("tracking", tl!("Tracking")), ] { - ui.label(caption); + let row = ui.label(caption); if d.fields.get(key).is_some_and(Value::is_null) { d.fields.insert(key.into(), json!("")); } - form::text(ui, d, key, 190.0); + ui.label_inputs_by(row.id, |ui| form::text(ui, d, key, 190.0)); ui.end_row(); } - ui.label(tl!("Case")); - choice(ui, d, "case", &[("normal", tl!("Normal")), ("allCaps", tl!("All Caps")), ("smallCaps", tl!("Small Caps"))]); + let row = ui.label(tl!("Case")); + ui.label_inputs_by(row.id, |ui| { + choice(ui, d, "case", &[("normal", tl!("Normal")), ("allCaps", tl!("All Caps")), ("smallCaps", tl!("Small Caps"))]); + }); ui.end_row(); - ui.label(tl!("Position")); - choice(ui, d, "position", &[("normal", tl!("Normal")), ("superscript", tl!("Superscript")), ("subscript", tl!("Subscript"))]); + let row = ui.label(tl!("Position")); + ui.label_inputs_by(row.id, |ui| { + choice(ui, d, "position", &[("normal", tl!("Normal")), ("superscript", tl!("Superscript")), ("subscript", tl!("Subscript"))]); + }); ui.end_row(); for (key, caption) in [ ("underline", tl!("Underline")), @@ -693,20 +698,22 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { } ui.end_row(); } - ui.label(tl!("Character Alignment")); - choice( - ui, - d, - "charAlign", - &[ - ("romanBaseline", tl!("Roman Baseline")), - ("emBoxTop", tl!("Em Box Top/Right")), - ("emBoxCenter", tl!("Em Box Center")), - ("emBoxBottom", tl!("Em Box Bottom/Left")), - ("icfTop", tl!("ICF Top/Right")), - ("icfBottom", tl!("ICF Bottom/Left")), - ], - ); + let row = ui.label(tl!("Character Alignment")); + ui.label_inputs_by(row.id, |ui| { + choice( + ui, + d, + "charAlign", + &[ + ("romanBaseline", tl!("Roman Baseline")), + ("emBoxTop", tl!("Em Box Top/Right")), + ("emBoxCenter", tl!("Em Box Center")), + ("emBoxBottom", tl!("Em Box Bottom/Left")), + ("icfTop", tl!("ICF Top/Right")), + ("icfBottom", tl!("ICF Bottom/Left")), + ], + ); + }); ui.end_row(); }); } @@ -721,20 +728,20 @@ pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { ("baseline_shift", tl!("Baseline Shift"), "pt"), ("rotation", tl!("Character Rotation"), "°"), ] { - ui.label(caption); + let row = ui.label(caption); if d.fields.get(key).is_some_and(Value::is_null) { d.fields.insert(key.into(), json!("")); } ui.horizontal(|ui| { - form::text(ui, d, key, 160.0); + ui.label_inputs_by(row.id, |ui| form::text(ui, d, key, 160.0)); ui.label(unit); }); ui.end_row(); } - ui.label(tl!("Language:")); + let row = ui.label(tl!("Language:")); let names: Vec<&str> = std::iter::once(" ").chain(vectorcraft_doc::setup::LANGUAGES.iter().map(|(name, _, _)| *name)).collect(); let current = d.fields.get("language").and_then(Value::as_str).unwrap_or("").to_string(); - if let Some(i) = crate::widgets::dropdown(ui, (KIND, "language"), ¤t, &names, 190.0) { + if let Some(i) = ui.label_inputs_by(row.id, |ui| crate::widgets::dropdown(ui, (KIND, "language"), ¤t, &names, 190.0)) { d.fields.insert("language".into(), if i == 0 { Value::Null } else { names.get(i).map_or(Value::Null, |name| json!(name)) }); } ui.end_row(); @@ -798,9 +805,9 @@ fn paint_choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { /// the stroke Weight and Overprint Fill and Overprint Stroke, each unset or set. pub(crate) fn color_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("character-style-color").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { - for (key, caption) in [("fill", tl!("Fill")), ("stroke", tl!("Stroke"))] { - ui.label(caption); - paint_choice(ui, d, key); + for (key, caption, tint_name) in [("fill", tl!("Fill"), tl!("Fill Tint")), ("stroke", tl!("Stroke"), tl!("Stroke Tint"))] { + let row = ui.label(caption); + ui.label_inputs_by(row.id, |ui| paint_choice(ui, d, key)); ui.end_row(); let tint_key = format!("{key}_tint"); ui.label(tl!("Tint")); @@ -810,20 +817,20 @@ pub(crate) fn color_body(ui: &mut egui::Ui, d: &mut Dialog) { let linked = d.fields.get(key).and_then(|p| p.get("swatch")).is_some_and(Value::is_string); ui.add_enabled_ui(linked, |ui| { ui.horizontal(|ui| { - form::text(ui, d, &tint_key, 160.0); + form::text_named(ui, d, &tint_key, tint_name, 160.0); ui.label("%"); }); }); ui.end_row(); } - ui.label(tl!("Weight")); + let row = ui.label(tl!("Weight")); if d.fields.get("stroke_width").is_none_or(Value::is_null) { d.fields.insert("stroke_width".into(), json!("")); } let stroked = d.fields.get("stroke").and_then(|p| p.get("type")).and_then(Value::as_str) != Some("none"); ui.add_enabled_ui(stroked, |ui| { ui.horizontal(|ui| { - form::text(ui, d, "stroke_width", 160.0); + ui.label_inputs_by(row.id, |ui| form::text(ui, d, "stroke_width", 160.0)); ui.label("pt"); }); }); @@ -839,11 +846,11 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) { let figures: Vec<(&str, &str)> = FIGURE_VALUES.into_iter().zip(crate::panels::opentype::FIGURES).collect(); let positions: Vec<(&str, &str)> = POSITION_VALUES.into_iter().zip(crate::panels::opentype::POSITIONS).collect(); egui::Grid::new("character-style-opentype").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Figure:")); - choice(ui, d, "ot_figure", &figures); + let row = ui.label(tl!("Figure:")); + ui.label_inputs_by(row.id, |ui| choice(ui, d, "ot_figure", &figures)); ui.end_row(); - ui.label(tl!("Position:")); - choice(ui, d, "ot_position", &positions); + let row = ui.label(tl!("Position:")); + ui.label_inputs_by(row.id, |ui| choice(ui, d, "ot_position", &positions)); ui.end_row(); for (key, caption) in [ ("ot_liga", tl!("Standard Ligatures")), @@ -929,8 +936,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { opentype_body(ui, d); } else { ui.horizontal(|ui| { - ui.label(tl!("Style Name:")); - form::text(ui, d, "name", 190.0); + let row = ui.label(tl!("Style Name:")); + ui.label_inputs_by(row.id, |ui| form::text(ui, d, "name", 190.0)); }); ui.separator(); if let Ok(attrs) = edited_attrs(d) { @@ -1643,8 +1650,8 @@ mod tests { fn color_tint_and_weight_outside_their_limits_are_errors() { let linked = json!({"type": "solid", "color": {"model": "gray", "k": 0.5}, "swatch": "Ink"}); for (key, value, message) in [ - ("fill_tint", "150", "Tint must be a number from 0 to 100"), - ("stroke_tint", "-1", "Tint must be a number from 0 to 100"), + ("fill_tint", "150", "Fill Tint must be a number from 0 to 100"), + ("stroke_tint", "-1", "Stroke Tint must be a number from 0 to 100"), ("stroke_width", "-1", "Weight must be a number from 0 to 4000000"), ("stroke_width", "thick", "Weight must be a number"), ] { @@ -1837,7 +1844,7 @@ mod tests { }; let shown = crate::i18n::message(crate::i18n::current(), &why); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).with_accessibility_check(false).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( move |ui, app: &mut VectorcraftApp| { if !std::mem::replace(&mut fonts, true) { crate::theme::install_fonts(ui.ctx()); @@ -1857,6 +1864,76 @@ mod tests { } } + #[test] + fn every_section_names_its_inputs_in_both_dialogs() { + for new_style in [false, true] { + for section in ["general", "basic", "advanced", "color", "opentype"] { + let mut app = app(); + if new_style { + app.run("ui.newCharStyle", json!({})).unwrap(); + } else { + app.run("charStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + open(&mut app, "Body").unwrap(); + } + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section)); + let mut fonts = false; + // The harness fails a frame that leaves an input without an accessible name. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + assert!(h.unnamed_widgets().is_empty(), "new {new_style}, {section}: {:?}", h.unnamed_widgets()); + } + } + } + + #[test] + fn fill_and_stroke_tint_fields_have_distinct_names() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + for new_style in [false, true] { + let mut app = app(); + app.run("swatch.new", json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap(); + if new_style { + app.run("ui.newCharStyle", json!({})).unwrap(); + } else { + app.run("charStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + open(&mut app, "Body").unwrap(); + } + let brand = json!(app.session.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap()); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("section".into(), json!("color")); + d.fields.insert("fill".into(), brand.clone()); + d.fields.insert("stroke".into(), brand); + } + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + for name in ["Fill Tint", "Stroke Tint"] { + let field = h.get_by_role_and_label(Role::TextInput, name); + assert!(!field.accesskit_node().is_disabled(), "new {new_style}: {name} is enabled for a linked swatch"); + } + assert!(h.query_by_role_and_label(Role::TextInput, "Tint").is_none(), "new {new_style}: a field is named only Tint"); + } + } + #[test] fn invalid_number_keeps_last_valid_preview() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index ab79ccf5d..06e96e45f 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -63,6 +63,31 @@ pub(crate) fn ask_replace(app: &mut VectorcraftApp, path: &str, typed: &str) { app.ui.dialog = Some(Dialog::new(REPLACE, fields)); } +/// The dialog kind of the question Reset Preferences asks over the Preferences dialog +/// ([`ask_reset_prefs`]). Fields: `message`, `detail`. +pub const RESET_PREFS: &str = "resetPreferences"; + +pub(super) const RESET_PREFS_SPEC: DialogSpec = DialogSpec { confirm: reset_prefs, ..SPEC }; + +/// Ask whether to reset the preferences, over the Preferences dialog open now. Cancel and Escape +/// show Preferences again as it was; OK shows it again with every preference at its default value +/// ([`crate::prefs_dialog::reset_fields`]). Nothing is applied until OK in Preferences. +pub(crate) fn ask_reset_prefs(app: &mut VectorcraftApp) { + let under = app.ui.dialog.take().and_then(|d| serde_json::to_value(d).ok()); + let message = tl!("Reset all preferences to their defaults?"); + let detail = tl!("The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences."); + let fields = json!({"message": message, "detail": detail, super::PARENT: under}); + app.ui.dialog = Some(Dialog::new(RESET_PREFS, fields)); +} + +/// OK on [`ask_reset_prefs`]: Preferences again, its fields at their defaults. +fn reset_prefs(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut prefs = super::parent(d).filter(|p| p.kind == "preferences").ok_or("the dialog it was opened over is no longer open")?; + crate::prefs_dialog::reset_fields(&mut prefs); + app.ui.dialog = Some(prefs); + Ok(Value::Null) +} + /// Most messages kept for later while a dialog is open ([`tell`]). const MAX_WAITING: usize = 16; diff --git a/crates/ui-egui/src/dialogs/custom_dictionary.rs b/crates/ui-egui/src/dialogs/custom_dictionary.rs new file mode 100644 index 000000000..8a61f7095 --- /dev/null +++ b/crates/ui-egui/src/dialogs/custom_dictionary.rs @@ -0,0 +1,334 @@ +//! Edit › Edit Custom Dictionary…: the custom dictionary's words in a list, with an Entry field and +//! Add, Delete and Change, as the public +//! [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) +//! page describes it: enter a word and Add it, select a word and Delete it, or select a word, edit it +//! in the Entry field and Change it. Nothing changes until Done, which deletes and adds the words +//! that differ in one `text.spelling.editDictionary`, all or none; Cancel leaves the dictionary as +//! it was. +//! +//! Fields: `orig` (the words when the dialog opened), `words` (as edited, sorted), `entry` (the +//! Entry field) and `selected` (the word picked, or null). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::spelling::custom_word; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Edit Custom Dictionary. +pub const KIND: &str = "editCustomDictionary"; + +/// The command its menu item runs: with no params, it opens this dialog. +pub const COMMAND: &str = "text.spelling.dictionary"; + +/// The command Done runs: every delete and add, saved once. +const EDIT: &str = "text.spelling.editDictionary"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Edit Custom Dictionary").into(), body, confirm, ok: Some("Done"), min_width: 420.0, ..DialogSpec::FORM }; + +/// Open Edit Custom Dictionary on the custom dictionary's words. +pub fn open(app: &mut VectorcraftApp) -> Result { + let words = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_else(|| json!([])); + app.ui.dialog = Some(Dialog::new(KIND, json!({ "orig": words, "words": words, "entry": "", "selected": null }))); + Ok(Value::Null) +} + +fn list(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default() +} + +/// The word picked in the list, while it is still there. +fn selected(d: &Dialog, words: &[String]) -> Option { + d.fields.get("selected").and_then(Value::as_str).filter(|w| words.iter().any(|x| x == w)).map(String::from) +} + +/// What the buttons can do now: the Entry field as a dictionary word that isn't in the list yet +/// (for Add and Change), and the word picked (for Delete and Change). +fn actions(d: &Dialog) -> (Option, Option) { + let words = list(d, "words"); + let entry = custom_word(&d.str("entry")).filter(|w| !words.contains(w)); + (entry, selected(d, &words)) +} + +fn set_words(d: &mut Dialog, mut words: Vec, selected: Option<&str>) { + words.sort(); + words.dedup(); + d.fields.insert("words".into(), json!(words)); + d.fields.insert("selected".into(), json!(selected)); +} + +/// Add: the Entry field's word joins the list, picked. +fn add(d: &mut Dialog) { + let (Some(w), _) = actions(d) else { return }; + let mut words = list(d, "words"); + words.push(w.clone()); + set_words(d, words, Some(&w)); +} + +/// Delete: the word picked leaves the list; the one after it (or before, for the last) is picked. +fn delete(d: &mut Dialog) { + let (_, Some(w)) = actions(d) else { return }; + let mut words = list(d, "words"); + let Some(i) = words.iter().position(|x| *x == w) else { return }; + words.remove(i); + let next = words.get(i).or_else(|| words.last()).cloned(); + if let Some(n) = &next { + d.fields.insert("entry".into(), json!(n)); + } + set_words(d, words, next.as_deref()); +} + +/// Change: the word picked becomes the Entry field's word. +fn change(d: &mut Dialog) { + let (Some(to), Some(w)) = actions(d) else { return }; + let mut words: Vec = list(d, "words").into_iter().filter(|x| *x != w).collect(); + words.push(to.clone()); + set_words(d, words, Some(&to)); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let words = list(d, "words"); + let current = selected(d, &words); + let (entry, picked) = actions(d); + let mut pick = None; + let (mut added, mut deleted, mut changed) = (false, false, false); + widgets::dim_label(ui, tl!("Entry:")); + form::text(ui, d, "entry", 380.0); + ui.add_space(8.0); + ui.horizontal_top(|ui| { + widgets::list_box(ui, |ui| { + ui.set_width(260.0); + let row = ui.spacing().interact_size.y; + egui::ScrollArea::vertical().id_salt("custom-dictionary").min_scrolled_height(200.0).max_height(200.0).show_rows( + ui, + row, + words.len(), + |ui, range| { + ui.set_width(ui.available_width()); + for w in words.get(range).unwrap_or_default() { + if ui.selectable_label(current.as_ref() == Some(w), w.as_str()).clicked() { + pick = Some(w.clone()); + } + } + }, + ); + }); + ui.add_space(16.0); + ui.vertical(|ui| { + added = ui.add_enabled_ui(entry.is_some(), |ui| widgets::flat_button(ui, tl!("Add"), 90.0).clicked()).inner; + ui.add_space(6.0); + deleted = ui.add_enabled_ui(picked.is_some(), |ui| widgets::flat_button(ui, tl!("Delete"), 90.0).clicked()).inner; + ui.add_space(6.0); + changed = ui.add_enabled_ui(entry.is_some() && picked.is_some(), |ui| widgets::flat_button(ui, tl!("Change"), 90.0).clicked()).inner; + }); + }); + // Picking a word puts it in the Entry field, to edit for Change. + if let Some(w) = pick { + d.fields.insert("entry".into(), json!(w)); + d.fields.insert("selected".into(), json!(w)); + } + if added { + add(d); + } else if deleted { + delete(d); + } else if changed { + change(d); + } + false +} + +/// Done: deletes the words no longer in the list and adds the new ones in one +/// `text.spelling.editDictionary`, saved once. When it can't be saved, none of it is kept: the +/// dialog stays open with the error, its list as edited, and Cancel leaves the dictionary as it was. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (orig, words) = (list(d, "orig"), list(d, "words")); + let remove: Vec<&String> = orig.iter().filter(|w| !words.contains(w)).collect(); + let add: Vec<&String> = words.iter().filter(|w| !orig.contains(w)).collect(); + app.run(EDIT, json!({ "remove": remove, "add": add })).map_err(|e| e.to_string())?; + app.ui.dialog = None; + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + use serde_json::{Value, json}; + + use crate::VectorcraftApp; + + // The custom dictionary is the whole process's: each test here uses words no other test does. + const A: &str = "zqvdicta"; + const B: &str = "zqvdictb"; + const M: &str = "zqvdictm"; + const X: &str = "zqvdictx"; + const Y: &str = "zqvdicty"; + + fn app() -> VectorcraftApp { + VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()) + } + + fn has(app: &mut VectorcraftApp, w: &str) -> bool { + app.run("text.spelling.dictionary", json!({})).unwrap()["words"].as_array().unwrap().contains(&json!(w)) + } + + fn forget(app: &mut VectorcraftApp, words: &[&str]) { + for w in words { + let _ = app.run("text.spelling.removeWord", json!({ "word": w })); + } + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + /// The dialog's list, of `mine` (other tests may have their words in the dictionary meanwhile). + fn words(app: &VectorcraftApp, mine: &[&str]) -> Vec { + super::list(app.ui.dialog.as_ref().unwrap(), "words").into_iter().filter(|w| mine.contains(&w.as_str())).collect() + } + + /// One frame of the dialog on an 800 × 600 window → the text painted, with its rect. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + // A window takes a sizing frame and fades in: each frame is a quarter second on. + let time = ctx.input(|i| i.time) + 0.25; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + } + + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + } + + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) -> Vec<(String, Rect)> { + frame(app, ctx, vec![]); + let texts = frame(app, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at)]); + frame(app, ctx, vec![press(true)]); + frame(app, ctx, vec![press(false)]); + frame(app, ctx, vec![]) + } + + /// Edit › Edit Custom Dictionary… (beside Spelling, as the public page has it) opens the + /// dialog on the custom dictionary's words. + #[test] + fn the_edit_menu_opens_it_on_the_custom_dictionary() { + let mut app = app(); + forget(&mut app, &[M]); + app.run("text.spelling.addWord", json!({ "word": M })).unwrap(); + let e = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Edit Custom Dictionary…").expect("in the Edit menu"); + assert_eq!((e.path.as_slice(), e.command.as_deref(), e.enabled), (&["Edit".to_string()][..], Some(super::COMMAND), true)); + crate::menus::invoke(&mut app, super::COMMAND, json!({})); + let d = app.ui.dialog.as_ref().expect("Edit Custom Dictionary opens"); + assert_eq!(d.kind, super::KIND); + assert_eq!(words(&app, &[M]), [M]); + forget(&mut app, &[M]); + } + + /// Add, Delete and Change edit the list; nothing changes in the dictionary until Done, which + /// applies them; Cancel leaves it as it was. + #[test] + fn add_delete_and_change_apply_on_done() { + let mut app = app(); + forget(&mut app, &[A, B, "zqvdictc"]); + app.run("text.spelling.addWord", json!({ "word": A })).unwrap(); + super::open(&mut app).unwrap(); + let ctx = context(); + // Add: the Entry field's word (kept in lower case) joins the list. + set(&mut app, "entry", json!(" ZqvDictB ")); + click(&mut app, &ctx, "Add"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), [A, B]); + assert!(!has(&mut app, B), "not until Done"); + // Change: pick A, edit it in the Entry field. + set(&mut app, "selected", json!(A)); + set(&mut app, "entry", json!("zqvdictc")); + click(&mut app, &ctx, "Change"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), [B, "zqvdictc"]); + // Delete the word picked. + set(&mut app, "selected", json!(B)); + click(&mut app, &ctx, "Delete"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), ["zqvdictc"]); + // Cancel: the dictionary is as it was. + crate::dialogs::cancel(&mut app); + assert!(has(&mut app, A) && !has(&mut app, B) && !has(&mut app, "zqvdictc")); + // Again, then Done. + super::open(&mut app).unwrap(); + set(&mut app, "selected", json!(A)); + set(&mut app, "entry", json!("zqvdictc")); + click(&mut app, &ctx, "Change"); + set(&mut app, "entry", json!(B)); + click(&mut app, &ctx, "Add"); + click(&mut app, &ctx, "Done"); + assert!(app.ui.dialog.is_none(), "Done closes it"); + assert!(!has(&mut app, A) && has(&mut app, B) && has(&mut app, "zqvdictc")); + forget(&mut app, &[A, B, "zqvdictc"]); + } + + /// Done that can't be saved keeps none of the edits: the dialog stays open with the error and + /// Cancel leaves the dictionary as it was. + #[test] + fn a_done_that_cant_be_saved_changes_nothing() { + const P: &str = "zqvdictp"; + const Q: &str = "zqvdictq"; + let mut app = app(); + forget(&mut app, &[P, Q]); + app.run("text.spelling.addWord", json!({ "word": P })).unwrap(); + let folder = vectorcraft_testkit::temp_dir("custom-dictionary-done").join("Custom Dictionary.txt"); + std::fs::create_dir_all(&folder).unwrap(); + let mut failing = self::app(); + failing.session.attach_custom_dictionary(folder); + super::open(&mut failing).unwrap(); + let ctx = context(); + set(&mut failing, "selected", json!(P)); + click(&mut failing, &ctx, "Delete"); + set(&mut failing, "entry", json!(Q)); + click(&mut failing, &ctx, "Add"); + assert_eq!(words(&failing, &[P, Q]), [Q]); + click(&mut failing, &ctx, "Done"); + assert!(failing.ui.dialog.is_some(), "the dialog stays open with the error"); + assert!(has(&mut app, P) && !has(&mut app, Q), "neither the delete nor the add is kept"); + crate::dialogs::cancel(&mut failing); + assert!(has(&mut app, P) && !has(&mut app, Q)); + forget(&mut app, &[P, Q]); + } + + /// Add and Change need one word that isn't in the list yet; Delete and Change need a word + /// picked. A button that can't act does nothing. + #[test] + fn the_buttons_act_only_on_a_word_they_can_take() { + let mut app = app(); + forget(&mut app, &[X, Y]); + app.run("text.spelling.addWord", json!({ "word": X })).unwrap(); + super::open(&mut app).unwrap(); + let ctx = context(); + for entry in ["", "two words", X, &"z".repeat(65)] { + set(&mut app, "entry", json!(entry)); + assert_eq!(super::actions(app.ui.dialog.as_ref().unwrap()).0, None, "{entry:?}"); + click(&mut app, &ctx, "Add"); + assert_eq!(words(&app, &[X, Y]), [X], "{entry:?} isn't added"); + } + // Nothing picked: Delete and Change do nothing. + set(&mut app, "entry", json!(Y)); + click(&mut app, &ctx, "Delete"); + click(&mut app, &ctx, "Change"); + assert_eq!(words(&app, &[X, Y]), [X]); + crate::dialogs::cancel(&mut app); + forget(&mut app, &[X]); + } +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index c87cfb7e6..bbddd222f 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -120,7 +120,7 @@ fn suffix(kind: &str, key: &str) -> Option<&'static str> { /// The fields of the form dialogs ([`grid`]) that the dialog's own body draws, by dialog kind. fn own_rows(kind: &str) -> &'static [&'static str] { match kind { - "artboardOptions" => &["color", super::artboard_options::PIXEL_ASPECT], + "artboardOptions" => &["color", super::artboard_options::GUIDE_LABEL, super::artboard_options::PIXEL_ASPECT], // The grids' Reference Point is their 9-point locator. "rectangularGrid" | "polarGrid" => &["reference"], _ => &[], diff --git a/crates/ui-egui/src/dialogs/halftone.rs b/crates/ui-egui/src/dialogs/halftone.rs index 653b16fc9..5084754fc 100644 --- a/crates/ui-egui/src/dialogs/halftone.rs +++ b/crates/ui-egui/src/dialogs/halftone.rs @@ -29,6 +29,12 @@ pub fn fields() -> Value { json!({"shape": "circle", "frequency": 20, "angle": 45, "mode": "mono", "color": "#000000", "invert": false, "clip": true, "keepOriginal": false, "preview": true}) } +/// Whether the open Object → Vector Halftone dialog is showing its temporary dot group. +pub(crate) fn previewing(app: &VectorcraftApp) -> bool { + app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND) + && app.session.active().and_then(|st| st.interaction.as_ref()?.preview.as_ref()).is_some_and(|(cmd, _)| cmd == CMD) +} + fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { screen_fields(ui, d); form::check(ui, d, "keepOriginal", tl!("Keep Original")); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index ee68105a6..ba6d20607 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -478,6 +478,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { let layers = standard.allows_layers() && choice::(d, "compatibility").unwrap_or_default().has_layers(); flag(ui, d, "createLayers", tl!("Create PDF layers from layers and sublayers"), layers); flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true); + // PDF/X files and PDF 1.3 and 1.4 files always write long pages in points. + let compatibility = choice::(d, "compatibility").unwrap_or_default(); + let scales = !standard.is_pdfx() && !matches!(compatibility, Compatibility::Pdf13 | Compatibility::Pdf14); + flag(ui, d, "longPagesInPoints", tl!("Write pages longer than 200 in at full size in points"), scales); if editor { heading(ui, tl!("Description")); if let Some(text) = widgets::text_field_named(ui, "pdf-preset-description", Some(tl!("Description")), Some(&d.str("description")), 460.0, 3) { diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index 8c13a9b61..7bb471a76 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -137,6 +137,35 @@ fn summary_warnings_are_translated() { assert_eq!(warning_line(fr, "no row: for this note"), "⚠ no row\u{a0}: for this note"); } +/// The warnings and the error for a page longer than 14,400 pt are translated: a PDF 1.5 file +/// written as PDF 1.6, a page with a `UserUnit`, a PDF/X-4 page in points, a refused PDF/X-1a +/// file and a page too narrow for the `UserUnit` its long side needs. +#[test] +fn long_page_notes_are_translated() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 56_692.91, "height": 2_834.65})).unwrap(); + let mut notes: Vec = vec![]; + for p in [json!({"compatibility": "1.5"}), json!({"preset": "PDF/X-4:2010"})] { + let r = s.execute("document.exportPdf", &p).unwrap(); + let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); + notes.extend(warnings.filter(|w| w.contains("14,400 pt")).map(str::to_string)); + } + notes.push(s.execute("document.exportPdf", &json!({"preset": "PDF/X-1a:2001"})).unwrap_err().to_string()); + s.execute("file.new", &json!({"width": 85_040, "height": 17})).unwrap(); + let r = s.execute("document.exportPdf", &json!({})).unwrap(); + let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); + notes.extend(warnings.filter(|w| w.contains("too long and narrow")).map(str::to_string)); + assert_eq!(notes.len(), 5, "{notes:?}"); + let english = ["has no UserUnit, which", "apps that ignore UserUnit", "apps that keep to the limit", "real numbers up to", "too long and narrow"]; + for code in ["de", "fr", "ja", "zh-hans"] { + let lang = crate::i18n::Lang::from_code(code).unwrap(); + for note in ¬es { + let shown = warning_line(lang, note); + assert!(english.iter().all(|e| !shown.contains(e)), "{code}: {shown}"); + } + } +} + /// A placed image whose pixel data can't be decoded is left out of the PDF, and the status bar /// shows the note raster exports give. #[test] diff --git a/crates/ui-egui/src/dialogs/script_run.rs b/crates/ui-egui/src/dialogs/script_run.rs index 2ef43cb8f..8f5e3b109 100644 --- a/crates/ui-egui/src/dialogs/script_run.rs +++ b/crates/ui-egui/src/dialogs/script_run.rs @@ -1,16 +1,25 @@ //! File › Scripts › Other Script…: choosing a JavaScript file and the warning before it runs -//! (`script.run` runs it), the question a run that keeps adding art asks, and the question quitting -//! asks while a script runs. +//! (`script.run` runs it), the question a run that keeps adding art asks, the question quitting +//! asks while a script runs, and the dialogs a script opens with `alert`, `confirm` and `prompt`. //! //! `ui.runScript {path? | source?, name?}` shows the warning for that script; without either it //! asks for a `.js` or `.jsx` file (the web opens the browser's file picker). With Preferences › //! General › Show External Script Warning off, the chosen script runs at once. Warning fields: //! `name`, `script` (`script.run`'s params) and `dontShow` (true: turn the warning off). +//! +//! A script's dialog (kind `scriptDialog`) has the fields `kind` (alert, confirm or prompt), +//! `message`, `title` (null: Script Alert), `errorIcon`, `noAsDefault` and `value` (the prompt's +//! text), and takes `answer`: `ok` (alert, prompt), `yes` or `no` (confirm), `cancel` (prompt) or +//! `stop`. OK, Enter or `ui.dialog.confirm` without an `answer` gives the default button's answer. +//! Escape or `ui.dialog.cancel` gives Cancel for a prompt, No for a confirm and Stop for an alert. +//! Stop ends the run and puts back everything it did. use serde_json::{Value, json}; use vectorcraft_engine::cmd::scripts::{RUN_EXTS, TOO_LARGE, runs}; +use vectorcraft_engine::script::dialogs::{MAX_PROMPT_BYTES, cut_to, shown_text}; +use vectorcraft_engine::script::{DialogAnswer, ScriptDialog}; -use super::DialogSpec; +use super::{DialogAction, DialogSpec}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{FilePick, VectorcraftApp, widgets}; @@ -21,6 +30,19 @@ pub const KIND: &str = "scriptRun"; pub const GROWTH_KIND: &str = "scriptGrowth"; /// The dialog kind of the question quitting asks while a script runs. pub const CLOSE_KIND: &str = "scriptClose"; +/// The dialog kind of a script's `alert`, `confirm` and `prompt`. +pub const NOTICE_KIND: &str = "scriptDialog"; + +pub(super) const NOTICE_SPEC: DialogSpec = DialogSpec { + heading: notice_heading, + body_with_actions: Some(notice_body), + confirm: notice_confirm, + ok: None, + inputs: |_| &["answer"], + min_width: 360.0, + max_width: Some(560.0), + ..DialogSpec::FORM +}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Run Script").into(), @@ -145,3 +167,170 @@ fn close_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result } Ok(Value::Null) } + +/// Quitting while a script runs asks whether to stop it, also over the script's own dialog, which +/// Cancel shows again as it was. Any other open dialog stays, and quitting asks nothing yet. +pub(crate) fn ask_to_close(app: &mut VectorcraftApp) { + let notice = app.ui.dialog.take_if(|d| d.kind == NOTICE_KIND); + if app.ui.dialog.is_some() { + return; + } + let mut fields = json!({}); + if let Some(notice) = notice { + fields[super::PARENT] = serde_json::to_value(notice).unwrap_or_default(); + } + app.ui.dialog = Some(Dialog::new(CLOSE_KIND, fields)); +} + +/// Closes the question quitting asks once the run it asks about has ended, and forgets the +/// script's dialog it was asked over once the run no longer waits at that dialog, so Cancel does +/// not show it again. +pub(crate) fn settle_close(app: &mut VectorcraftApp) { + if !app.ui.dialog.as_ref().is_some_and(|d| d.kind == CLOSE_KIND) { + return; + } + if !app.session.script_running() { + app.ui.dialog = None; + } else if app.session.script_dialog().is_none() + && let Some(d) = app.ui.dialog.as_mut() + { + d.fields.remove(super::PARENT); + } +} + +/// Shows the dialog the running script waits at. +pub(crate) fn open_notice(app: &mut VectorcraftApp, d: &ScriptDialog) { + let fields = json!({ + "kind": d.kind.id(), + "message": d.message, + "title": d.title, + "errorIcon": d.error_icon, + "noAsDefault": d.no_as_default, + "value": d.preset, + }); + app.ui.dialog = Some(Dialog::new(NOTICE_KIND, fields)); +} + +/// The dialog's heading: the script's title on one line, or Script Alert. +pub(crate) fn notice_heading(d: &Dialog) -> String { + match d.fields.get("title").and_then(Value::as_str) { + Some(title) => shown_text(title, false), + None => tl!("Script Alert").into(), + } +} + +/// The answer of the default button: OK, or for a confirm Yes, or No with `noAsDefault`. +fn default_answer(d: &Dialog) -> &'static str { + match (d.str("kind").as_str(), d.bool("noAsDefault")) { + ("confirm", true) => "no", + ("confirm", false) => "yes", + _ => "ok", + } +} + +/// A button that is the primary one when it is the default. +fn answer_button(ui: &mut egui::Ui, label: &str, default: bool) -> bool { + if default { widgets::primary_button_enabled(ui, label, true).clicked() } else { widgets::secondary_button(ui, label).clicked() } +} + +fn notice_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let t = Tokens::get(ui.ctx()); + let kind = d.str("kind"); + let message = shown_text(&d.str("message"), true); + ui.horizontal_top(|ui| { + if d.bool("errorIcon") { + ui.label(egui::RichText::new("!").font(theme::semibold(22.0)).color(t.error)); + ui.add_space(8.0); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| ui.label(&message)); + }); + if kind == "prompt" { + ui.add_space(8.0); + let name = cut_to(&shown_text(&message, false), 200).to_string(); + super::form::text_edit_named(ui, d, "value", &name, ui.available_width()); + let value = d.str("value"); + if value.len() > MAX_PROMPT_BYTES { + d.fields.insert("value".into(), json!(cut_to(&value, MAX_PROMPT_BYTES))); + } + } + ui.add_space(16.0); + let default = default_answer(d); + let mut answer = None; + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { + let buttons: &[(&str, &str)] = match kind.as_str() { + "confirm" => &[("yes", "Yes"), ("no", "No")], + "prompt" => &[("ok", "OK"), ("cancel", "Cancel")], + _ => &[("ok", "OK")], + }; + for (id, label) in buttons { + if answer_button(ui, tl!(label), *id == default) { + answer = Some(*id); + } + ui.add_space(8.0); + } + // The row runs right to left: Stop goes at its left end. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if widgets::secondary_button(ui, tl!("Stop")).clicked() { + answer = Some("stop"); + } + }); + }); + if answer.is_none() && crate::shortcuts::pressed_anew(ui.ctx(), egui::Key::Enter) { + answer = Some(default); + } + let Some(answer) = answer else { return DialogAction::None }; + d.fields.insert("answer".into(), json!(answer)); + DialogAction::Confirm +} + +/// Gives the running script the dialog's `answer`, or the default button's. +fn notice_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let answer = d.fields.get("answer").and_then(Value::as_str).unwrap_or(default_answer(d)); + give(app, d, answer) +} + +/// Escape or Cancel: Cancel for a prompt, No for a confirm and Stop for an alert. +pub(super) fn notice_cancel(app: &mut VectorcraftApp, d: &Dialog) { + let answer = match d.str("kind").as_str() { + "prompt" => "cancel", + "confirm" => "no", + _ => "stop", + }; + let _ = give(app, d, answer); +} + +fn give(app: &mut VectorcraftApp, d: &Dialog, answer: &str) -> Result { + let given = match (d.str("kind").as_str(), answer) { + (_, "stop") => None, + ("alert", "ok") => Some(DialogAnswer::Ok), + ("confirm", "yes") => Some(DialogAnswer::Confirm(true)), + ("confirm", "no") => Some(DialogAnswer::Confirm(false)), + ("prompt", "ok") => Some(DialogAnswer::Prompt(Some(d.str("value")))), + ("prompt", "cancel") => Some(DialogAnswer::Prompt(None)), + (kind, _) => return Err(format!("`answer` for this {kind} dialog must be one of: {}", answers_for(kind))), + }; + match given { + None => { + app.session.script_request_stop(); + } + Some(a) => { + // Refused while the dialog still waits: the text is too long, and the dialog stays open. + if !app.session.script_answer(a) && app.session.script_dialog().is_some() { + return Err(format!("the prompt's text is longer than {MAX_PROMPT_BYTES} bytes")); + } + } + } + if app.ui.dialog.as_ref().is_some_and(|open| open.kind == NOTICE_KIND) { + app.ui.dialog = None; + } + Ok(Value::Null) +} + +fn answers_for(kind: &str) -> &'static str { + match kind { + "confirm" => "yes, no, stop", + "prompt" => "ok, cancel, stop", + _ => "ok, stop", + } +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 7783e205f..a7d62b6da 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -32,7 +32,7 @@ fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) { } fn objects(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count())).sum() } #[test] @@ -570,11 +570,11 @@ fn split_into_grid_opens_at_the_selections_size_and_previews() { ]); frame(&mut app, Default::default()); // The preview: 2 × 3 cells and the guides along their edges, not yet an undo step. - assert_eq!((objects(&app), app.session.doc().unwrap().doc.guides.len()), (6, 10)); + assert_eq!((objects(&app), app.session.doc().unwrap().doc.ruler_guides().len()), (6, 10)); assert!(app.session.in_interaction()); confirm(&mut app).unwrap(); let st = app.session.doc().unwrap(); - assert_eq!((objects(&app), st.doc.guides.len(), st.history.undo.len()), (6, 10, undo + 1)); + assert_eq!((objects(&app), st.doc.ruler_guides().len(), st.history.undo.len()), (6, 10, undo + 1)); assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); // Cancel rolls a preview back. app.run("select.all", json!({})).unwrap(); @@ -660,6 +660,7 @@ impl PluginIntField { budget: Default::default(), paint_output: false, commands: Vec::new(), + command_allowlist: Vec::new(), }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option { @@ -1754,6 +1755,64 @@ fn new_artboard_options_give_the_new_artboard_its_background_color() { } } +/// Artboard Options shows the artboard's guide label on its Guide Label: row (None, a layer color's +/// name, or Custom). OK sets a label picked there in one undo step, keeps it while another field +/// changes, and a null `guideLabel` clears it; Cancel leaves it. OK with nothing changed adds no +/// undo step, and a new artboard's Artboard Options gives it the label picked. +#[test] +fn artboard_options_shows_sets_and_clears_the_guide_label() { + let mut app = app(); + let label = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[0].guide_label.map(vectorcraft_doc::LayerColor::label_text); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let lines = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, |app, ui| show(app, ui.ctx())); + text.lines().map(str::trim).map(str::to_string).collect::>() + }; + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let shown = lines(&mut app); + assert!(shown.iter().any(|l| l == "Guide Label:"), "{shown:?}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["guideLabel"], json!(null)); + let u = undo(&app); + super::confirm(&mut app).unwrap(); + assert_eq!((label(&app), undo(&app)), (None, u), "OK with nothing changed"); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("guideLabel".into(), json!("Orange")); + super::confirm(&mut app).unwrap(); + assert_eq!((label(&app).as_deref(), undo(&app)), (Some("Orange"), u + 1)); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["guideLabel"], json!("Orange")); + assert!(lines(&mut app).iter().any(|l| l == "Orange")); + app.ui.dialog.as_mut().unwrap().fields.insert("guideLabel".into(), json!("#123456")); + cancel(&mut app); + assert_eq!(label(&app).as_deref(), Some("Orange"), "Cancel leaves it"); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + assert!(lines(&mut app).iter().any(|l| l == "Orange")); + app.ui.dialog.as_mut().unwrap().fields.insert("guideLabel".into(), json!("#123456")); + super::confirm(&mut app).unwrap(); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + assert!(lines(&mut app).iter().any(|l| l == "Custom")); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Card")); + super::confirm(&mut app).unwrap(); + assert_eq!(label(&app).as_deref(), Some("#123456"), "kept while another field changes"); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("guideLabel".into(), json!("purple")); + assert!(super::confirm(&mut app).is_err(), "a name in other letter case is refused"); + assert_eq!(label(&app).as_deref(), Some("#123456")); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("guideLabel".into(), json!(null)); + super::confirm(&mut app).unwrap(); + assert_eq!(label(&app), None); + for (picked, want) in [(Some(json!("Teal")), Some("Teal")), (None, None)] { + app.run("ui.newArtboard", json!({})).unwrap(); + if let Some(c) = picked { + app.ui.dialog.as_mut().unwrap().fields.insert("guideLabel".into(), c); + } + super::confirm(&mut app).unwrap(); + let ab = app.session.doc().unwrap().doc.artboards.last().cloned().unwrap(); + assert_eq!(ab.guide_label.map(vectorcraft_doc::LayerColor::label_text).as_deref(), want); + } +} + /// Artboard Options shows Show Center Mark and Show Cross Hairs, ticked as the artboard has them /// whether it opens from the Artboards panel or from a double-click with the Artboard tool. OK /// sets them in one undo step, and a new artboard's Artboard Options gives it the ones ticked. diff --git a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs index 75aecde46..46c912170 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs @@ -315,22 +315,18 @@ impl Harness { } } -fn clipping_group() -> Vec<(&'static str, Value)> { - vec![ - ("shape.ellipse", json!({"x":20,"y":20,"width":100,"height":100})), - ("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100})), - ("select.all", json!({})), - ("object.clippingMask.make", json!({})), - ] +fn an_image() -> Vec<(&'static str, Value)> { + let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png()); + vec![("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [150, 150]}))] } /// #6140: on an object a 3D effect can't take, the dialog says why in its own words, and OK, which /// could only fail, is off, where it used to look as if OK did nothing. #[test] fn a_refused_3d_effect_says_why_and_turns_ok_off() { - let mut h = Harness::extrude_dialog(&clipping_group(), true); + let mut h = Harness::extrude_dialog(&an_image(), true); assert!(!h.ok_is_on()); - assert!(h.shows("requires a path")); + assert!(h.shows("can't build a solid from an image")); assert!(!h.shows("effect.apply"), "the reason is written without the command's id"); let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; h.frame(vec![enter], Modifiers::NONE); @@ -341,9 +337,9 @@ fn a_refused_3d_effect_says_why_and_turns_ok_off() { /// and trying it leaves no preview open. #[test] fn a_refused_3d_effect_is_found_with_preview_off() { - let mut h = Harness::extrude_dialog(&clipping_group(), false); + let mut h = Harness::extrude_dialog(&an_image(), false); assert!(!h.ok_is_on()); - assert!(h.shows("requires a path")); + assert!(h.shows("can't build a solid from an image")); assert!(!h.app.session.in_interaction()); } @@ -351,7 +347,7 @@ fn a_refused_3d_effect_is_found_with_preview_off() { /// one would replace its preview and cancel it. #[test] fn a_3d_effect_with_preview_off_leaves_another_interaction_alone() { - let mut h = Harness::extrude_dialog(&clipping_group(), false); + let mut h = Harness::extrude_dialog(&an_image(), false); h.app.session.begin_interaction("Drag").unwrap(); let d = h.app.ui.dialog.as_mut().unwrap(); d.fields.remove("__tried"); @@ -360,7 +356,7 @@ fn a_3d_effect_with_preview_off_leaves_another_interaction_alone() { assert!(h.app.session.in_interaction(), "the other interaction goes on"); assert!(h.ok_is_on(), "nothing has tried the values yet"); h.app.session.cancel_interaction().unwrap(); - assert!(h.shows("requires a path")); + assert!(h.shows("can't build a solid from an image")); assert!(!h.ok_is_on()); assert!(!h.app.session.in_interaction()); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e1121be66..d83b5ac3c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -276,6 +276,7 @@ font-technology Variable Променливи Average… Усредняване… Axis: Ос: Background Color: Цвят на фона: + Guide Label: Етикет на водачите: Show Center Mark Показване на централен знак Show Cross Hairs Показване на кръстосани линии Show Video Safe Areas Показване на безопасните видеозони @@ -1603,6 +1604,7 @@ font-technology Variable Променливи Include Linked Files Включване на свързаните файлове Include Metadata Включване на метаданни Include non-printing layers Включване на слоеве, които не се печат + Write pages longer than 200 in at full size in points Записване на страници, по-дълги от 200 in, в пълен размер в точки Include SVG Code Включване на SVG код Include Swatches for Tints Включване на образци за тонове Inflate… Издуване… @@ -1826,6 +1828,7 @@ font-technology Variable Променливи Lock Grid Заключване на мрежата Lock Guides Заключване на водачите Lock Guides by Default Заключване на водачите по подразбиране + Artboard Guides with Cmd/Ctrl + Ruler Drag Водачи за работната област с Cmd/Ctrl + плъзгане от линийка Lock Others Заключване на другите Lock Slices Заключване на фрагментите Lock Station Point Заключване на базовата точка @@ -2260,6 +2263,7 @@ font-technology Variable Променливи Open… Отваряне… Optical Оптично Optical Margin Alignment Подравняване по оптично поле + Dynamic Text Динамичен текст Optimize for fast web view Оптимизиране за бърз уеб преглед Optimized Оптимизирано Options Опции @@ -2277,6 +2281,8 @@ font-technology Variable Променливи Other Layers Други слоеве Other Libraries Други библиотеки Other Library… Друга библиотека… + Some scripts were not listed Някои скриптове не са изброени + The Scripts folder cannot be read Папката Scripts не може да бъде прочетена Other Script… Друг скрипт… Out of Gamut Warning Предупреждение за излизане извън цветовата гама Out of Web Color Warning Предупреждение за цвят извън уеб палитрата @@ -2744,6 +2750,12 @@ digits Malayalam Малаялам Reset OpenType Features Нулиране на OpenType характеристиките Reset Panel Нулиране на панела Reset Preferences Нулиране на настройките + Export Preferences… Експортиране на настройките… + Import Preferences… Импортиране на настройките… + Save the applied preferences to a JSON file Запазване на приложените настройки във файл JSON + Apply the preferences in a file Export Preferences saved Прилагане на настройките от файл, запазен с „Експортиране на настройките“ + Export Preferences Експортиране на настройките + Import Preferences Импортиране на настройките Search Preferences Търсене в настройките No settings found Няма намерени настройки Reset Profiles Нулиране на профилите @@ -2766,6 +2778,8 @@ digits Malayalam Малаялам Responsive Адаптивен Restore Възстановяване Restore every preference to its default (applied on OK) Възстановяване на всички настройки по подразбиране (прилага се при ОПК) + Reset all preferences to their defaults? Да се нулират ли всички настройки до стойностите по подразбиране? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Стойностите по подразбиране се прилагат, когато щракнете върху ОК в Настройки. Отказ там запазва текущите ви настройки. Restore Recovered Documents Възстановяване на възстановените документи Restored documents open unsaved: save them to keep them. Възстановените документи се отварят незаписани: запазете ги, за да ги съхраните. Restrict printing, editing and other tasks Ограничаване на печата, редактирането и други задачи @@ -3052,6 +3066,20 @@ digits Malayalam Малаялам Settings… Настройки… Setup… Настройка… Shadows and Glows Сенки и блясъци + Game HUD Игрови HUD + Health Fill Здраве + Mana Glow Мана + Damage Flash Щета + Quest Outline Квест + Coin Shine Монета + Boss Alert Бос + Status Effects Статус ефекти + Poison Tint Отрова + Freeze Edge Замразяване + Burn Aura Изгаряне + Stun Pulse Зашеметяване + Stealth Fade Скритост + Shield Buff Щит Shape Фигура Shape Builder Създател на фигури Shape Builder Regions Области на създателя на фигури @@ -3469,6 +3497,8 @@ digits Malayalam Малаялам Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка. Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа) Tint Оттенък + Fill Tint Оттенък на запълване + Stroke Tint Оттенък на щриха Tip extends past the end point Връхът продължава след крайната точка Tip on the end point Връх върху крайната точка Title Case Заглавни букви @@ -3959,6 +3989,7 @@ axis Both И двете Stop Script Спри скрипта Show External Script Warning Показвай предупреждение за външни скриптове A Script Is Running Изпълнява се скрипт + Script Alert Известие от скрипт A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скриптът може да променя отворените документи и да записва файлове чрез командите, които изпълнява. Изпълнявайте само скриптове, на които имате доверие. Do not show this warning again Не показвай повече това предупреждение Stop the script and undo what it did? Then quit again. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново. @@ -4499,6 +4530,20 @@ axis Both И двете @msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (запазване, симулиране или отхвърляне) @msg unknown panel `{raw}` непознат панел `{raw}` @msg unknown preference `{key}` непозната настройка `{key}` +@msg Preferences imported. Настройките са импортирани. +@msg not a preferences file: {e} не е файл с настройки: {e} +@msg not a preferences file: no JSON object не е файл с настройки: няма обект JSON +@msg not a preferences file (format `{format}`) не е файл с настройки (формат `{format}`) +@msg not a preferences file: no `format` не е файл с настройки: няма `format` +@msg not a preferences file: no `version` не е файл с настройки: няма `version` +@msg not a preferences file: no `prefs` не е файл с настройки: няма `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) файлът е запазен от по-нова версия на VectorCraft (версия {v}) +@msg the file holds no preferences файлът не съдържа настройки +@msg `{key}` names a folder, which preferences files leave out `{key}` указва папка, а файловете с настройки не съдържат папки +@msg the file is larger than 1 MB файлът е по-голям от 1 MB +@msg give `path` or `data` посочете `path` или `data` +@msg `{key}` must be an object `{key}` трябва да бъде обект +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` се изпълнява самостоятелно, а не като стъпка от пакет @msg unknown preset (one of {_1}) непозната предварителна настройка (една от {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) непозната предварителна настройка `{name}` (висока, средна, ниска или запазена: вижте flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) непозната предварителна настройка `{name}` (вижте flattener.presets.list) @@ -4712,6 +4757,8 @@ axis Both И двете @msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} на `{key}` е {value}, а не идентификатор на обект (неотрицателно цяло число) @msg no such object id {value} няма обект с идентификатор {value} +@msg id {value} is a ruler guide, not an object идентификатор {value} е на водач, а не на обект +@msg a ruler guide goes only directly in a layer or sublayer водачът може да е само пряко в слой или подслой @msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path} @msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path} @msg automation path rejected: outside the read root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за четене {root}: {path} @@ -4988,7 +5035,8 @@ lighting Light Светлина Bevel both sides Фасет от двете страни Presets Предварителни настройки @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Един обект може да има само един 3D ефект. Редактирайте параметрите на съществуващия ефект или го премахнете предварително. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} в момента изисква път или съставен път. Първо преобразувайте текста, изображенията и групите в пътища. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} в момента изисква път, съставен път, текст, група, преливане или екземпляр на символ. Първо разширете другите обекти. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} не може да създаде тяло от изображение: първо го трасирайте с Обект › Трасиране на изображение › Създаване. @msg {label} requires a shape with at least two anchors {label} изисква форма с поне две опорни точки @msg The shape is too complex for a 3D effect Формата е твърде сложна за 3D ефект @msg The 3D surface is too complex; simplify the shape 3D повърхността е твърде сложна; опростете формата @@ -5413,6 +5461,7 @@ file-filter Flattener presets Предварителни настройки за file-filter PDF presets Предварителни настройки за PDF file-filter PDF-compatible .ai Съвместими с PDF .ai файлове file-filter Print presets Предварителни настройки за печат +file-filter Preferences Настройки file-filter Export presets Набори за експортиране file-filter SVG Compressed Компресиран SVG file-filter Swatch libraries Библиотеки с образци @@ -5494,6 +5543,7 @@ theme Appearance Външен вид Preview (1 inch on the page) Преглед (1 инч на страницата) Set as Default Задаване по подразбиране out from a highlighted edge to place its dimension | навън от осветен ръб, за да поставите размера му | + two points, then move and click to place an aligned, horizontal or vertical dimension | върху две точки, после преместете и щракнете, за да поставите подравнен, хоризонтален или вертикален размер | cancels | Double-click the tool for options отказва | Двойно щракване върху инструмента за опции Angular Ъглов Away from the Line Встрани от линията @@ -5523,14 +5573,17 @@ theme Appearance Външен вид @msg Invalid drawing scale Невалиден мащаб на чертежа @msg Invalid real scale Невалиден реален мащаб @msg Measured anchor no longer exists Измерената опорна точка вече не съществува +@msg Invalid measured point Невалидна измерена точка @msg Measured segment no longer exists Измереният сегмент вече не съществува @msg Non-finite dimension Размерът не е крайно число +@msg Only linear dimensions are horizontal or vertical Само линейните размери са хоризонтални или вертикални @msg Scale Text Attributes: selected type has no scale to bake Мащабиране на текстови атрибути: избраният текст няма мащаб за прилагане @msg Scale must be drawing:real, for example 1:10 Мащабът трябва да е чертеж:реалност, например 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Съотношението на мащаба трябва да е между 1:1000000 и 1000000:1 @msg Second measured segment no longer exists Вторият измерен сегмент вече не съществува @msg Unknown dimension mode Непознат режим на размера @msg Unknown dimension option {k} Непозната опция за размер {k} +@msg Unknown dimension orientation Непозната ориентация на размера @msg Unknown dimension style Непознат стил на размера @msg Unknown unit Непозната единица Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Затваря кръговите краища на отворени профили. При частично завъртане срязаните страни остават отворени. @@ -5795,6 +5848,7 @@ axis Y Axis Y ос Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника Keep settings of its own Съхраняване на собствени настройки Keep a cache of its own, which you can clear Съхраняване на собствен кеш, който можете да изчистите + Run these commands on the document: {ids} Изпълняване на тези команди върху документа: {ids} Clear Plug-in Cache Изчистване на кеша на добавката # Actions panel: action sets (#3712). New Set… Нов набор… @@ -5844,6 +5898,23 @@ axis Y Axis Y ос Button Бутон Panel Панел UI Frames UI рамки + Map Markers Маркери на карта + Pin Карфица + Flag Флаг + Crosshair Мерник + Checkpoint Контролна точка + Inventory Icons Икони на инвентара + Skill Icons Икони на умения + Attack Атака + Defend Защита + Heal Лечение + Buff Усилване + Ult Улт + Sword Меч + Shield Щит + Potion Отвара + Key Ключ + Gem Скъпоценен камък Open Symbol Library Отваряне на библиотека със символи Save Symbol Library Запазване на библиотека със символи Save Symbol Library… Запази библиотека със символи… @@ -5892,6 +5963,16 @@ axis Y Axis Y ос User Brush Library 9 Потребителска библиотека с четки 9 User Brush Library 10 Потребителска библиотека с четки 10 Art Ribbons Арт ленти + Zigzag Зигзаг + Burst Ray Лъч на удар + Dash Trail Пунктирна следа + Speed Line Линия на скорост + Game Trails Игрови следи + Energy Beams Енергийни лъчи + Laser Bolt Лазерен болт + Plasma Arc Плазмена дъга + Charge Wave Вълна на заряд + Spark Trail Следа от искри Calligraphic Nibs Калиграфски писци Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури @@ -6440,3 +6521,30 @@ breakText Glyphs Глифи # Align to Pixel Grid Align to Pixel Grid Подравняване към пикселната мрежа @msg side is left or right, not `{s}` side е left или right, а не `{s}` + Edit Custom Dictionary Редактиране на потребителски речник + Entry: Запис: +# Script Console + Script Console Конзола за скриптове + Clear Script Console Изчистване на конзолата за скриптове + Copy Script Console Копиране на конзолата за скриптове + Copy All Копиране на всичко + Copy Selected Копиране на избраното + Finished Завършен + Ended with an error Прекратен с грешка + Out of fuel Достигнат лимит на инструкциите + Ended at a host limit Прекратен при лимит на хоста + Ended at a limit Прекратен при лимит + Stopped Спрян + Ended by an internal fault Прекратен поради вътрешна грешка + time unavailable времето не е налично + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} най-бавни: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} извикване към хоста|{n} извиквания към хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати +@plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени + +# Deferred plug-in effects + Refresh Deferred Effects Обновяване на отложените ефекти + Deferred Effects Отложени ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d4749aba4..663e95cea 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -62,6 +62,8 @@ Scripts Skripte Check Script… Provjeri skriptu… Check Script Provjeri skriptu + Some scripts were not listed Neke skripte nisu navedene + The Scripts folder cannot be read Folder Scripts se ne može pročitati Other Script… Druga skripta… Document Setup… Postavke dokumenta… Switch Units Promijeni jedinice @@ -397,6 +399,7 @@ Smart Punctuation… Pametna interpunkcija… Create Outlines Kreiraj obrise Optical Margin Alignment Optičko poravnanje margina + Dynamic Text Dinamički tekst Fill with Placeholder Text Ispuni tekstom čuvara mjesta Insert Inline Symbol Umetni simbol u redu Show Hidden Characters Prikaži skrivene znakove @@ -601,6 +604,7 @@ Show Guides Prikaži vodilice Lock Guides Zaključaj vodilice Lock Guides by Default Zaključaj vodilice po zadanom + Artboard Guides with Cmd/Ctrl + Ruler Drag Vodilice radne ploče uz Cmd/Ctrl + povlačenje s ravnala Unlock Guides Otključaj vodilice Make Guides Napravi vodilice Release Guides Oslobodi vodilice @@ -766,6 +770,20 @@ Other Library… Druga biblioteka… Graphic Style Libraries Biblioteke grafičkih stilova Shadows and Glows Sjene i sjajevi + Game HUD Igraći HUD + Health Fill Zdravlje + Mana Glow Mana + Damage Flash Šteta + Quest Outline Zadatak + Coin Shine Novčić + Boss Alert Bos + Status Effects Statusni efekti + Poison Tint Otrov + Freeze Edge Zamrzavanje + Burn Aura Izgaranje + Stun Pulse Omamljenost + Stealth Fade Nevidljivost + Shield Buff Štit Outlines and Rules Obrisi i linije Hand-Drawn Ručno crtano Gradient Finishes Gradijentne završnice @@ -2248,6 +2266,8 @@ digits Malayalam Malajalam Green Zelena Shifts the tints of global and spot colors; other colors stay. Pomjera nijanse globalnih i spot boja; ostale boje ostaju. Tint Preliv + Fill Tint Preliv ispune + Stroke Tint Preliv poteza Color Guide Options Opcije vodiča za boje Less Manje Steps: Koraci: @@ -2615,6 +2635,7 @@ digits Malayalam Malajalam File Dimensions Dimenzije datoteke Transforms Transformacije Background Color: Boja pozadine: + Guide Label: Oznaka vodilica: Show Center Mark Prikaži oznaku centra Show Cross Hairs Prikaži končanicu Show Video Safe Areas Prikaži sigurne oblasti za video @@ -2858,6 +2879,7 @@ antiAlias Sharp Oštro General › Thumbnails Općenito › Sličice Grayscale images Slike u nijansama sive Include non-printing layers Uključi slojeve koji se ne štampaju + Write pages longer than 200 in at full size in points Upiši stranice duže od 200 in u punoj veličini, u tačkama Left: Lijevo: Mark as trapped Označi kao zarobljeno Marks and Bleeds Oznake i napusti @@ -3705,6 +3727,12 @@ theme Appearance Izgled Real-time Drawing and Editing Crtanje i uređivanje u stvarnom vremenu Render Threads (-1 = Automatic) Niti iscrtavanja (-1 = automatski) Reset Preferences Vrati postavke na početne + Export Preferences… Izvezi postavke… + Import Preferences… Uvezi postavke… + Save the applied preferences to a JSON file Sačuvaj primijenjene postavke u JSON datoteku + Apply the preferences in a file Export Preferences saved Primijeni postavke iz datoteke sačuvane naredbom Izvezi postavke + Export Preferences Izvezi postavke + Import Preferences Uvezi postavke Search Preferences Pretraga postavki No settings found Nema pronađenih postavki Scale Corners Skaliraj uglove @@ -4084,6 +4112,8 @@ font-technology Variable Varijabilni Preferences Postavke Previous Prethodno Restore every preference to its default (applied on OK) Vrati svaku postavku na zadanu vrijednost (primjenjuje se na U redu) + Reset all preferences to their defaults? Vratiti sve postavke na zadane vrijednosti? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Zadane vrijednosti primjenjuju se kada kliknete U redu u Postavkama. Otkaži tamo zadržava vaše trenutne postavke. Smaller ↔ Larger Manje ↔ veće Smart Guides (View → Smart Guides) Pametne vodilice (Prikaz → Pametne vodilice) Couldn't save recovery data: {e} Nije moguće sačuvati podatke za oporavak: {e} @@ -4324,6 +4354,7 @@ imageTrace Outline Obris Stop Script Zaustavi skriptu Show External Script Warning Prikaži upozorenje za vanjske skripte A Script Is Running Skripta se izvršava + Script Alert Upozorenje skripte A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skripta može mijenjati otvorene dokumente i pisati datoteke putem naredbi koje pokreće. Pokrećite samo skripte kojima vjerujete. Do not show this warning again Ne prikazuj više ovo upozorenje Stop the script and undo what it did? Then quit again. Zaustaviti skriptu i poništiti ono što je uradila? Zatim ponovo izađite. @@ -4898,6 +4929,20 @@ imageTrace Outline Obris @msg unknown overprints `{o}` (preserve, simulate or discard) nepoznati pretisci `{o}` (preserve, simulate ili discard) @msg unknown panel `{raw}` nepoznat panel `{raw}` @msg unknown preference `{key}` nepoznata postavka `{key}` +@msg Preferences imported. Postavke su uvezene. +@msg not a preferences file: {e} nije datoteka postavki: {e} +@msg not a preferences file: no JSON object nije datoteka postavki: nema JSON objekta +@msg not a preferences file (format `{format}`) nije datoteka postavki (format `{format}`) +@msg not a preferences file: no `format` nije datoteka postavki: nema `format` +@msg not a preferences file: no `version` nije datoteka postavki: nema `version` +@msg not a preferences file: no `prefs` nije datoteka postavki: nema `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) datoteku je sačuvala novija verzija programa VectorCraft (verzija {v}) +@msg the file holds no preferences datoteka ne sadrži postavke +@msg `{key}` names a folder, which preferences files leave out `{key}` navodi folder, a datoteke postavki ne sadrže foldere +@msg the file is larger than 1 MB datoteka je veća od 1 MB +@msg give `path` or `data` navedite `path` ili `data` +@msg `{key}` must be an object `{key}` mora biti objekat +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` se pokreće samostalno, ne kao korak paketa @msg unknown preset (one of {_1}) nepoznat predložak (jedan od {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) nepoznat predložak `{name}` (high, medium, low ili sačuvani: vidi flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) nepoznat predložak `{name}` (vidi flattener.presets.list) @@ -5067,6 +5112,8 @@ imageTrace Outline Obris @msg invalid object id {value}: expected a non-negative integer nevažeći ID objekta {value}: očekuje se nenegativan cijeli broj @msg `{key}` entry {index} is {value}, not a non-negative integer object id stavka {index} u `{key}` je {value}, a ne ID objekta (nenegativan cijeli broj) @msg no such object id {value} nema objekta s ID-om {value} +@msg id {value} is a ruler guide, not an object ID {value} je vodilica, a ne objekat +@msg a ruler guide goes only directly in a layer or sublayer vodilica može biti samo direktno u sloju ili podsloju @msg id {value} is a layer, not an object id {value} je sloj, a ne objekat @msg id {value} is a layer, not an object; move layers with layer.move id {value} je sloj, a ne objekat; slojeve pomjerajte s layer.move @msg automation filesystem access is not granted: read authority is absent: {path} pristup datotečnom sistemu za automatizaciju nije odobren: nema ovlaštenja za čitanje: {path} @@ -5438,6 +5485,7 @@ file-filter Swatch libraries Biblioteke uzoraka boja file-filter Flattener presets Predlošci spljoštavanja file-filter PDF presets PDF predlošci file-filter Print presets Predlošci štampanja +file-filter Preferences Postavke file-filter Export presets Predlošci izvoza file-filter SVG Compressed Komprimirani SVG file-filter PDF-compatible .ai .ai kompatibilan s PDF-om @@ -5497,7 +5545,8 @@ mark-style Japanese Japanski Bevel both sides Zakošenje s obje strane Presets Unaprijed postavljene vrijednosti @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objekat prima jedan 3D efekat. Uredite opcije postojećeg efekta ili ga prvo uklonite. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} trenutno zahtijeva putanju ili složenu putanju. Prvo pretvorite tekst, slike i grupe u putanje. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} trenutno zahtijeva putanju, složenu putanju, tekst, grupu, stapanje ili instancu simbola. Prvo proširite ostale objekte. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ne može napraviti tijelo od slike: prvo je pratite pomoću Objekat › Praćenje slike › Napravi. @msg {label} requires a shape with at least two anchors {label} zahtijeva oblik s najmanje dva sidra @msg The shape is too complex for a 3D effect Oblik je previše složen za 3D efekat @msg The 3D surface is too complex; simplify the shape 3D površina je previše složena; pojednostavite oblik @@ -5543,6 +5592,7 @@ mark-style Japanese Japanski Preview (1 inch on the page) Pregled (1 inč na stranici) Set as Default Postavi kao zadano out from a highlighted edge to place its dimension | od istaknute ivice da postavite njenu kotu | + two points, then move and click to place an aligned, horizontal or vertical dimension | na dvije tačke, zatim pomjerite i kliknite da postavite poravnatu, horizontalnu ili vertikalnu kotu | cancels | Double-click the tool for options otkazuje | Dvostruki klik na alat za opcije Angular Ugaono Away from the Line Dalje od linije @@ -5571,14 +5621,17 @@ mark-style Japanese Japanski @msg Invalid drawing scale Nevažeća skala crteža @msg Invalid real scale Nevažeća stvarna skala @msg Measured anchor no longer exists Izmjereno sidro više ne postoji +@msg Invalid measured point Neispravna izmjerena tačka @msg Measured segment no longer exists Izmjereni segment više ne postoji @msg Non-finite dimension Nekonačna kota +@msg Only linear dimensions are horizontal or vertical Samo linearne kote su horizontalne ili vertikalne @msg Scale Text Attributes: selected type has no scale to bake Skaliraj atribute teksta: odabrani tekst nema skalu za primjenu @msg Scale must be drawing:real, for example 1:10 Skala mora biti crtež:stvarno, npr. 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Omjer skale mora biti između 1:1000000 i 1000000:1 @msg Second measured segment no longer exists Drugi izmjereni segment više ne postoji @msg Unknown dimension mode Nepoznat način kote @msg Unknown dimension option {k} Nepoznata opcija kote {k} +@msg Unknown dimension orientation Nepoznata orijentacija kote @msg Unknown dimension style Nepoznat stil kote @msg Unknown unit Nepoznata jedinica Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Zatvori kružne krajeve otvorenih profila. Djelimična izvlačenja zadržavaju otvorene rezane strane. @@ -5817,6 +5870,7 @@ axis Y Axis Y osa Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika Keep settings of its own Čuvati vlastite postavke Keep a cache of its own, which you can clear Čuvati vlastitu predmemoriju koju možete očistiti + Run these commands on the document: {ids} Izvršavati ove naredbe na dokumentu: {ids} Clear Plug-in Cache Očisti predmemoriju dodatka @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `{permission}` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature} @@ -5888,6 +5942,23 @@ axis Y Axis Y osa Button Dugme Panel Panel UI Frames UI okviri + Map Markers Markeri mape + Pin Pin + Flag Zastava + Crosshair Nišan + Checkpoint Kontrolna tačka + Inventory Icons Ikone inventara + Skill Icons Ikone vještina + Attack Napad + Defend Odbrana + Heal Iscjeljenje + Buff Pojačanje + Ult Ult + Sword Mač + Shield Štit + Potion Napitak + Key Ključ + Gem Dragulj Open Symbol Library Otvori biblioteku simbola Save Symbol Library Sačuvaj biblioteku simbola Save Symbol Library… Sačuvaj biblioteku simbola… @@ -5942,6 +6013,16 @@ axis Y Axis Y osa User Brush Library 9 Korisnička biblioteka četkica 9 User Brush Library 10 Korisnička biblioteka četkica 10 Art Ribbons Umjetničke trake + Zigzag Cik-cak + Burst Ray Zraka udara + Dash Trail Isprekidani trag + Speed Line Linija brzine + Game Trails Tragovi igre + Energy Beams Energetski snopovi + Laser Bolt Laserski metak + Plasma Arc Plazma luk + Charge Wave Talas naboja + Spark Trail Trag iskri Calligraphic Nibs Kaligrafski vrhovi Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici @@ -6491,3 +6572,30 @@ shape Open Otvoren # Align to Pixel Grid Align to Pixel Grid Poravnaj s mrežom piksela @msg side is left or right, not `{s}` side je left ili right, a ne `{s}` + Edit Custom Dictionary Uredi prilagođeni rječnik + Entry: Unos: +# Script Console + Script Console Konzola skripti + Clear Script Console Očisti konzolu skripti + Copy Script Console Kopiraj konzolu skripti + Copy All Kopiraj sve + Copy Selected Kopiraj odabrano + Finished Završeno + Ended with an error Završeno s greškom + Out of fuel Dostignuto ograničenje instrukcija + Ended at a host limit Završeno na ograničenju hosta + Ended at a limit Završeno na ograničenju + Stopped Zaustavljeno + Ended by an internal fault Završeno zbog interne greške + time unavailable vrijeme nije dostupno + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} najsporije: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} poziv hosta|{n} poziva hosta|{n} poziva hosta +@plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno +@plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno + +# Deferred plug-in effects + Refresh Deferred Effects Osvježi odgođene efekte + Deferred Effects Odgođeni efekti diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 91169aaa4..9c60e87e0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -35,6 +35,7 @@ on the canvas to create one al llenç per crear-ne un on the canvas to create one ( al llenç per crear-ne un ( out from a highlighted edge to place its dimension | des d’una vora ressaltada cap enfora per col·locar-ne la dimensió | + two points, then move and click to place an aligned, horizontal or vertical dimension | dos punts, després moveu i feu clic per col·locar una dimensió alineada, horitzontal o vertical | over paths to roughen their outlines | sobre traçats per fer aspres els contorns | over paths, and hold still to keep going | sobre traçats i mantén el punter quiet per continuar | right or left to zoom in or out | a la dreta o a l’esquerra per ampliar o reduir | @@ -362,6 +363,16 @@ antiAlias Sharp Nítid Art Brush Options Opcions del pinzell artístic Art Optimized Optimitzat per a l’art Art Ribbons Cintes d’art + Zigzag Zigzag + Burst Ray Raig d’impacte + Dash Trail Estela discontínua + Speed Line Línia de velocitat + Game Trails Esteles de joc + Energy Beams Feixos d'energia + Laser Bolt Projectil làser + Plasma Arc Arc de plasma + Charge Wave Ona de càrrega + Spark Trail Estela d'espurnes Art using a deleted global swatch keeps its colour. L’art que utilitza una mostra global suprimida conserva el seu color. Artboard Tauler Artboard Options Opcions del tauler @@ -422,6 +433,7 @@ antiAlias Sharp Nítid B5 B5 Back Enrere Background Color: Color de fons: + Guide Label: Etiqueta de les guies: Background Contents Contingut del fons Background Level Nivell de fons Background: Fons: @@ -443,6 +455,23 @@ antiAlias Sharp Nítid Button Botó Panel Panell UI Frames Marcs d’IU + Map Markers Marcadors de mapa + Pin Xinxeta + Flag Bandera + Crosshair Punt de mira + Checkpoint Punt de control + Inventory Icons Icones d’inventari + Skill Icons Icones d'habilitat + Attack Atac + Defend Defensa + Heal Curació + Buff Potenciació + Ult Ult + Sword Espasa + Shield Escut + Potion Poció + Key Clau + Gem Gemm Batch Lot Batch… Lot… Bearing Orientació @@ -2027,6 +2056,7 @@ digits Malayalam Malaiàlam Include Linked Files Inclou els fitxers enllaçats Include Metadata Inclou les metadades Include non-printing layers Inclou les capes no imprimibles + Write pages longer than 200 in at full size in points Escriu les pàgines de més de 200 in a mida real, en punts Include Objects On Canvas Inclou els objectes del llenç Include SVG Code Inclou codi SVG Include Swatches for Tints Inclou mostres per als tons @@ -2339,6 +2369,7 @@ digits Malayalam Malaiàlam Lock Grid Bloca la quadrícula Lock Guides Bloca les guies Lock Guides by Default Bloca les guies per defecte + Artboard Guides with Cmd/Ctrl + Ruler Drag Guies del tauler amb Cmd/Ctrl + arrossegament des de la regla Lock Others Bloca la resta Lock Selected Bloca la selecció Lock Slices Bloca els talls @@ -2853,6 +2884,8 @@ digits Malayalam Malaiàlam Open… Obre… Optical Òptic Optical Margin Alignment Alineació òptica de marges + Dynamic Text Text dinàmic +@msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames} Optimize for fast web view Optimitza per a la visualització web ràpida Optimized Optimitzat Options Opcions @@ -3464,6 +3497,12 @@ digits Malayalam Malaiàlam Reset Painting Restableix Pintura Reset Panel Restableix el panell Reset Preferences Restableix les preferències + Export Preferences… Exporta les preferències… + Import Preferences… Importa les preferències… + Save the applied preferences to a JSON file Desa les preferències aplicades en un fitxer JSON + Apply the preferences in a file Export Preferences saved Aplica les preferències d’un fitxer desat amb Exporta les preferències + Export Preferences Exporta les preferències + Import Preferences Importa les preferències Search Preferences Cerca a les preferències No settings found No s'ha trobat cap paràmetre Reset Printing and Proofing Restableix impressió i prova @@ -3501,6 +3540,8 @@ digits Malayalam Malaiàlam Responsive Adaptatiu Restore Restaura Restore every preference to its default (applied on OK) Restaura cada preferència al seu valor predeterminat (s’aplica en prémer D’acord) + Reset all preferences to their defaults? Voleu restablir totes les preferències als valors predeterminats? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Els valors predeterminats s’apliquen quan feu clic a D’acord a Preferències. Cancel·la allà conserva les preferències actuals. Restore Recovered Documents Restaura els documents recuperats Restored documents open unsaved: save them to keep them. Els documents restaurats s’obren sense desar: desa’ls per conservar-los. Restrict printing, editing and other tasks Restringeix la impressió, l’edició i altres tasques @@ -3921,6 +3962,20 @@ digits Malayalam Malaiàlam Shading Sombra Shadow Intensity Intensitat de l’ombra Shadows and Glows Ombres i resplendors + Game HUD HUD de joc + Health Fill Salut + Mana Glow Mana + Damage Flash Dany + Quest Outline Missió + Coin Shine Moneda + Boss Alert Cap + Status Effects Efectes d’estat + Poison Tint Verí + Freeze Edge Congelació + Burn Aura Cremada + Stun Pulse Atordiment + Stealth Fade Sigil + Shield Buff Escut Shape Forma Shape Builder Constructor de formes Shape Builder Delete Suprimeix del constructor de formes @@ -4450,6 +4505,8 @@ digits Malayalam Malaiàlam Tilt Inclinació Time (hhmmss) Hora (hhmmss) Tint To + Fill Tint To de farciment + Stroke Tint To del traç Tint Reduction Reducció de to Tints Tons Tints and Shades Tons i ombres @@ -5102,6 +5159,7 @@ digits Malayalam Malaiàlam @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis ha de ser "vertical", "horizontal" o un angle en graus, no `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" fons {other}: transparent, blanc, negre o un color com "#ff8800" @msg bad color {v} color incorrecte {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` és null, el nom d’un color de capa o #rrggbb @msg bad colour `{hex}` (#rrggbb) color incorrecte `{hex}` (#rrggbb) @msg bad page tree arbre de pàgines incorrecte @msg bad shortcut drecera incorrecta @@ -5159,6 +5217,7 @@ digits Malayalam Malaiàlam Stop Script Atura l'script Show External Script Warning Mostra l'avís dels scripts externs A Script Is Running S'està executant un script + Script Alert Avís de l'script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script pot canviar els documents oberts i escriure fitxers mitjançant les ordres que executa. Executeu només scripts de confiança. Do not show this warning again No tornis a mostrar aquest avís Stop the script and undo what it did? Then quit again. Voleu aturar l'script i desfer el que ha fet? Després, torneu a sortir. @@ -5172,6 +5231,9 @@ digits Malayalam Malaiàlam @msg the script is too large to run l'script és massa gran per executar-lo @msg a drag or an undo group is open; finish it before running a script hi ha un arrossegament o un grup de desfer obert; acabeu-lo abans d'executar un script @msg the script left a document that cannot be kept l'script ha deixat un document que no es pot conservar +@msg the script is no longer in the Scripts folder l'script ja no és a la carpeta Scripts + Some scripts were not listed Alguns scripts no s'han llistat + The Scripts folder cannot be read No es pot llegir la carpeta Scripts @msg the script's run is no longer available l'execució de l'script ja no està disponible @msg a failed command changed undo steps that could not be put back, so the script was stopped una ordre fallida ha canviat passos de desfer que no s'han pogut restaurar, així que l'script s'ha aturat @msg a recovery copy could not be written, so the script was not run no s'ha pogut escriure una còpia de recuperació, per tant l'script no s'ha executat @@ -5275,6 +5337,11 @@ digits Malayalam Malaiàlam @msg Invalid panel layout; restored the default dock Disposició de panells no vàlida; s’ha restaurat l’acoblament per defecte @msg invalid parameters for `{cmd}`: {msg} paràmetres no vàlids per a `{cmd}`: {msg} @msg invalid PDF setting: {0} configuració del PDF no vàlida: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): s’escriu amb un UserUnit de {unit}, de manera que les aplicacions que ignoren UserUnit la mostren tantes vegades més petita +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): les pàgines {label} s’escriuen sense UserUnit, en punts, i les aplicacions que respecten el límit retallen la pàgina a 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 el PDF 1.5 no té UserUnit, que una pàgina de més de 14.400 pt (200 in) necessita per mantenir la mida: el fitxer s’escriu com a PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la pàgina del tauler {n} fa {size}, massa llarga i estreta per als costats de pàgina del PDF de 3 a 14.400 unitats: s'escriu amb una UserUnit de {unit}, que manté el costat curt en 3 unitats o més, i les aplicacions que respecten el límit retallen el costat llarg a 14.400 unitats +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard els fitxers {label} admeten nombres reals fins a {max}, i la pàgina del tauler {n}, de {side} pt, en necessita de més grans: trieu PDF 1.6 o posterior sense estàndard PDF/X, o un tauler més petit @msg Invalid real scale Escala real no vàlida @msg invalid restored panel group grup de panells restaurat no vàlid @msg invalid stroke widths amplades de traç no vàlides @@ -5334,6 +5401,7 @@ digits Malayalam Malaiàlam @msg Make Guides: select paths Crea guies: seleccioneu traçats @msg mask art ends outside a mask dictionary l’art de la màscara acaba fora d’un diccionari de màscara @msg Measured anchor no longer exists El punt d’ancoratge mesurat ja no existeix +@msg Invalid measured point El punt mesurat no és vàlid @msg Measured segment no longer exists El segment mesurat ja no existeix @msg missing `folder` falta `folder` @msg missing `id` (see plugin.list) falta `id` (vegeu plugin.list) @@ -5464,6 +5532,8 @@ digits Malayalam Malaiàlam @msg no such document no existeix aquest document @msg no such layer or object no existeix aquesta capa o objecte @msg no such object id {value} no existeix l’id d’objecte {value} +@msg id {value} is a ruler guide, not an object l’id {value} és una guia, no un objecte +@msg a ruler guide goes only directly in a layer or sublayer una guia només pot anar directament en una capa o subcapa @msg no such object {0} no existeix l’objecte {0} @msg no such perspective grid preset no existeix aquest preajust de quadrícula de perspectiva @msg no such recent file no existeix aquest fitxer recent @@ -5493,6 +5563,7 @@ digits Malayalam Malaiàlam @msg node {_1} is not text el node {_1} no és text @msg node {_1} is on a locked artboard el node {_1} és en un tauler bloquejat @msg Non-finite dimension Cota no finita +@msg Only linear dimensions are horizontal or vertical Només les dimensions lineals són horitzontals o verticals @msg not a blend no és una fusió @msg not a DXF drawing no és un dibuix DXF @msg not a file no és un fitxer @@ -5863,6 +5934,7 @@ digits Malayalam Malaiàlam @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode control desconegut `{name}`: fillStroke, coloring, drawMode o screenMode @msg Unknown dimension mode Mode de dimensió desconegut @msg Unknown dimension option {k} Opció de dimensió desconeguda {k} +@msg Unknown dimension orientation Orientació de dimensió desconeguda @msg Unknown dimension style Estil de dimensió desconegut @msg unknown effect `{id}` efecte desconegut `{id}` @msg unknown format format desconegut @@ -5882,6 +5954,20 @@ digits Malayalam Malaiàlam @msg missing `{path}` falta `{path}` @msg `{path}` must be {expected}, not {got} `{path}` ha de ser {expected}, no {got} @msg unknown preference `{key}` preferència desconeguda `{key}` +@msg Preferences imported. S’han importat les preferències. +@msg not a preferences file: {e} no és un fitxer de preferències: {e} +@msg not a preferences file: no JSON object no és un fitxer de preferències: no hi ha cap objecte JSON +@msg not a preferences file (format `{format}`) no és un fitxer de preferències (format `{format}`) +@msg not a preferences file: no `format` no és un fitxer de preferències: no hi ha `format` +@msg not a preferences file: no `version` no és un fitxer de preferències: no hi ha `version` +@msg not a preferences file: no `prefs` no és un fitxer de preferències: no hi ha `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) el fitxer s’ha desat amb una versió més nova del VectorCraft (versió {v}) +@msg the file holds no preferences el fitxer no conté cap preferència +@msg `{key}` names a folder, which preferences files leave out `{key}` indica una carpeta, i els fitxers de preferències no inclouen carpetes +@msg the file is larger than 1 MB el fitxer ocupa més d’1 MB +@msg give `path` or `data` indiqueu `path` o `data` +@msg `{key}` must be an object `{key}` ha de ser un objecte +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` s’executa sol, no com a pas d’un lot @msg unknown preset (one of {_1}) preajust desconegut (un de {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preajust desconegut `{name}` (high, medium, low o un de desat: consulta flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) preajust desconegut `{name}` (consulta flattener.presets.list) @@ -5939,7 +6025,8 @@ digits Malayalam Malaiàlam @msg {key} must be a finite coordinate {key} ha de ser una coordenada finita @msg {key} must be an array of non-negative integer object ids {key} ha de ser una matriu d’identificadors d’objecte enters no negatius @msg {key} must be Unix seconds (an integer) or null {key} ha de ser segons Unix (un enter) o null -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} actualment requereix un traçat, un traçat compost, text o un grup. Converteix primer les imatges i altres objectes en traçats. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} actualment requereix un traçat, un traçat compost, text, un grup, una fusió o una instància de símbol. Expandeix primer els altres objectes. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} no pot crear un sòlid a partir d’una imatge: traça-la primer amb Objecte › Traçat d’imatge › Crea. @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} conserva 1 dels {n} taulers del document: el fitxer es reobre amb un. Activa Conserva les capacitats d’edició per conservar-los tots. @msg {label} produced an empty result {label} ha produït un resultat buit @msg {label} requires a shape with at least two anchors {label} requereix una forma amb com a mínim dos punts d’ancoratge @@ -5974,6 +6061,20 @@ digits Malayalam Malaiàlam Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre Keep settings of its own Desar una configuració pròpia Keep a cache of its own, which you can clear Desar una memòria cau pròpia, que podeu buidar + Run these commands on the document: {ids} Executar aquestes ordres al document: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call «{name}» inclou `{id}` a `commandAllowlist`, que un complement no pot cridar +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: el `commandAllowlist` del manifest no la inclou +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: l’ID o els arguments són fora de la memòria del mòdul +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: l’ID o els arguments no són UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: els arguments no són un objecte JSON +@msg only a filter with the usual budget declares `commandAllowlist` només un filtre amb el pressupost habitual declara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) crides d’ordres ({MAX_CALLS} crides i {MAX_CALL_BYTES} bytes per execució) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) mida d’una crida d’ordre (vc_call `{shown}`: {len} bytes, límit {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` com a màxim {MAX_ALLOWED_COMMANDS} ordres a `commandAllowlist` +@msg a plug-in may not call this command un complement no pot cridar aquesta ordre +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` necessita el permís `commands`, i `commands` necessita un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` inclou `{shown}`, que no és un ID d’ordre o hi apareix dues vegades Clear Plug-in Cache Buida la memòria cau del complement @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `{permission}` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature} @@ -6029,6 +6130,7 @@ file-filter Flattener presets Preajustos de l’aplanador file-filter PDF presets Preajustos de PDF file-filter PDF-compatible .ai .ai compatible amb PDF file-filter Print presets Preajustos d’impressió +file-filter Preferences Preferències file-filter Export presets Preajustos d’exportació file-filter SVG Compressed SVG comprimit file-filter Swatch libraries Biblioteques de mostres @@ -6675,3 +6777,33 @@ breakText Glyphs Glifs Ignore All Ignora-ho tot Add to Dictionary Afegeix al diccionari No Suggestions No hi ha suggeriments + Edit Custom Dictionary Edita el diccionari personalitzat + Entry: Entrada: +# Script Console + Script Console Consola d’scripts + Clear Script Console Esborra la consola d’scripts + Copy Script Console Copia la consola d’scripts + Copy All Copia-ho tot + Copy Selected Copia la selecció + Finished Acabat + Ended with an error Acabat amb un error + Out of fuel Límit d’instruccions esgotat + Ended at a host limit Acabat en un límit de l’amfitrió + Ended at a limit Acabat en un límit + Stopped Aturat + Ended by an internal fault Acabat per un error intern + time unavailable temps no disponible + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} més lentes: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} crida a l’amfitrió|{n} crides a l’amfitrió +@plural {n} earlier line dropped|{n} earlier lines dropped {n} línia anterior descartada|{n} línies anteriors descartades +@plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last` + +# Deferred plug-in effects + Refresh Deferred Effects Actualitza els efectes ajornats + Deferred Effects Efectes ajornats +@msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 91d86612c..e8f9666fa 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -406,6 +406,8 @@ Smart Punctuation… Inteligentní interpunkce… Create Outlines Převést na křivky Optical Margin Alignment Optické zarovnání okrajů + Dynamic Text Dynamický text +@msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames} Fill with Placeholder Text Vyplnit zástupným textem Insert Inline Symbol Vložit symbol do textu Show Hidden Characters Zobrazit skryté znaky @@ -610,6 +612,7 @@ Show Guides Zobrazit vodítka Lock Guides Zamknout vodítka Lock Guides by Default Ve výchozím stavu zamknout vodítka + Artboard Guides with Cmd/Ctrl + Ruler Drag Vodítka pracovní plochy tažením z pravítka s Cmd/Ctrl Unlock Guides Odemknout vodítka Make Guides Vytvořit vodítka Release Guides Uvolnit vodítka @@ -775,6 +778,20 @@ Other Library… Jiná knihovna… Graphic Style Libraries Knihovny grafických stylů Shadows and Glows Stíny a záře + Game HUD Herní HUD + Health Fill Život + Mana Glow Mana + Damage Flash Poškození + Quest Outline Úkol + Coin Shine Mince + Boss Alert Boss + Status Effects Stavové efekty + Poison Tint Jed + Freeze Edge Zmrazení + Burn Aura Spálení + Stun Pulse Omráčení + Stealth Fade Plížení + Shield Buff Štít Outlines and Rules Obrysy a linky Hand-Drawn Ručně kreslené Gradient Finishes Přechodové povrchy @@ -2277,6 +2294,8 @@ digits Malayalam Malajálamské Green Zelená Shifts the tints of global and spot colors; other colors stay. Posune tóny globálních a přímých barev; ostatní barvy zůstanou. Tint Tón + Fill Tint Tón výplně + Stroke Tint Tón tahu Color Guide Options Možnosti průvodce barvami Less Méně Steps: Kroky: @@ -2646,6 +2665,7 @@ digits Malayalam Malajálamské File Dimensions Rozměry souboru Transforms Transformace Background Color: Barva pozadí: + Guide Label: Štítek vodítek: Show Center Mark Zobrazit středovou značku Show Cross Hairs Zobrazit nitkový kříž Show Video Safe Areas Zobrazit bezpečné oblasti videa @@ -2890,6 +2910,7 @@ antiAlias Sharp Ostré General › Thumbnails Obecné › Miniatury Grayscale images Obrazy ve stupních šedi Include non-printing layers Zahrnout netisknutelné vrstvy + Write pages longer than 200 in at full size in points Zapsat stránky delší než 200 in v plné velikosti v bodech Left: Vlevo: Mark as trapped Označit jako s přesahy barev Marks and Bleeds Značky a spadávky @@ -3757,6 +3778,12 @@ theme Appearance Motiv Real-time Drawing and Editing Kreslení a úpravy v reálném čase Render Threads (-1 = Automatic) Vlákna vykreslování (-1 = automaticky) Reset Preferences Obnovit předvolby + Export Preferences… Exportovat předvolby… + Import Preferences… Importovat předvolby… + Save the applied preferences to a JSON file Uložit použité předvolby do souboru JSON + Apply the preferences in a file Export Preferences saved Použít předvolby ze souboru uloženého příkazem Exportovat předvolby + Export Preferences Exportovat předvolby + Import Preferences Importovat předvolby Search Preferences Hledat v předvolbách No settings found Nenalezena žádná nastavení Scale Corners Měnit velikost rohů @@ -4136,6 +4163,8 @@ font-technology Variable Variabilní Preferences Předvolby Previous Předchozí Restore every preference to its default (applied on OK) Obnovit všechny předvolby na výchozí hodnoty (použije se po OK) + Reset all preferences to their defaults? Obnovit všechny předvolby na výchozí hodnoty? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Výchozí hodnoty se použijí po klepnutí na OK v Předvolbách. Zrušit tam zachová vaše současné předvolby. Smaller ↔ Larger Menší ↔ Větší Smart Guides (View → Smart Guides) Chytrá vodítka (Zobrazení → Chytrá vodítka) Couldn't save recovery data: {e} Data pro obnovení nelze uložit: {e} @@ -4382,6 +4411,7 @@ imageTrace Outline Obrys Stop Script Zastavit skript Show External Script Warning Zobrazit upozornění u externích skriptů A Script Is Running Běží skript + Script Alert Upozornění skriptu A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skript může měnit otevřené dokumenty a prostřednictvím příkazů, které spouští, zapisovat soubory. Spouštějte jen skripty, kterým důvěřujete. Do not show this warning again Toto upozornění již nezobrazovat Stop the script and undo what it did? Then quit again. Zastavit skript a vrátit jeho změny? Potom aplikaci ukončete znovu. @@ -4395,6 +4425,9 @@ imageTrace Outline Obrys @msg the script is too large to run skript je příliš velký na spuštění @msg a drag or an undo group is open; finish it before running a script je otevřené tažení nebo skupina kroků zpět; před spuštěním skriptu ji dokončete @msg the script left a document that cannot be kept skript zanechal dokument, který nelze zachovat +@msg the script is no longer in the Scripts folder skript už není ve složce Scripts + Some scripts were not listed Některé skripty nebyly uvedeny + The Scripts folder cannot be read Složku Scripts nelze přečíst @msg the script's run is no longer available běh skriptu již není k dispozici @msg a failed command changed undo steps that could not be put back, so the script was stopped neúspěšný příkaz změnil kroky zpět, které nebylo možné obnovit, proto byl skript zastaven @msg a recovery copy could not be written, so the script was not run záložní kopii se nepodařilo zapsat, proto skript nebyl spuštěn @@ -4578,6 +4611,7 @@ imageTrace Outline Obrys @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" pozadí {other}: transparent, white, black nebo barva jako "#ff8800" @msg bad color {v} chybná barva {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` je null, název barvy vrstvy nebo #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musí být číslo od 0,1 do 10 @msg the Color Picker is not open Výběr barvy není otevřený @msg the Simplify slider is not open Posuvník Zjednodušit není otevřený @@ -4641,6 +4675,11 @@ imageTrace Outline Obrys @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) vnitřní chyba v `{cmd}`: {msg} (dokument zůstal ve stavu před příkazem; nahlaste prosím tuto chybu) @msg internal error: {msg} (please report this bug) vnitřní chyba: {msg} (nahlaste prosím tuto chybu) @msg invalid PDF setting: {0} neplatné nastavení PDF: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): zapisuje se s UserUnit {unit}, takže aplikace, které UserUnit ignorují, ji zobrazí tolikrát menší +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): stránky {label} se zapisují bez UserUnit, v bodech, a aplikace, které limit dodržují, stránku oříznou na 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nemá UserUnit, který stránka delší než 14 400 pt (200 in) potřebuje, aby si zachovala velikost: soubor se zapíše jako PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units stránka pracovní plochy {n} má rozměr {size} a je příliš dlouhá a úzká pro strany stránky PDF od 3 do 14 400 jednotek: zapisuje se s UserUnit {unit}, která drží kratší stranu na 3 jednotkách nebo více, a aplikace, které limit dodržují, ořežou delší stranu na 14 400 jednotek +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard soubory {label} pojmou reálná čísla nejvýše {max} a stránka pracovní plochy {n}, dlouhá {side} pt, potřebuje větší: zvolte PDF 1.6 nebo novější bez standardu PDF/X, nebo menší pracovní plochu @msg invalid image data for `{0}` neplatná obrazová data pro `{0}` @msg invalid parameters for `{cmd}`: {msg} neplatné parametry pro `{cmd}`: {msg} @msg is not an image VectorCraft reads není obraz, který VectorCraft umí přečíst @@ -4977,6 +5016,20 @@ imageTrace Outline Obrys @msg unknown overprints `{o}` (preserve, simulate or discard) neznámé přetisky `{o}` (preserve, simulate nebo discard) @msg unknown panel `{raw}` neznámý panel `{raw}` @msg unknown preference `{key}` neznámá předvolba `{key}` +@msg Preferences imported. Předvolby byly importovány. +@msg not a preferences file: {e} nejde o soubor předvoleb: {e} +@msg not a preferences file: no JSON object nejde o soubor předvoleb: chybí objekt JSON +@msg not a preferences file (format `{format}`) nejde o soubor předvoleb (formát `{format}`) +@msg not a preferences file: no `format` nejde o soubor předvoleb: chybí `format` +@msg not a preferences file: no `version` nejde o soubor předvoleb: chybí `version` +@msg not a preferences file: no `prefs` nejde o soubor předvoleb: chybí `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) soubor byl uložen novější verzí aplikace VectorCraft (verze {v}) +@msg the file holds no preferences soubor neobsahuje žádné předvolby +@msg `{key}` names a folder, which preferences files leave out `{key}` určuje složku a soubory předvoleb složky neobsahují +@msg the file is larger than 1 MB soubor je větší než 1 MB +@msg give `path` or `data` zadejte `path` nebo `data` +@msg `{key}` must be an object `{key}` musí být objekt +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` se spouští samostatně, ne jako krok dávky @msg unknown preset (one of {_1}) neznámé přednastavení (jedno z {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) neznámé přednastavení `{name}` (high, medium, low nebo uložené: viz flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) neznámé přednastavení `{name}` (viz flattener.presets.list) @@ -5157,6 +5210,8 @@ imageTrace Outline Obrys @msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo @msg `{key}` entry {index} is {value}, not a non-negative integer object id položka {index} v `{key}` je {value}, nikoli ID objektu (nezáporné celé číslo) @msg no such object id {value} objekt s ID {value} neexistuje +@msg id {value} is a ruler guide, not an object ID {value} patří vodítku, ne objektu +@msg a ruler guide goes only directly in a layer or sublayer vodítko může být jen přímo ve vrstvě nebo podvrstvě @msg id {value} is a layer, not an object ID {value} je vrstva, nikoli objekt @msg id {value} is a layer, not an object; move layers with layer.move ID {value} je vrstva, nikoli objekt; vrstvy přesunete pomocí layer.move @msg automation filesystem access is not granted: read authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění ke čtení: {path} @@ -5548,6 +5603,7 @@ file-filter Swatch libraries Knihovny vzorníků file-filter Flattener presets Přednastavení sloučení průhlednosti file-filter PDF presets Přednastavení PDF file-filter Print presets Přednastavení tisku +file-filter Preferences Předvolby file-filter Export presets Přednastavení exportu file-filter SVG Compressed Komprimované SVG file-filter PDF-compatible .ai .ai kompatibilní s PDF @@ -5609,7 +5665,8 @@ mark-style Japanese Japonské Bevel both sides Zkosení na obou stranách Presets Předvolby @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objekt může mít jen jeden 3D efekt. Upravte volby stávajícího efektu, nebo jej nejprve odstraňte. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} zatím vyžaduje cestu, složenou cestu, text nebo skupinu. Obrazy a další objekty nejprve převeďte na cesty. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} zatím vyžaduje cestu, složenou cestu, text, skupinu, prolnutí nebo instanci symbolu. Ostatní objekty nejprve rozložte. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nedokáže vytvořit těleso z obrazu: nejprve jej vektorizujte příkazem Objekt › Vektorizace obrazu › Vytvořit. @msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar @@ -5669,6 +5726,7 @@ mark-style Japanese Japonské Preview (1 inch on the page) Náhled (1 palec na stránce) Set as Default Nastavit jako výchozí out from a highlighted edge to place its dimension | ze zvýrazněné hrany ven a umístíte její kótu | + two points, then move and click to place an aligned, horizontal or vertical dimension | na dva body, pak posuňte ukazatel a klepnutím umístěte zarovnanou, vodorovnou nebo svislou kótu | cancels | Double-click the tool for options zruší | Dvojitým kliknutím na nástroj otevřete volby Angular Úhlová Away from the Line Dál od čáry @@ -5697,14 +5755,17 @@ mark-style Japanese Japonské @msg Invalid drawing scale Neplatné měřítko výkresu @msg Invalid real scale Neplatné skutečné měřítko @msg Measured anchor no longer exists Měřený kotevní bod už neexistuje +@msg Invalid measured point Neplatný měřený bod @msg Measured segment no longer exists Měřený segment už neexistuje @msg Non-finite dimension Kóta není konečné číslo +@msg Only linear dimensions are horizontal or vertical Vodorovné nebo svislé jsou jen lineární kóty @msg Scale Text Attributes: selected type has no scale to bake Měnit velikost atributů textu: vybraný text nemá žádnou skalaci, kterou lze použít @msg Scale must be drawing:real, for example 1:10 Měřítko musí mít tvar výkres:skutečnost, například 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Poměr měřítka musí být mezi 1:1000000 a 1000000:1 @msg Second measured segment no longer exists Druhý měřený segment už neexistuje @msg Unknown dimension mode Neznámý režim kóty @msg Unknown dimension option {k} Neznámá volba kóty {k} +@msg Unknown dimension orientation Neznámá orientace kóty @msg Unknown dimension style Neznámý styl kóty @msg Unknown unit Neznámá jednotka # Revolve cap description @@ -5963,6 +6024,20 @@ axis Y Axis osa Y Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu Keep settings of its own Uchovávat vlastní nastavení Keep a cache of its own, which you can clear Uchovávat vlastní mezipaměť, kterou můžete vymazat + Run these commands on the document: {ids} Spouštět v dokumentu tyto příkazy: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ uvádí v `commandAllowlist` příkaz `{id}`, který zásuvný modul nesmí volat +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: `commandAllowlist` manifestu ho neuvádí +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID nebo argumenty leží mimo paměť modulu +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID nebo argumenty nejsou v UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumenty nejsou objekt JSON +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` deklaruje pouze filtr s obvyklým rozpočtem +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) volání příkazů ({MAX_CALLS} volání a {MAX_CALL_BYTES} bajtů na běh) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) velikost volání příkazu (vc_call `{shown}`: {len} bajtů, limit {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` nejvýše {MAX_ALLOWED_COMMANDS} příkazů v `commandAllowlist` +@msg a plug-in may not call this command zásuvný modul nesmí volat tento příkaz +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` potřebuje oprávnění `commands` a `commands` potřebuje `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` uvádí `{shown}`, což není ID příkazu nebo je uvedeno dvakrát Clear Plug-in Cache Vymazat mezipaměť zásuvného modulu @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `{permission}` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature} @@ -6052,6 +6127,23 @@ axis Y Axis osa Y Button Tlačítko Panel Panel UI Frames Rámce UI + Map Markers Mapové značky + Pin Špendlík + Flag Vlajka + Crosshair Zaměřovač + Checkpoint Kontrolní bod + Inventory Icons Ikony inventáře + Skill Icons Ikony dovedností + Attack Útok + Defend Obrana + Heal Léčení + Buff Posílení + Ult Ult + Sword Meč + Shield Štít + Potion Lektvar + Key Klíč + Gem Drahokam Open Symbol Library Otevřít knihovnu symbolů Save Symbol Library Uložit knihovnu symbolů Save Symbol Library… Uložit knihovnu symbolů… @@ -6106,6 +6198,16 @@ axis Y Axis osa Y User Brush Library 9 Uživatelská knihovna štětců 9 User Brush Library 10 Uživatelská knihovna štětců 10 Art Ribbons Obrazové stuhy + Zigzag Cikcak + Burst Ray Paprsek nárazu + Dash Trail Čárkovaná stopa + Speed Line Rychlostní čára + Game Trails Herní stopy + Energy Beams Energetické paprsky + Laser Bolt Laserový výstřel + Plasma Arc Plazmový oblouk + Charge Wave Nabíjecí vlna + Spark Trail Jiskrová stopa Calligraphic Nibs Kaligrafická pera Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary @@ -6694,3 +6796,33 @@ breakText Glyphs Glyfy Ignore All Ignorovat vše Add to Dictionary Přidat do slovníku No Suggestions Žádné návrhy + Edit Custom Dictionary Upravit vlastní slovník + Entry: Položka: +# Script Console + Script Console Konzole skriptů + Clear Script Console Vymazat konzoli skriptů + Copy Script Console Kopírovat konzoli skriptů + Copy All Kopírovat vše + Copy Selected Kopírovat vybrané + Finished Dokončeno + Ended with an error Ukončeno s chybou + Out of fuel Vyčerpán limit instrukcí + Ended at a host limit Ukončeno na limitu hostitele + Ended at a limit Ukončeno na limitu + Stopped Zastaveno + Ended by an internal fault Ukončeno vnitřní chybou + time unavailable čas není k dispozici + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} nejpomalejší: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} volání hostitele|{n} volání hostitele|{n} volání hostitele +@plural {n} earlier line dropped|{n} earlier lines dropped {n} starší řádek zahozen|{n} starší řádky zahozeny|{n} starších řádků zahozeno +@plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last` + +# Deferred plug-in effects + Refresh Deferred Effects Obnovit odložené efekty + Deferred Effects Odložené efekty +@msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6348e68a4..31915ed6b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -55,6 +55,8 @@ Scripts Scripter Check Script… Kontrollér script… Check Script Kontrollér script + Some scripts were not listed Nogle scripter blev ikke vist + The Scripts folder cannot be read Mappen Scripts kan ikke læses Other Script… Andet script… Document Setup… Dokumentopsætning… Switch Units Skift enheder @@ -365,6 +367,7 @@ Smart Punctuation… Smart tegnsætning… Create Outlines Opret konturer Optical Margin Alignment Optisk marginjustering + Dynamic Text Dynamisk tekst Fill with Placeholder Text Udfyld med pladsholdertekst Insert Inline Symbol Indsæt indlejret symbol Show Hidden Characters Vis skjulte tegn @@ -560,6 +563,7 @@ Show Guides Vis hjælpelinjer Lock Guides Lås hjælpelinjer Lock Guides by Default Lås hjælpelinjer som standard + Artboard Guides with Cmd/Ctrl + Ruler Drag Hjælpelinjer til kunstplade med Cmd/Ctrl + træk fra lineal Unlock Guides Lås hjælpelinjer op Make Guides Opret hjælpelinjer Release Guides Frigør hjælpelinjer @@ -712,6 +716,20 @@ Other Library… Andet bibliotek… Graphic Style Libraries Biblioteker med grafiske stile Shadows and Glows Skygger og gløder + Game HUD Spil-HUD + Health Fill Helbred + Mana Glow Mana + Damage Flash Skade + Quest Outline Quest + Coin Shine Mønt + Boss Alert Boss + Status Effects Statuseffekter + Poison Tint Gift + Freeze Edge Frost + Burn Aura Brænd + Stun Pulse Bedøvelse + Stealth Fade Snige + Shield Buff Skjold Outlines and Rules Konturer og linjer Hand-Drawn Håndtegnet Gradient Finishes Gradientfinish @@ -2081,6 +2099,8 @@ digits Malayalam Malayalam Green Grøn Shifts the tints of global and spot colors; other colors stay. Flytter nuancerne af globale farver og spotfarver; andre farver forbliver uændrede. Tint Nuance + Fill Tint Fyldnuance + Stroke Tint Stregnuance Color Guide Options Indstillinger for farveguide Less Mindre Steps: Trin: @@ -2434,6 +2454,7 @@ digits Malayalam Malayalam File Dimensions Fildimensioner Transforms Transformationer Background Color: Baggrundsfarve: + Guide Label: Hjælpelinjeetiket: Show Center Mark Vis midtermærke Show Cross Hairs Vis trådkors Show Video Safe Areas Vis sikre videoområder @@ -2678,6 +2699,7 @@ antiAlias Sharp Skarp General › Thumbnails Generelt › Miniaturer Grayscale images Gråtonebilleder Include non-printing layers Medtag lag, der ikke udskrives + Write pages longer than 200 in at full size in points Skriv sider længere end 200 in i fuld størrelse i punkter Left: Venstre: Mark as trapped Markér som trappet Marks and Bleeds Mærker og udløb @@ -3480,6 +3502,12 @@ theme Appearance Udseende Real-time Drawing and Editing Tegning og redigering i realtid Render Threads (-1 = Automatic) Renderingstråde (-1 = automatisk) Reset Preferences Nulstil indstillinger + Export Preferences… Eksportér præferencer… + Import Preferences… Importér præferencer… + Save the applied preferences to a JSON file Gem de anvendte præferencer i en JSON-fil + Apply the preferences in a file Export Preferences saved Anvend præferencerne i en fil, som Eksportér præferencer har gemt + Export Preferences Eksportér præferencer + Import Preferences Importér præferencer Search Preferences Søg i præferencer No settings found Ingen indstillinger fundet Scale Corners Skalér hjørner @@ -3850,6 +3878,8 @@ font-technology Variable Variable Preferences Præferencer Previous Forrige Restore every preference to its default (applied on OK) Gendan alle præferencer til standard (anvendes ved OK) + Reset all preferences to their defaults? Nulstil alle præferencer til standard? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Standardværdierne anvendes, når du klikker på OK i Præferencer. Annullér der beholder dine nuværende præferencer. Smaller ↔ Larger Mindre ↔ Større Smart Guides (View → Smart Guides) Smarte hjælpelinjer (Vis → Smarte hjælpelinjer) Couldn't save recovery data: {e} Kunne ikke gemme gendannelsesdata: {e} @@ -4078,6 +4108,7 @@ imageTrace Outline Kontur Stop Script Stop script Show External Script Warning Vis advarsel om eksterne scripts A Script Is Running Et script kører + Script Alert Scriptadvarsel A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Et script kan ændre de åbne dokumenter og skrive filer gennem de kommandoer, det kører. Kør kun scripts, du stoler på. Do not show this warning again Vis ikke denne advarsel igen Stop the script and undo what it did? Then quit again. Stop scriptet og fortryd det, det har gjort? Afslut derefter igen. @@ -4595,6 +4626,20 @@ imageTrace Outline Kontur @msg unknown overprints `{o}` (preserve, simulate or discard) ukendt overprint `{o}` (bevar, simulér eller forkast) @msg unknown panel `{raw}` ukendt panel `{raw}` @msg unknown preference `{key}` ukendt præference `{key}` +@msg Preferences imported. Præferencerne er importeret. +@msg not a preferences file: {e} ikke en præferencefil: {e} +@msg not a preferences file: no JSON object ikke en præferencefil: intet JSON-objekt +@msg not a preferences file (format `{format}`) ikke en præferencefil (format `{format}`) +@msg not a preferences file: no `format` ikke en præferencefil: intet `format` +@msg not a preferences file: no `version` ikke en præferencefil: ingen `version` +@msg not a preferences file: no `prefs` ikke en præferencefil: ingen `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) filen er gemt af en nyere version af VectorCraft (version {v}) +@msg the file holds no preferences filen indeholder ingen præferencer +@msg `{key}` names a folder, which preferences files leave out `{key}` angiver en mappe, og præferencefiler indeholder ikke mapper +@msg the file is larger than 1 MB filen er større end 1 MB +@msg give `path` or `data` angiv `path` eller `data` +@msg `{key}` must be an object `{key}` skal være et objekt +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` køres alene, ikke som et trin i en batch @msg unknown preset (one of {_1}) ukendt forudindstilling (én af {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) ukendt forudindstilling `{name}` (høj, mellem, lav eller en gemt: se flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) ukendt forudindstilling `{name}` (se flattener.presets.list) @@ -4759,6 +4804,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} i `{key}` er {value}, ikke et objekt-id (et ikke-negativt heltal) @msg no such object id {value} der findes intet objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} er en hjælpelinje, ikke et objekt +@msg a ruler guide goes only directly in a layer or sublayer en hjælpelinje kan kun ligge direkte i et lag eller underlag @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path} @@ -5091,6 +5138,7 @@ file-filter Swatch libraries Farveprøvebiblioteker file-filter Flattener presets Flattener-forudindstillinger file-filter PDF presets PDF-forudindstillinger file-filter Print presets Printforudindstillinger +file-filter Preferences Præferencer file-filter Export presets Eksportforudindstillinger file-filter SVG Compressed Komprimeret SVG file-filter PDF-compatible .ai PDF-kompatibel .ai @@ -5150,7 +5198,8 @@ mark-style Japanese Japansk Bevel both sides Facet på begge sider Presets Forudindstillinger @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Et objekt kan kun have én 3D-effekt. Redigér den eksisterende effekts indstillinger, eller fjern den først. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} kræver i øjeblikket en sti eller en sammensat sti. Konvertér tekst, billeder og grupper til stier først. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} kræver i øjeblikket en sti, en sammensat sti, tekst, en gruppe, en blanding eller en symbolforekomst. Udvid andre objekter først. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan ikke bygge et legeme ud fra et billede: spor det først med Objekt › Billedsporing › Opret. @msg {label} requires a shape with at least two anchors {label} kræver en form med mindst to ankerpunkter @msg The shape is too complex for a 3D effect Formen er for kompleks til en 3D-effekt @msg The 3D surface is too complex; simplify the shape 3D-fladen er for kompleks; forenkl formen @@ -5264,6 +5313,7 @@ mark-style Japanese Japansk Preview (1 inch on the page) Forhåndsvisning (1 tomme på siden) Set as Default Angiv som standard out from a highlighted edge to place its dimension | ud fra en fremhævet kant for at placere dens mål | + two points, then move and click to place an aligned, horizontal or vertical dimension | på to punkter, flyt derefter, og klik for at placere et justeret, vandret eller lodret mål | cancels | Double-click the tool for options annullerer | Dobbeltklik på værktøjet for indstillinger Angular Vinklet Away from the Line Væk fra linjen @@ -5293,14 +5343,17 @@ mark-style Japanese Japansk @msg Invalid drawing scale Ugyldig tegningsmålestok @msg Invalid real scale Ugyldig virkelig målestok @msg Measured anchor no longer exists Det målte ankerpunkt findes ikke længere +@msg Invalid measured point Ugyldigt målt punkt @msg Measured segment no longer exists Det målte segment findes ikke længere @msg Non-finite dimension Ikke-endeligt mål +@msg Only linear dimensions are horizontal or vertical Kun lineære mål er vandrette eller lodrette @msg Scale Text Attributes: selected type has no scale to bake Skalér tekstattributter: den valgte tekst har ingen skalering at anvende @msg Scale must be drawing:real, for example 1:10 Målestokken skal være tegning:virkelig, for eksempel 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Målestoksforholdet skal være mellem 1:1000000 og 1000000:1 @msg Second measured segment no longer exists Det andet målte segment findes ikke længere @msg Unknown dimension mode Ukendt måltilstand @msg Unknown dimension option {k} Ukendt målindstilling {k} +@msg Unknown dimension orientation Ukendt målretning @msg Unknown dimension style Ukendt målstil @msg Unknown unit Ukendt enhed Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Luk de runde ender af åbne profiler. Delvise omdrejninger beholder deres skårne sider åbne. @@ -5810,6 +5863,7 @@ axis Y Axis Y-akse Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker Keep settings of its own Gemme sine egne indstillinger Keep a cache of its own, which you can clear Gemme sin egen cache, som du kan rydde + Run these commands on the document: {ids} Køre disse kommandoer på dokumentet: {ids} Clear Plug-in Cache Ryd plug-in-cache # Actions panel: action sets (#3712). New Set… Nyt sæt… @@ -5859,6 +5913,23 @@ axis Y Axis Y-akse Button Knap Panel Panel UI Frames UI-rammer + Map Markers Kortmarkører + Pin Nål + Flag Flag + Crosshair Sigtekorn + Checkpoint Kontrolpunkt + Inventory Icons Inventarikoner + Skill Icons Færdighedsikoner + Attack Angreb + Defend Forsvar + Heal Helbredelse + Buff Buff + Ult Ult + Sword Sværd + Shield Skjold + Potion Eliksir + Key Nøgle + Gem Ædelsten Open Symbol Library Åbn symbolbibliotek Save Symbol Library Gem symbolbibliotek Save Symbol Library… Gem symbolbibliotek… @@ -5907,6 +5978,16 @@ axis Y Axis Y-akse User Brush Library 9 Brugerpenselbibliotek 9 User Brush Library 10 Brugerpenselbibliotek 10 Art Ribbons Kunstbånd + Zigzag Zigzag + Burst Ray Burststråle + Dash Trail Streget spor + Speed Line Hastighedslinje + Game Trails Spilspor + Energy Beams Energistråler + Laser Bolt Laserbolt + Plasma Arc Plasmabue + Charge Wave Opladningsbølge + Spark Trail Gnistsvær Calligraphic Nibs Kalligrafiske penne Pattern Borders Mønsterborter Scatter Shapes Spredte former @@ -6454,3 +6535,30 @@ breakText Glyphs Glyffer # Align to Pixel Grid Align to Pixel Grid Juster efter pixelgitter @msg side is left or right, not `{s}` side er left eller right, ikke `{s}` + Edit Custom Dictionary Redigér egen ordliste + Entry: Opslag: +# Script Console + Script Console Scriptkonsol + Clear Script Console Ryd scriptkonsollen + Copy Script Console Kopiér scriptkonsollen + Copy All Kopiér alt + Copy Selected Kopiér markeret + Finished Færdig + Ended with an error Afsluttet med en fejl + Out of fuel Instruktionsgrænsen er nået + Ended at a host limit Afsluttet ved en værtsgrænse + Ended at a limit Afsluttet ved en grænse + Stopped Stoppet + Ended by an internal fault Afsluttet af en intern fejl + time unavailable tid ikke tilgængelig + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} langsomste: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} værtskald|{n} værtskald +@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet +@plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet + +# Deferred plug-in effects + Refresh Deferred Effects Opdatér udskudte effekter + Deferred Effects Udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0ffe6eeb9..2c5424d81 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -264,6 +264,7 @@ font-technology Variable Variabel Average… Durchschnitt berechnen… Axis: Achse: Background Color: Hintergrundfarbe: + Guide Label: Farbmarkierung der Hilfslinien: Show Center Mark Mittelpunktmarke einblenden Show Cross Hairs Fadenkreuz einblenden Show Video Safe Areas Videosichere Bereiche einblenden @@ -1248,6 +1249,7 @@ file-filter Swatch libraries Farbfeldbibliotheken file-filter Flattener presets Reduzierungsvorgaben file-filter PDF presets PDF-Vorgaben file-filter Print presets Druckvorgaben +file-filter Preferences Voreinstellungen file-filter Export presets Exportvorgaben file-filter SVG Compressed SVG komprimiert file-filter PDF-compatible .ai PDF-kompatible .ai @@ -1653,6 +1655,7 @@ file-filter Templates Vorlagen Include Linked Files Verknüpfte Dateien einschließen Include Metadata Metadaten einschließen Include non-printing layers Nicht druckende Ebenen einschließen + Write pages longer than 200 in at full size in points Seiten über 200 in in voller Größe in Punkt schreiben Include SVG Code SVG-Code einschließen Include Swatches for Tints Farbfelder für Tonwerte einschließen Inflate… Aufblasen… @@ -1893,6 +1896,7 @@ file-filter Templates Vorlagen Lock Grid Raster sperren Lock Guides Hilfslinien sperren Lock Guides by Default Hilfslinien standardmäßig sperren + Artboard Guides with Cmd/Ctrl + Ruler Drag Zeichenflächen-Hilfslinien mit Cmd/Ctrl + Ziehen aus dem Lineal Lock Others Andere sperren Lock Slices Slices sperren Lock Station Point Standpunkt sperren @@ -2285,6 +2289,8 @@ file-filter Templates Vorlagen Open… Öffnen… Optical Optisch Optical Margin Alignment Optischer Randausgleich + Dynamic Text Dynamischer Text +@msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames} Optimize for fast web view Für schnelle Webanzeige optimieren Optimized Optimiert Options Optionen @@ -2776,6 +2782,12 @@ digits Malayalam Malayalam Reset OpenType Features OpenType-Funktionen zurücksetzen Reset Panel Bedienfeld zurücksetzen Reset Preferences Voreinstellungen zurücksetzen + Export Preferences… Voreinstellungen exportieren… + Import Preferences… Voreinstellungen importieren… + Save the applied preferences to a JSON file Die angewendeten Voreinstellungen in einer JSON-Datei speichern + Apply the preferences in a file Export Preferences saved Die Voreinstellungen aus einer mit „Voreinstellungen exportieren“ gespeicherten Datei anwenden + Export Preferences Voreinstellungen exportieren + Import Preferences Voreinstellungen importieren Search Preferences Voreinstellungen durchsuchen No settings found Keine Einstellungen gefunden Reset Profiles Profile zurücksetzen @@ -2799,6 +2811,8 @@ digits Malayalam Malayalam Responsive Responsiv Restore Wiederherstellen Restore every preference to its default (applied on OK) Alle Voreinstellungen auf ihre Standardwerte zurücksetzen (wird bei OK angewendet) + Reset all preferences to their defaults? Alle Voreinstellungen auf ihre Standardwerte zurücksetzen? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Die Standardwerte werden angewendet, wenn Sie in den Voreinstellungen auf OK klicken. Abbrechen dort behält Ihre aktuellen Voreinstellungen. Restore Recovered Documents Wiederhergestellte Dokumente öffnen Restored documents open unsaved: save them to keep them. Wiederhergestellte Dokumente werden ungespeichert geöffnet: Speichern Sie sie, um sie zu behalten. Restrict printing, editing and other tasks Drucken, Bearbeiten und andere Aufgaben einschränken @@ -3099,6 +3113,20 @@ digits Malayalam Malayalam Settings… Einstellungen… Setup… Einrichten… Shadows and Glows Schatten und Leuchteffekte + Game HUD Spiel-HUD + Health Fill Leben + Mana Glow Mana + Damage Flash Schaden + Quest Outline Quest + Coin Shine Münze + Boss Alert Boss + Status Effects Statuseffekte + Poison Tint Gift + Freeze Edge Frost + Burn Aura Brand + Stun Pulse Betäubung + Stealth Fade Schleichen + Shield Buff Schild Shape Form Shape Builder Formerstellung Shape Builder Regions Formerstellungsbereiche @@ -3502,6 +3530,8 @@ effect Points Punkte Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster) Gutter: Zwischenraum: Tint Tonwert + Fill Tint Flächentonwert + Stroke Tint Konturtonwert Tip extends past the end point Spitze ragt über den Endpunkt hinaus Tip on the end point Spitze auf dem Endpunkt Title Case Wortanfänge groß @@ -3987,6 +4017,7 @@ theme Appearance Erscheinungsbild Stop Script Skript stoppen Show External Script Warning Warnung bei externen Skripten anzeigen A Script Is Running Ein Skript wird ausgeführt + Script Alert Skriptmeldung A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ein Skript kann die geöffneten Dokumente ändern und über die ausgeführten Befehle Dateien schreiben. Führen Sie nur Skripte aus, denen Sie vertrauen. Do not show this warning again Diese Warnung nicht mehr anzeigen Stop the script and undo what it did? Then quit again. Skript stoppen und seine Änderungen rückgängig machen? Beenden Sie danach erneut. @@ -4000,6 +4031,9 @@ theme Appearance Erscheinungsbild @msg the script is too large to run das Skript ist zu groß zum Ausführen @msg a drag or an undo group is open; finish it before running a script ein Ziehvorgang oder eine Rückgängig-Gruppe ist offen; schließen Sie sie ab, bevor Sie ein Skript ausführen @msg the script left a document that cannot be kept das Skript hat ein Dokument hinterlassen, das nicht behalten werden kann +@msg the script is no longer in the Scripts folder das Skript befindet sich nicht mehr im Ordner „Scripts“ + Some scripts were not listed Einige Skripte wurden nicht aufgeführt + The Scripts folder cannot be read Der Ordner „Scripts“ kann nicht gelesen werden @msg the script's run is no longer available der Lauf des Skripts ist nicht mehr verfügbar @msg a failed command changed undo steps that could not be put back, so the script was stopped ein fehlgeschlagener Befehl hat Rückgängig-Schritte geändert, die nicht wiederhergestellt werden konnten, daher wurde das Skript angehalten @msg a recovery copy could not be written, so the script was not run eine Wiederherstellungskopie konnte nicht geschrieben werden, daher wurde das Skript nicht ausgeführt @@ -4184,6 +4218,7 @@ theme Appearance Erscheinungsbild @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis muss "vertical", "horizontal" oder ein Winkel in Grad sein, nicht `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black oder eine Farbe wie "#ff8800" @msg bad color {v} ungültige Farbe {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` ist null, der Name einer Ebenenfarbe oder #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio muss eine Zahl von 0,1 bis 10 sein @msg the Color Picker is not open der Farbwähler ist nicht geöffnet @msg the Simplify slider is not open der Regler „Vereinfachen“ ist nicht geöffnet @@ -4247,6 +4282,11 @@ theme Appearance Erscheinungsbild @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) interner Fehler in `{cmd}`: {msg} (das Dokument wurde im vorherigen Zustand belassen; bitte melden Sie diesen Fehler) @msg internal error: {msg} (please report this bug) interner Fehler: {msg} (bitte melden Sie diesen Fehler) @msg invalid PDF setting: {0} ungültige PDF-Einstellung: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): sie wird mit einer UserUnit von {unit} geschrieben, daher zeigen Apps, die UserUnit ignorieren, sie entsprechend oft kleiner an +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): {label}-Seiten werden ohne UserUnit in Punkt geschrieben, und Apps, die die Grenze einhalten, schneiden die Seite auf 200 in ab +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 hat keine UserUnit, die eine Seite länger als 14.400 pt (200 in) braucht, um ihre Größe zu behalten: die Datei wird als PDF 1.6 geschrieben +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units die Seite der Zeichenfläche {n} ist {size} groß und damit zu lang und schmal für PDF-Seitenlängen von 3 bis 14.400 Einheiten: sie wird mit einer UserUnit von {unit} geschrieben, die die kurze Seite bei mindestens 3 Einheiten hält, und Apps, die die Grenze einhalten, schneiden die lange Seite auf 14.400 Einheiten ab +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-Dateien fassen reelle Zahlen bis {max}, und die Seite der Zeichenfläche {n} mit {side} pt Länge braucht größere: wählen Sie PDF 1.6 oder höher ohne PDF/X-Standard oder eine kleinere Zeichenfläche @msg invalid image data for `{0}` ungültige Bilddaten für `{0}` @msg invalid parameters for `{cmd}`: {msg} ungültige Parameter für `{cmd}`: {msg} @msg is not an image VectorCraft reads ist kein Bild, das VectorCraft lesen kann @@ -4585,6 +4625,20 @@ theme Appearance Erscheinungsbild @msg unknown overprints `{o}` (preserve, simulate or discard) unbekannter Wert für overprints `{o}` (preserve, simulate oder discard) @msg unknown panel `{raw}` unbekanntes Bedienfeld `{raw}` @msg unknown preference `{key}` unbekannte Voreinstellung `{key}` +@msg Preferences imported. Voreinstellungen importiert. +@msg not a preferences file: {e} keine Voreinstellungsdatei: {e} +@msg not a preferences file: no JSON object keine Voreinstellungsdatei: kein JSON-Objekt +@msg not a preferences file (format `{format}`) keine Voreinstellungsdatei (Format `{format}`) +@msg not a preferences file: no `format` keine Voreinstellungsdatei: kein `format` +@msg not a preferences file: no `version` keine Voreinstellungsdatei: keine `version` +@msg not a preferences file: no `prefs` keine Voreinstellungsdatei: keine `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) die Datei wurde mit einer neueren Version von VectorCraft gespeichert (Version {v}) +@msg the file holds no preferences die Datei enthält keine Voreinstellungen +@msg `{key}` names a folder, which preferences files leave out `{key}` nennt einen Ordner, und Voreinstellungsdateien enthalten keine Ordner +@msg the file is larger than 1 MB die Datei ist größer als 1 MB +@msg give `path` or `data` geben Sie `path` oder `data` an +@msg `{key}` must be an object `{key}` muss ein Objekt sein +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` läuft für sich allein, nicht als Stapelschritt @msg unknown preset (one of {_1}) unbekannte Vorgabe (eine von {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) unbekannte Vorgabe `{name}` (high, medium, low oder eine gespeicherte: siehe flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) unbekannte Vorgabe `{name}` (siehe flattener.presets.list) @@ -4812,6 +4866,8 @@ theme Appearance Erscheinungsbild @msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl @msg `{key}` entry {index} is {value}, not a non-negative integer object id Eintrag {index} von `{key}` ist {value}, keine Objekt-ID (nicht negative ganze Zahl) @msg no such object id {value} kein Objekt mit der ID {value} +@msg id {value} is a ruler guide, not an object die ID {value} gehört zu einer Hilfslinie, nicht zu einem Objekt +@msg a ruler guide goes only directly in a layer or sublayer eine Hilfslinie kann nur direkt in einer Ebene oder Unterebene liegen @msg id {value} is a layer, not an object die ID {value} ist eine Ebene, kein Objekt @msg id {value} is a layer, not an object; move layers with layer.move die ID {value} ist eine Ebene, kein Objekt; verschieben Sie Ebenen mit layer.move @msg automation filesystem access is not granted: read authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Leseberechtigung fehlt: {path} @@ -5176,7 +5232,8 @@ lighting Light Licht Bevel both sides Abgeflachte Kante beidseitig Presets Vorgaben @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ein Objekt kann nur einen 3D-Effekt haben. Bearbeiten Sie die Optionen des vorhandenen Effekts oder entfernen Sie ihn zuerst. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} erfordert derzeit einen Pfad, zusammengesetzten Pfad, Text oder eine Gruppe. Wandeln Sie Bilder und andere Objekte zuerst in Pfade um. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} erfordert derzeit einen Pfad, zusammengesetzten Pfad, Text, eine Gruppe, eine Angleichung oder eine Symbolinstanz. Wandeln Sie andere Objekte zuerst um. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kann aus einem Bild keinen Körper erstellen: Zeichnen Sie es zuerst mit Objekt › Bildnachzeichner › Erstellen nach. @msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form @@ -5242,6 +5299,7 @@ effect Soft Weich Preview (1 inch on the page) Vorschau (1 Zoll auf der Seite) Set as Default Als Standard festlegen out from a highlighted edge to place its dimension | von einer hervorgehobenen Kante nach außen, um ihre Bemaßung zu platzieren | + two points, then move and click to place an aligned, horizontal or vertical dimension | auf zwei Punkte, dann bewegen und klicken, um eine ausgerichtete, horizontale oder vertikale Bemaßung zu platzieren | cancels | Double-click the tool for options bricht ab | Doppelklick auf das Werkzeug für Optionen Linear Linear Angular Winkel @@ -5274,14 +5332,17 @@ effect Soft Weich @msg Invalid drawing scale Ungültiger Zeichnungsmaßstab @msg Invalid real scale Ungültiger realer Maßstab @msg Measured anchor no longer exists Der gemessene Ankerpunkt existiert nicht mehr +@msg Invalid measured point Ungültiger gemessener Punkt @msg Measured segment no longer exists Das gemessene Segment existiert nicht mehr @msg Non-finite dimension Die Bemaßung ist keine endliche Zahl +@msg Only linear dimensions are horizontal or vertical Nur lineare Bemaßungen sind horizontal oder vertikal @msg Scale Text Attributes: selected type has no scale to bake Textattribute skalieren: Der ausgewählte Text weist keine zu übernehmende Skalierung auf @msg Scale must be drawing:real, for example 1:10 Der Maßstab muss Zeichnung:Realität sein, zum Beispiel 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Das Maßstabsverhältnis muss zwischen 1:1000000 und 1000000:1 liegen @msg Second measured segment no longer exists Das zweite gemessene Segment existiert nicht mehr @msg Unknown dimension mode Unbekannter Bemaßungsmodus @msg Unknown dimension option {k} Unbekannte Bemaßungsoption {k} +@msg Unknown dimension orientation Unbekannte Bemaßungsausrichtung @msg Unknown dimension style Unbekannter Bemaßungsstil @msg Unknown unit Unbekannte Einheit # Revolve cap description @@ -5546,6 +5607,20 @@ axis Y Axis Y-Achse Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben Keep settings of its own Eigene Einstellungen speichern Keep a cache of its own, which you can clear Einen eigenen Cache anlegen, den Sie leeren können + Run these commands on the document: {ids} Diese Befehle im Dokument ausführen: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ führt `{id}` in `commandAllowlist` auf, den ein Plug-in nicht aufrufen darf +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: die `commandAllowlist` des Manifests führt ihn nicht auf +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: die ID oder die Argumente liegen außerhalb des Speichers des Moduls +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: die ID oder die Argumente sind kein UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: die Argumente sind kein JSON-Objekt +@msg only a filter with the usual budget declares `commandAllowlist` nur ein Filter mit dem üblichen Budget deklariert `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) Befehlsaufrufe ({MAX_CALLS} Aufrufe und {MAX_CALL_BYTES} Byte pro Lauf) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) Größe eines Befehlsaufrufs (vc_call `{shown}`: {len} Byte, Grenze {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` höchstens {MAX_ALLOWED_COMMANDS} Befehle in `commandAllowlist` +@msg a plug-in may not call this command ein Plug-in darf diesen Befehl nicht aufrufen +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` braucht die Berechtigung `commands`, und `commands` braucht eine `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` nennt `{shown}`, das keine Befehls-ID ist oder doppelt aufgeführt ist Clear Plug-in Cache Plug-in-Cache leeren @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `{permission}` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an @msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature} @@ -5635,6 +5710,23 @@ axis Y Axis Y-Achse Button Schaltfläche Panel Bedienfeld UI Frames UI-Rahmen + Map Markers Kartenmarkierungen + Pin Stecknadel + Flag Flagge + Crosshair Fadenkreuz + Checkpoint Kontrollpunkt + Inventory Icons Inventar-Symbole + Skill Icons Fähigkeitssymbole + Attack Angriff + Defend Verteidigung + Heal Heilung + Buff Buff + Ult Ult + Sword Schwert + Shield Schild + Potion Trank + Key Schlüssel + Gem Edelstein Open Symbol Library Symbol-Bibliothek öffnen Save Symbol Library Symbol-Bibliothek speichern Save Symbol Library… Symbol-Bibliothek speichern… @@ -5689,6 +5781,16 @@ axis Y Axis Y-Achse User Brush Library 9 Benutzer-Pinsel-Bibliothek 9 User Brush Library 10 Benutzer-Pinsel-Bibliothek 10 Art Ribbons Bildbänder + Zigzag Zickzack + Burst Ray Stoßstrahl + Dash Trail Strichelspur + Speed Line Geschwindigkeitslinie + Game Trails Spielspuren + Energy Beams Energiestrahlen + Laser Bolt Laserbolzen + Plasma Arc Plasmabogen + Charge Wave Ladewelle + Spark Trail Funkenspur Calligraphic Nibs Kalligrafie-Federn Pattern Borders Musterbordüren Scatter Shapes Streuformen @@ -6281,3 +6383,33 @@ breakText Glyphs Glyphen Ignore All Alle ignorieren Add to Dictionary Zum Wörterbuch hinzufügen No Suggestions Keine Vorschläge + Edit Custom Dictionary Benutzerwörterbuch bearbeiten + Entry: Eintrag: +# Script Console + Script Console Skriptkonsole + Clear Script Console Skriptkonsole leeren + Copy Script Console Skriptkonsole kopieren + Copy All Alles kopieren + Copy Selected Auswahl kopieren + Finished Beendet + Ended with an error Mit einem Fehler beendet + Out of fuel Anweisungslimit erreicht + Ended at a host limit An einem Host-Limit beendet + Ended at a limit An einem Limit beendet + Stopped Gestoppt + Ended by an internal fault Durch einen internen Fehler beendet + time unavailable Zeit nicht verfügbar + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} langsamste: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} Host-Aufruf|{n} Host-Aufrufe +@plural {n} earlier line dropped|{n} earlier lines dropped {n} ältere Zeile verworfen|{n} ältere Zeilen verworfen +@plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last` + +# Deferred plug-in effects + Refresh Deferred Effects Zurückgestellte Effekte aktualisieren + Deferred Effects Zurückgestellte Effekte +@msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6dc7a40ea..6071ed5be 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -267,6 +267,7 @@ font-technology Variable Μεταβλητές Average… Μέσος όρος… Axis: Άξονας: Background Color: Χρώμα φόντου: + Guide Label: Ετικέτα οδηγών: Show Center Mark Εμφάνιση σημαδιού κέντρου Show Cross Hairs Εμφάνιση σταυρονήματος Show Video Safe Areas Εμφάνιση ασφαλών περιοχών βίντεο @@ -1577,6 +1578,7 @@ font-technology Variable Μεταβλητές Include Linked Files Συμπερίληψη συνδεδεμένων αρχείων Include Metadata Συμπερίληψη μεταδεδομένων Include non-printing layers Συμπερίληψη μη εκτυπώσιμων επιπέδων + Write pages longer than 200 in at full size in points Εγγραφή σελίδων μεγαλύτερων από 200 in σε πλήρες μέγεθος, σε στιγμές Include SVG Code Συμπερίληψη κώδικα SVG Include Swatches for Tints Συμπερίληψη δειγμάτων για αποχρώσεις Inflate… Φούσκωμα… @@ -1725,6 +1727,7 @@ font-technology Variable Μεταβλητές Lock Κλείδωμα Lock Guides Κλείδωμα οδηγών Lock Guides by Default Κλείδωμα οδηγών από προεπιλογή + Artboard Guides with Cmd/Ctrl + Ruler Drag Οδηγοί πίνακα σχεδίασης με Cmd/Ctrl + σύρσιμο από χάρακα Lock Slices Κλείδωμα τομών Low Χαμηλή Luminosity Φωτεινότητα @@ -1919,6 +1922,12 @@ font-technology Variable Μεταβλητές Reselect Επαναφορά προηγούμενης επιλογής Reset Επαναφορά Reset Preferences Επαναφορά προτιμήσεων + Export Preferences… Εξαγωγή προτιμήσεων… + Import Preferences… Εισαγωγή προτιμήσεων… + Save the applied preferences to a JSON file Αποθήκευση των εφαρμοσμένων προτιμήσεων σε αρχείο JSON + Apply the preferences in a file Export Preferences saved Εφαρμογή των προτιμήσεων από αρχείο που αποθήκευσε η Εξαγωγή προτιμήσεων + Export Preferences Εξαγωγή προτιμήσεων + Import Preferences Εισαγωγή προτιμήσεων Search Preferences Αναζήτηση στις προτιμήσεις No settings found Δεν βρέθηκαν ρυθμίσεις Reset to Default Επαναφορά προεπιλογής @@ -2077,6 +2086,8 @@ font-technology Variable Μεταβλητές Threshold Κατώφλι Tile Παράθεση Tint Χρωματική απόχρωση + Fill Tint Χρωματική απόχρωση γεμίσματος + Stroke Tint Χρωματική απόχρωση περιγράμματος Tolerance Ανοχή Tolerance: Ανοχή: Tool Options… Επιλογές εργαλείου… @@ -2237,6 +2248,7 @@ axis Both Και τα δύο Stop Script Διακοπή σεναρίου Show External Script Warning Εμφάνιση προειδοποίησης για εξωτερικά σενάρια A Script Is Running Εκτελείται ένα σενάριο + Script Alert Ειδοποίηση σεναρίου A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ένα σενάριο μπορεί να αλλάξει τα ανοιχτά έγγραφα και να γράψει αρχεία μέσω των εντολών που εκτελεί. Εκτελείτε μόνο σενάρια που εμπιστεύεστε. Do not show this warning again Να μην εμφανιστεί ξανά αυτή η προειδοποίηση Stop the script and undo what it did? Then quit again. Διακοπή του σεναρίου και αναίρεση όσων έκανε; Έπειτα κάντε ξανά έξοδο. @@ -2250,6 +2262,9 @@ axis Both Και τα δύο @msg the script is too large to run το σενάριο είναι πολύ μεγάλο για εκτέλεση @msg a drag or an undo group is open; finish it before running a script είναι ανοιχτή μια μεταφορά ή ομάδα αναίρεσης· ολοκληρώστε την πριν εκτελέσετε σενάριο @msg the script left a document that cannot be kept το σενάριο άφησε ένα έγγραφο που δεν μπορεί να διατηρηθεί +@msg the script is no longer in the Scripts folder το σενάριο δεν βρίσκεται πλέον στον φάκελο Scripts + Some scripts were not listed Ορισμένα σενάρια δεν εμφανίζονται στη λίστα + The Scripts folder cannot be read Δεν είναι δυνατή η ανάγνωση του φακέλου Scripts @msg the script's run is no longer available η εκτέλεση του σεναρίου δεν είναι πλέον διαθέσιμη @msg a failed command changed undo steps that could not be put back, so the script was stopped μια εντολή που απέτυχε άλλαξε βήματα αναίρεσης που δεν ήταν δυνατό να επαναφερθούν, οπότε το σενάριο σταμάτησε @msg a recovery copy could not be written, so the script was not run δεν ήταν δυνατή η εγγραφή αντιγράφου ανάκτησης, οπότε το σενάριο δεν εκτελέστηκε @@ -2616,6 +2631,7 @@ file-filter Flattener presets Προεπιλογές ισοπέδωσης file-filter PDF presets Προεπιλογές PDF file-filter PDF-compatible .ai .ai συμβατό με PDF file-filter Print presets Προεπιλογές εκτύπωσης +file-filter Preferences Προτιμήσεις file-filter Export presets Προκαθορισμένα εξαγωγής file-filter SVG Compressed Συμπιεσμένο SVG file-filter Swatch libraries Βιβλιοθήκες δειγμάτων χρωμάτων @@ -3035,6 +3051,8 @@ theme Appearance Εμφάνιση Open the URL in the web browser Άνοιγμα της διεύθυνσης στο πρόγραμμα περιήγησης Optical Οπτική Optical Margin Alignment Οπτική στοίχιση περιθωρίου + Dynamic Text Δυναμικό κείμενο +@msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames} Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό Options for Selection… Επιλογές για την επιλογή… or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη @@ -3421,6 +3439,8 @@ digits Malayalam Μαλαγιάλαμ Reshape Type Area Αναδιαμόρφωση περιοχής κειμένου Responsive Αποκρινόμενο Restore every preference to its default (applied on OK) Επαναφορά όλων των προτιμήσεων στις προεπιλογές τους (εφαρμογή με το Εντάξει) + Reset all preferences to their defaults? Επαναφορά όλων των προτιμήσεων στις προεπιλογές τους; + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Οι προεπιλογές εφαρμόζονται όταν κάνετε κλικ στο Εντάξει στις Προτιμήσεις. Το Ακύρωση εκεί διατηρεί τις τρέχουσες προτιμήσεις σας. Restore Recovered Documents Επαναφορά ανακτημένων εγγράφων Restored documents open unsaved: save them to keep them. Τα ανακτημένα έγγραφα ανοίγουν χωρίς να είναι αποθηκευμένα: αποθηκεύστε τα για να τα διατηρήσετε. Restrict printing, editing and other tasks Περιορισμός εκτύπωσης, επεξεργασίας και άλλων ενεργειών @@ -3601,6 +3621,20 @@ digits Malayalam Μαλαγιάλαμ Set Zoom Ορισμός ζουμ Setup… Ρύθμιση… Shadows and Glows Σκιές και λάμψεις + Game HUD HUD παιχνιδιού + Health Fill Υγεία + Mana Glow Μάνα + Damage Flash Ζημιά + Quest Outline Αποστολή + Coin Shine Νόμισμα + Boss Alert Αρχηγός + Status Effects Εφέ κατάστασης + Poison Tint Δηλητήριο + Freeze Edge Πάγωμα + Burn Aura Κάψιμο + Stun Pulse Ζάλη + Stealth Fade Αορασία + Shield Buff Ασπίδα Shape Builder Regions Περιοχές δημιουργίας σχημάτων Shape Effects Εφέ σχημάτων Shape Mode Λειτουργία σχήματος @@ -4228,6 +4262,7 @@ digits Malayalam Μαλαγιάλαμ @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` το axis πρέπει να είναι "vertical", "horizontal" ή γωνία σε μοίρες, όχι `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" φόντο {other}: transparent, white, black ή χρώμα όπως "#ff8800" @msg bad color {v} μη έγκυρο χρώμα {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb το `guideLabel` είναι null, όνομα χρώματος επιπέδου ή #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 Το videoRulerPixelAspectRatio πρέπει να είναι αριθμός από 0,1 έως 10 @msg bad colour `{hex}` (#rrggbb) μη έγκυρο χρώμα `{hex}` (#rrggbb) @msg bad page tree μη έγκυρο δέντρο σελίδων @@ -4286,6 +4321,11 @@ digits Malayalam Μαλαγιάλαμ @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) εσωτερικό σφάλμα στην εντολή `{cmd}`: {msg} (το έγγραφο έμεινε αμετάβλητο· αναφέρετε το σφάλμα) @msg internal error: {msg} (please report this bug) εσωτερικό σφάλμα: {msg} (αναφέρετε το σφάλμα) @msg invalid PDF setting: {0} μη έγκυρη ρύθμιση PDF: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): γράφεται με UserUnit {unit}, οπότε οι εφαρμογές που αγνοούν το UserUnit την εμφανίζουν τόσες φορές μικρότερη +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): οι σελίδες {label} γράφονται χωρίς UserUnit, σε στιγμές, και οι εφαρμογές που τηρούν το όριο κόβουν τη σελίδα στις 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 το PDF 1.5 δεν έχει UserUnit, που χρειάζεται μια σελίδα μεγαλύτερη από 14.400 pt (200 in) για να κρατήσει το μέγεθός της: το αρχείο γράφεται ως PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units η σελίδα του πίνακα σχεδίασης {n} είναι {size}, πολύ μακριά και στενή για τις πλευρές σελίδας PDF από 3 έως 14.400 μονάδες: γράφεται με UserUnit {unit}, που κρατά τη μικρή πλευρά στις 3 μονάδες ή περισσότερο, και οι εφαρμογές που τηρούν το όριο κόβουν τη μεγάλη πλευρά στις 14.400 μονάδες +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard τα αρχεία {label} δέχονται πραγματικούς αριθμούς έως {max}, και η σελίδα του πίνακα σχεδίασης {n}, μήκους {side} pt, χρειάζεται μεγαλύτερους: επιλέξτε PDF 1.6 ή νεότερο χωρίς πρότυπο PDF/X, ή μικρότερο πίνακα σχεδίασης @msg invalid image data for `{0}` μη έγκυρα δεδομένα εικόνας για `{0}` @msg invalid parameters for `{cmd}`: {msg} μη έγκυρες παράμετροι για `{cmd}`: {msg} @msg is not an image VectorCraft reads δεν είναι μορφή εικόνας που μπορεί να διαβάσει το VectorCraft @@ -4617,6 +4657,20 @@ digits Malayalam Μαλαγιάλαμ @msg unknown overprints `{o}` (preserve, simulate or discard) άγνωστη επιλογή υπερεκτύπωσης `{o}` (preserve, simulate ή discard) @msg unknown panel `{raw}` άγνωστος πίνακας `{raw}` @msg unknown preference `{key}` άγνωστη προτίμηση `{key}` +@msg Preferences imported. Οι προτιμήσεις εισήχθησαν. +@msg not a preferences file: {e} δεν είναι αρχείο προτιμήσεων: {e} +@msg not a preferences file: no JSON object δεν είναι αρχείο προτιμήσεων: δεν υπάρχει αντικείμενο JSON +@msg not a preferences file (format `{format}`) δεν είναι αρχείο προτιμήσεων (μορφή `{format}`) +@msg not a preferences file: no `format` δεν είναι αρχείο προτιμήσεων: δεν υπάρχει `format` +@msg not a preferences file: no `version` δεν είναι αρχείο προτιμήσεων: δεν υπάρχει `version` +@msg not a preferences file: no `prefs` δεν είναι αρχείο προτιμήσεων: δεν υπάρχει `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) το αρχείο αποθηκεύτηκε από νεότερη έκδοση του VectorCraft (έκδοση {v}) +@msg the file holds no preferences το αρχείο δεν περιέχει προτιμήσεις +@msg `{key}` names a folder, which preferences files leave out το `{key}` ορίζει φάκελο, και τα αρχεία προτιμήσεων δεν περιέχουν φακέλους +@msg the file is larger than 1 MB το αρχείο είναι μεγαλύτερο από 1 MB +@msg give `path` or `data` δώστε `path` ή `data` +@msg `{key}` must be an object το `{key}` πρέπει να είναι αντικείμενο +@msg `prefs.import` runs on its own, not as a batch step το `prefs.import` εκτελείται μόνο του, όχι ως βήμα δέσμης @msg unknown preset (one of {_1}) άγνωστη προεπιλογή (μία από τις {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) άγνωστη προεπιλογή `{name}` (high, medium, low ή αποθηκευμένη: δείτε flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) άγνωστη προεπιλογή `{name}` (δείτε flattener.presets.list) @@ -4835,6 +4889,8 @@ digits Malayalam Μαλαγιάλαμ @msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος @msg `{key}` entry {index} is {value}, not a non-negative integer object id το στοιχείο {index} του `{key}` είναι {value}, όχι αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) @msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value} +@msg id {value} is a ruler guide, not an object το αναγνωριστικό {value} ανήκει σε οδηγό, όχι σε αντικείμενο +@msg a ruler guide goes only directly in a layer or sublayer ένας οδηγός μπορεί να βρίσκεται μόνο απευθείας σε επίπεδο ή υποεπίπεδο @msg id {value} is a layer, not an object το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο @msg id {value} is a layer, not an object; move layers with layer.move το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο· μετακινήστε τα επίπεδα με το layer.move @msg automation filesystem access is not granted: read authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα ανάγνωσης: {path} @@ -4970,7 +5026,8 @@ digits Malayalam Μαλαγιάλαμ Bevel Inside Εσωτερική λοξότμηση Bevel both sides Λοξότμηση και των δύο πλευρών @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ένα αντικείμενο δέχεται ένα εφέ 3D. Επεξεργαστείτε τις επιλογές του υπάρχοντος εφέ ή αφαιρέστε το πρώτα. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο ή ομάδα. Μετατρέψτε πρώτα τις εικόνες και τα άλλα αντικείμενα σε διαδρομές. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο, ομάδα, ανάμειξη ή στιγμιότυπο συμβόλου. Επεκτείνετε πρώτα τα άλλα αντικείμενα. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. Το {label} δεν μπορεί να δημιουργήσει στερεό από εικόνα: ανιχνεύστε την πρώτα με Αντικείμενο › Ανίχνευση περιγραμμάτων εικόνας › Κατασκευαστής. @msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης @msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D @msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα @@ -5556,6 +5613,7 @@ effect Points Σημεία # Strings added upstream after this catalog was written (machine-drafted; review welcome). cancels | Double-click the tool for options για ακύρωση | Διπλό κλικ στο εργαλείο για επιλογές out from a highlighted edge to place its dimension | προς τα έξω από μια επισημασμένη ακμή για τοποθέτηση της διάστασής της | + two points, then move and click to place an aligned, horizontal or vertical dimension | σε δύο σημεία, μετά μετακινήστε και κάντε κλικ για τοποθέτηση ευθυγραμμισμένης, οριζόντιας ή κατακόρυφης διάστασης | A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Ένα αρχείο .ai παλαιού τύπου περιέχει έναν πίνακα σχεδίασης: κάθε πίνακας σχεδίασης που επιλέγετε αποθηκεύεται ως ξεχωριστό αρχείο. Arrange Intertwine Area Διάταξη περιοχής διαπλοκής Batch… Μαζική επεξεργασία… @@ -5652,14 +5710,17 @@ effect Points Σημεία @msg Invalid drawing scale Μη έγκυρη κλίμακα σχεδίου @msg Invalid real scale Μη έγκυρη πραγματική κλίμακα @msg Measured anchor no longer exists Το μετρημένο σημείο αγκύρωσης δεν υπάρχει πλέον +@msg Invalid measured point Μη έγκυρο μετρημένο σημείο @msg Measured segment no longer exists Το μετρημένο τμήμα δεν υπάρχει πλέον @msg Non-finite dimension Η διάσταση δεν είναι πεπερασμένος αριθμός +@msg Only linear dimensions are horizontal or vertical Μόνο οι γραμμικές διαστάσεις είναι οριζόντιες ή κατακόρυφες @msg Scale Text Attributes: selected type has no scale to bake Κλιμάκωση ιδιοτήτων κειμένου: το επιλεγμένο κείμενο δεν έχει κλίμακα προς εφαρμογή @msg Scale must be drawing:real, for example 1:10 Η κλίμακα πρέπει να είναι σχέδιο:πραγματικό, για παράδειγμα 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Ο λόγος κλίμακας πρέπει να είναι μεταξύ 1:1000000 και 1000000:1 @msg Second measured segment no longer exists Το δεύτερο μετρημένο τμήμα δεν υπάρχει πλέον @msg Unknown dimension mode Άγνωστη λειτουργία διάστασης @msg Unknown dimension option {k} Άγνωστη επιλογή διάστασης {k} +@msg Unknown dimension orientation Άγνωστος προσανατολισμός διάστασης @msg `mode` must be grayscale, rgb, hsb, cmyk or websafe το `mode` πρέπει να είναι grayscale, rgb, hsb, cmyk ή websafe @msg `{key}` must be an array of these names: {_1} το `{key}` πρέπει να είναι πίνακας με αυτά τα ονόματα: {_1} @msg choose a destination folder επιλέξτε φάκελο προορισμού @@ -5914,6 +5975,20 @@ axis Y Axis Άξονας Y Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής Keep settings of its own Διατήρηση δικών του ρυθμίσεων Keep a cache of its own, which you can clear Διατήρηση δικής του κρυφής μνήμης, την οποία μπορείτε να εκκαθαρίσετε + Run these commands on the document: {ids} Εκτέλεση αυτών των εντολών στο έγγραφο: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call το «{name}» αναφέρει το `{id}` στο `commandAllowlist`, το οποίο ένα πρόσθετο δεν επιτρέπεται να καλέσει +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: το `commandAllowlist` του μανιφέστου δεν την αναφέρει +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: το αναγνωριστικό ή τα ορίσματα βρίσκονται εκτός της μνήμης της λειτουργικής μονάδας +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: το αναγνωριστικό ή τα ορίσματα δεν είναι UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: τα ορίσματα δεν είναι αντικείμενο JSON +@msg only a filter with the usual budget declares `commandAllowlist` μόνο ένα φίλτρο με τον συνήθη προϋπολογισμό δηλώνει `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) κλήσεις εντολών ({MAX_CALLS} κλήσεις και {MAX_CALL_BYTES} byte ανά εκτέλεση) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) μέγεθος κλήσης εντολής (vc_call `{shown}`: {len} byte, όριο {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` το πολύ {MAX_ALLOWED_COMMANDS} εντολές στο `commandAllowlist` +@msg a plug-in may not call this command ένα πρόσθετο δεν επιτρέπεται να καλέσει αυτή την εντολή +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` το `commandAllowlist` χρειάζεται το δικαίωμα `commands`, και το `commands` χρειάζεται ένα `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice το `commandAllowlist` αναφέρει το `{shown}`, που δεν είναι αναγνωριστικό εντολής ή αναφέρεται δύο φορές Clear Plug-in Cache Εκκαθάριση κρυφής μνήμης προσθέτου @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `{permission}` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature} @@ -6003,6 +6078,23 @@ axis Y Axis Άξονας Y Button Κουμπί Panel Πίνακας UI Frames Πλαίσια UI + Map Markers Δείκτες χάρτη + Pin Καρφίτσα + Flag Σημαία + Crosshair Σταυρόνημα + Checkpoint Σημείο ελέγχου + Inventory Icons Εικονίδια αποθέματος + Skill Icons Εικονίδια δεξιοτήτων + Attack Επίθεση + Defend Άμυνα + Heal Θεραπεία + Buff Ενίσχυση + Ult Ult + Sword Ξίφος + Shield Ασπίδα + Potion Φίλτρο + Key Κλειδί + Gem Πετράδι Open Symbol Library Άνοιγμα βιβλιοθήκης συμβόλων Save Symbol Library Αποθήκευση βιβλιοθήκης συμβόλων Save Symbol Library… Αποθήκευση βιβλιοθήκης συμβόλων… @@ -6057,6 +6149,16 @@ axis Y Axis Άξονας Y User Brush Library 9 Βιβλιοθήκη πινέλων χρήστη 9 User Brush Library 10 Βιβλιοθήκη πινέλων χρήστη 10 Art Ribbons Γραφικές κορδέλες + Zigzag Ζιγκζάγκ + Burst Ray Ακτίνα κρούσης + Dash Trail Διακεκομμένο ίχνος + Speed Line Γραμμή ταχύτητας + Game Trails Ίχνη παιχνιδιού + Energy Beams Δέσμες ενέργειας + Laser Bolt Βολίδα λέιζερ + Plasma Arc Τόξο πλάσματος + Charge Wave Κύμα φόρτισης + Spark Trail Ίχνος σπινθήρων Calligraphic Nibs Καλλιγραφικές πένες Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς @@ -6646,3 +6748,33 @@ breakText Glyphs Γλύφοι Ignore All Παράβλεψη όλων Add to Dictionary Προσθήκη στο λεξικό No Suggestions Καμία πρόταση + Edit Custom Dictionary Επεξεργασία προσαρμοσμένου λεξικού + Entry: Λήμμα: +# Script Console + Script Console Κονσόλα σεναρίων + Clear Script Console Εκκαθάριση κονσόλας σεναρίων + Copy Script Console Αντιγραφή κονσόλας σεναρίων + Copy All Αντιγραφή όλων + Copy Selected Αντιγραφή επιλεγμένων + Finished Ολοκληρώθηκε + Ended with an error Τερματίστηκε με σφάλμα + Out of fuel Εξαντλήθηκε το όριο εντολών + Ended at a host limit Τερματίστηκε σε όριο του κεντρικού συστήματος + Ended at a limit Τερματίστηκε σε όριο + Stopped Διακόπηκε + Ended by an internal fault Τερματίστηκε λόγω εσωτερικού σφάλματος + time unavailable ο χρόνος δεν είναι διαθέσιμος + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} πιο αργές: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} κλήση προς το κεντρικό σύστημα|{n} κλήσεις προς το κεντρικό σύστημα +@plural {n} earlier line dropped|{n} earlier lines dropped {n} παλαιότερη γραμμή απορρίφθηκε|{n} παλαιότερες γραμμές απορρίφθηκαν +@plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν +@msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last` + +# Deferred plug-in effects + Refresh Deferred Effects Ανανέωση εφέ σε αναμονή + Deferred Effects Εφέ σε αναμονή +@msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0f3947277..4a74e3ae3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -289,6 +289,7 @@ font-technology Variable Variables Average… Promediar… Axis: Eje: Background Color: Color de fondo: + Guide Label: Etiqueta de las guías: Show Center Mark Mostrar marca central Show Cross Hairs Mostrar líneas en cruz Show Video Safe Areas Mostrar áreas seguras de video @@ -1658,6 +1659,7 @@ font-technology Variable Variables Include Linked Files Incluir archivos vinculados Include Metadata Incluir metadatos Include non-printing layers Incluir capas no imprimibles + Write pages longer than 200 in at full size in points Escribir las páginas de más de 200 in a tamaño real, en puntos Include SVG Code Incluir código SVG Include Swatches for Tints Incluir muestras para matices Inflate… Inflar… @@ -1896,6 +1898,7 @@ font-technology Variable Variables Lock Grid Bloquear cuadrícula Lock Guides Bloquear guías Lock Guides by Default Bloquear guías de forma predeterminada + Artboard Guides with Cmd/Ctrl + Ruler Drag Guías de mesa de trabajo con Cmd/Ctrl + arrastre desde la regla Lock Others Bloquear otros Lock Slices Bloquear sectores Lock Station Point Bloquear punto de estación @@ -2332,6 +2335,8 @@ font-technology Variable Variables Open… Abrir… Optical Óptico Optical Margin Alignment Alineación óptica de márgenes + Dynamic Text Texto dinámico +@msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames} Optimize for fast web view Optimizar para vista web rápida Optimized Optimizado Options Opciones @@ -2819,6 +2824,12 @@ digits Malayalam Malayálam Reset OpenType Features Restablecer funciones OpenType Reset Panel Restablecer panel Reset Preferences Restablecer preferencias + Export Preferences… Exportar preferencias… + Import Preferences… Importar preferencias… + Save the applied preferences to a JSON file Guardar las preferencias aplicadas en un archivo JSON + Apply the preferences in a file Export Preferences saved Aplicar las preferencias de un archivo guardado con Exportar preferencias + Export Preferences Exportar preferencias + Import Preferences Importar preferencias Search Preferences Buscar en preferencias No settings found No se encontraron ajustes Reset Profiles Restablecer perfiles @@ -2841,6 +2852,8 @@ digits Malayalam Malayálam Responsive Adaptable Restore Restaurar Restore every preference to its default (applied on OK) Restaurar todas las preferencias a sus valores predeterminados (se aplica al Aceptar) + Reset all preferences to their defaults? ¿Restablecer todas las preferencias a sus valores predeterminados? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Los valores predeterminados se aplican al hacer clic en Aceptar en Preferencias. Cancelar ahí conserva sus preferencias actuales. Restore Recovered Documents Restaurar documentos recuperados Restored documents open unsaved: save them to keep them. Los documentos restaurados se abren sin guardar: guárdalos para conservarlos. Restrict printing, editing and other tasks Restringir la impresión, la edición y otras tareas @@ -3139,6 +3152,20 @@ digits Malayalam Malayálam Settings… Ajustes… Setup… Configuración… Shadows and Glows Sombras y resplandores + Game HUD HUD de juego + Health Fill Salud + Mana Glow Maná + Damage Flash Daño + Quest Outline Misión + Coin Shine Moneda + Boss Alert Jefe + Status Effects Efectos de estado + Poison Tint Veneno + Freeze Edge Congelación + Burn Aura Quemadura + Stun Pulse Aturdimiento + Stealth Fade Sigilo + Shield Buff Escudo Shape Forma Shape Builder Generador de formas Shape Builder Regions Regiones del generador de formas @@ -3558,6 +3585,8 @@ digits Malayalam Malayálam Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula) Gutter: Medianil: Tint Matiz + Fill Tint Matiz de relleno + Stroke Tint Matiz de trazo Tip extends past the end point La punta se extiende más allá del punto final Tip on the end point Punta en el punto final Title Case Tipo Título @@ -4072,6 +4101,7 @@ axis Both Ambos Stop Script Detener script Show External Script Warning Mostrar advertencia de scripts externos A Script Is Running Se está ejecutando un script + Script Alert Aviso del script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script puede cambiar los documentos abiertos y escribir archivos mediante los comandos que ejecuta. Ejecute solo scripts de confianza. Do not show this warning again No volver a mostrar esta advertencia Stop the script and undo what it did? Then quit again. ¿Detener el script y deshacer lo que hizo? Después, vuelva a salir. @@ -4085,6 +4115,9 @@ axis Both Ambos @msg the script is too large to run el script es demasiado grande para ejecutarlo @msg a drag or an undo group is open; finish it before running a script hay un arrastre o un grupo de deshacer abierto; termínelo antes de ejecutar un script @msg the script left a document that cannot be kept el script dejó un documento que no se puede conservar +@msg the script is no longer in the Scripts folder el script ya no está en la carpeta Scripts + Some scripts were not listed Algunos scripts no se han incluido en la lista + The Scripts folder cannot be read No se puede leer la carpeta Scripts @msg the script's run is no longer available la ejecución del script ya no está disponible @msg a failed command changed undo steps that could not be put back, so the script was stopped un comando fallido cambió pasos de deshacer que no se pudieron restaurar, así que el script se detuvo @msg a recovery copy could not be written, so the script was not run no se pudo escribir una copia de recuperación, así que el script no se ejecutó @@ -4269,6 +4302,7 @@ axis Both Ambos @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis debe ser "vertical", "horizontal" o un ángulo en grados, no `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800" @msg bad color {v} color no válido {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` es null, el nombre de un color de capa o #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio debe ser un número de 0,1 a 10 @msg the Color Picker is not open el Selector de color no está abierto @msg the Simplify slider is not open el regulador de Simplificar no está abierto @@ -4332,6 +4366,11 @@ axis Both Ambos @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) error interno en `{cmd}`: {msg} (el documento quedó como estaba; por favor, informa de este error) @msg internal error: {msg} (please report this bug) error interno: {msg} (por favor, informa de este error) @msg invalid PDF setting: {0} ajuste de PDF no válido: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): se escribe con un UserUnit de {unit}, así que las aplicaciones que ignoran UserUnit la muestran ese número de veces más pequeña +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): las páginas {label} se escriben sin UserUnit, en puntos, y las aplicaciones que respetan el límite recortan la página a 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 no tiene UserUnit, que una página de más de 14.400 pt (200 in) necesita para conservar su tamaño: el archivo se escribe como PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la página de la mesa de trabajo {n} mide {size}, demasiado larga y estrecha para los lados de página de PDF de 3 a 14.400 unidades: se escribe con una UserUnit de {unit}, que mantiene el lado corto en 3 unidades o más, y las aplicaciones que respetan el límite recortan el lado largo a 14.400 unidades +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard los archivos {label} admiten números reales de hasta {max}, y la página de la mesa de trabajo {n}, de {side} pt, necesita otros mayores: elija PDF 1.6 o posterior sin estándar PDF/X, o una mesa de trabajo más pequeña @msg invalid image data for `{0}` datos de imagen no válidos para `{0}` @msg invalid parameters for `{cmd}`: {msg} parámetros no válidos para `{cmd}`: {msg} @msg is not an image VectorCraft reads no es una imagen que VectorCraft pueda leer @@ -4669,6 +4708,20 @@ axis Both Ambos @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` panel desconocido `{raw}` @msg unknown preference `{key}` preferencia desconocida `{key}` +@msg Preferences imported. Preferencias importadas. +@msg not a preferences file: {e} no es un archivo de preferencias: {e} +@msg not a preferences file: no JSON object no es un archivo de preferencias: no hay ningún objeto JSON +@msg not a preferences file (format `{format}`) no es un archivo de preferencias (formato `{format}`) +@msg not a preferences file: no `format` no es un archivo de preferencias: falta `format` +@msg not a preferences file: no `version` no es un archivo de preferencias: falta `version` +@msg not a preferences file: no `prefs` no es un archivo de preferencias: falta `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) el archivo se guardó con una versión más reciente de VectorCraft (versión {v}) +@msg the file holds no preferences el archivo no contiene preferencias +@msg `{key}` names a folder, which preferences files leave out `{key}` indica una carpeta, y los archivos de preferencias no incluyen carpetas +@msg the file is larger than 1 MB el archivo ocupa más de 1 MB +@msg give `path` or `data` indique `path` o `data` +@msg `{key}` must be an object `{key}` debe ser un objeto +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` se ejecuta por sí solo, no como paso de un lote @msg unknown preset (one of {_1}) preajuste desconocido (uno de estos: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preajuste desconocido `{name}` (high, medium, low o uno guardado: consulta flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) preajuste desconocido `{name}` (consulta flattener.presets.list) @@ -4875,6 +4928,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la entrada {index} de `{key}` es {value}, no un identificador de objeto (un entero no negativo) @msg no such object id {value} no existe el objeto con el identificador {value} +@msg id {value} is a ruler guide, not an object el identificador {value} es de una guía, no de un objeto +@msg a ruler guide goes only directly in a layer or sublayer una guía solo puede estar directamente en una capa o subcapa @msg id {value} is a layer, not an object el identificador {value} es una capa, no un objeto @msg id {value} is a layer, not an object; move layers with layer.move el identificador {value} es una capa, no un objeto; mueva las capas con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path} @@ -5417,6 +5472,7 @@ file-filter Swatch libraries Bibliotecas de muestras file-filter Flattener presets Preajustes del acoplador file-filter PDF presets Preajustes de PDF file-filter Print presets Preajustes de impresión +file-filter Preferences Preferencias file-filter Export presets Ajustes de exportación file-filter SVG Compressed SVG comprimido file-filter PDF-compatible .ai .ai compatible con PDF @@ -5550,7 +5606,8 @@ mark-style Japanese Japonesas Bevel both sides Bisel en ambos lados Presets Ajustes preestablecidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objeto admite un solo efecto 3D. Edite las opciones del efecto existente o quítelo primero. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} requiere por ahora un trazado, un trazado compuesto, texto o un grupo. Convierta primero las imágenes y otros objetos en trazados. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} requiere por ahora un trazado, un trazado compuesto, texto, un grupo, una fusión o una instancia de símbolo. Expanda primero los demás objetos. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} no puede crear un sólido a partir de una imagen: cálquela primero con Objeto › Calco de imagen › Crear. @msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma @@ -5617,6 +5674,7 @@ mark-style Japanese Japonesas Preview (1 inch on the page) Vista previa (1 pulgada en la página) Set as Default Establecer como predeterminado out from a highlighted edge to place its dimension | hacia fuera desde un borde resaltado para colocar su cota | + two points, then move and click to place an aligned, horizontal or vertical dimension | en dos puntos, luego mueva y haga clic para colocar una cota alineada, horizontal o vertical | cancels | Double-click the tool for options cancela | Doble clic en la herramienta para ver las opciones Angular Angular Away from the Line Lejos de la línea @@ -5638,14 +5696,17 @@ mark-style Japanese Japonesas @msg Invalid drawing scale Escala de dibujo no válida @msg Invalid real scale Escala real no válida @msg Measured anchor no longer exists El punto de ancla medido ya no existe +@msg Invalid measured point Punto medido no válido @msg Measured segment no longer exists El segmento medido ya no existe @msg Non-finite dimension Cota no finita +@msg Only linear dimensions are horizontal or vertical Solo las cotas lineales son horizontales o verticales @msg Scale Text Attributes: selected type has no scale to bake Escalar atributos de texto: el texto seleccionado no tiene escala que aplicar @msg Scale must be drawing:real, for example 1:10 La escala debe ser dibujo:real, por ejemplo 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 La relación de escala debe estar entre 1:1000000 y 1000000:1 @msg Second measured segment no longer exists El segundo segmento medido ya no existe @msg Unknown dimension mode Modo de cota desconocido @msg Unknown dimension option {k} Opción de cota desconocida {k} +@msg Unknown dimension orientation Orientación de cota desconocida @msg Unknown dimension style Estilo de cota desconocido @msg Unknown unit Unidad desconocida # Revolve cap description @@ -5905,6 +5966,20 @@ axis Y Axis Eje Y Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro Keep settings of its own Guardar una configuración propia Keep a cache of its own, which you can clear Guardar una caché propia, que usted puede vaciar + Run these commands on the document: {ids} Ejecutar estos comandos en el documento: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” incluye `{id}` en `commandAllowlist`, que un plugin no puede llamar +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: el `commandAllowlist` del manifiesto no lo incluye +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: el ID o los argumentos están fuera de la memoria del módulo +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: el ID o los argumentos no son UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: los argumentos no son un objeto JSON +@msg only a filter with the usual budget declares `commandAllowlist` solo un filtro con el presupuesto habitual declara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) llamadas a comandos ({MAX_CALLS} llamadas y {MAX_CALL_BYTES} bytes por ejecución) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) tamaño de una llamada a comando (vc_call `{shown}`: {len} bytes, límite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` como máximo {MAX_ALLOWED_COMMANDS} comandos en `commandAllowlist` +@msg a plug-in may not call this command un plugin no puede llamar a este comando +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` necesita el permiso `commands`, y `commands` necesita un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` incluye `{shown}`, que no es un ID de comando o aparece dos veces Clear Plug-in Cache Vaciar caché del plugin @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `{permission}` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature} @@ -5994,6 +6069,23 @@ axis Y Axis Eje Y Button Botón Panel Panel UI Frames Marcos de IU + Map Markers Marcadores de mapa + Pin Chincheta + Flag Bandera + Crosshair Punto de mira + Checkpoint Punto de control + Inventory Icons Iconos de inventario + Skill Icons Iconos de habilidad + Attack Ataque + Defend Defensa + Heal Curación + Buff Mejora + Ult Ult + Sword Espada + Shield Escudo + Potion Poción + Key Llave + Gem Gema Open Symbol Library Abrir biblioteca de símbolos Save Symbol Library Guardar biblioteca de símbolos Save Symbol Library… Guardar biblioteca de símbolos… @@ -6048,6 +6140,16 @@ axis Y Axis Eje Y User Brush Library 9 Biblioteca de pinceles del usuario 9 User Brush Library 10 Biblioteca de pinceles del usuario 10 Art Ribbons Cintas de ilustración + Zigzag Zigzag + Burst Ray Rayo de impacto + Dash Trail Estela discontinua + Speed Line Línea de velocidad + Game Trails Estelas de juego + Energy Beams Haces de energía + Laser Bolt Proyectil láser + Plasma Arc Arco de plasma + Charge Wave Onda de carga + Spark Trail Estela de chispas Calligraphic Nibs Plumillas caligráficas Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión @@ -6637,3 +6739,33 @@ breakText Glyphs Glifos Ignore All Omitir todo Add to Dictionary Añadir al diccionario No Suggestions No hay sugerencias + Edit Custom Dictionary Editar diccionario personalizado + Entry: Entrada: +# Script Console + Script Console Consola de scripts + Clear Script Console Borrar consola de scripts + Copy Script Console Copiar consola de scripts + Copy All Copiar todo + Copy Selected Copiar selección + Finished Terminado + Ended with an error Terminado con un error + Out of fuel Límite de instrucciones agotado + Ended at a host limit Terminado en un límite del host + Ended at a limit Terminado en un límite + Stopped Detenido + Ended by an internal fault Terminado por un error interno + time unavailable tiempo no disponible + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} más lentos: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} llamada al host|{n} llamadas al host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} línea anterior descartada|{n} líneas anteriores descartadas +@plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last` + +# Deferred plug-in effects + Refresh Deferred Effects Actualizar efectos aplazados + Deferred Effects Efectos aplazados +@msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e2ab48874..f2ec64f60 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -403,6 +403,8 @@ Smart Punctuation… Älykäs välimerkitys… Create Outlines Muunna ääriviivoiksi Optical Margin Alignment Optinen reunustasaus + Dynamic Text Dynaaminen teksti +@msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames} Fill with Placeholder Text Täytä paikkamerkkitekstillä Insert Inline Symbol Lisää tekstinsisäinen symboli Show Hidden Characters Näytä piilotetut merkit @@ -601,6 +603,7 @@ Show Guides Näytä apuviivat Lock Guides Lukitse apuviivat Lock Guides by Default Lukitse apuviivat oletuksena + Artboard Guides with Cmd/Ctrl + Ruler Drag Piirtoalustan apuviivat Cmd/Ctrl + vetämällä viivaimesta Unlock Guides Avaa apuviivojen lukitus Make Guides Luo apuviivat Release Guides Vapauta apuviivat @@ -766,6 +769,20 @@ Other Library… Muu kirjasto… Graphic Style Libraries Grafiikkatyylikirjastot Shadows and Glows Varjot ja hehkut + Game HUD Peli-HUD + Health Fill Terveys + Mana Glow Mana + Damage Flash Vahinko + Quest Outline Tehtävä + Coin Shine Kolikko + Boss Alert Bossi + Status Effects Tilatehosteet + Poison Tint Myrkky + Freeze Edge Jäädytys + Burn Aura Poltto + Stun Pulse Tainnutus + Stealth Fade Hiipiminen + Shield Buff Kilpi Outlines and Rules Ääriviivat ja viivat Hand-Drawn Käsin piirretyt Gradient Finishes Liukuväriviimeistelyt @@ -2236,6 +2253,8 @@ digits Malayalam Malajalam Green Vihreä Shifts the tints of global and spot colors; other colors stay. Siirtää globaalien värien ja spottivärien sävyjä; muut värit säilyvät. Tint Sävy + Fill Tint Täytön sävy + Stroke Tint Viivan sävy Color Guide Options Väriopasteen asetukset Less Vähemmän Steps: Vaiheet: @@ -2590,6 +2609,7 @@ digits Malayalam Malajalam File Dimensions Tiedoston mitat Transforms Muunnokset Background Color: Taustaväri: + Guide Label: Apuviivojen tunniste: Show Center Mark Näytä keskimerkki Show Cross Hairs Näytä hiusristikko Show Video Safe Areas Näytä videon turva-alueet @@ -2834,6 +2854,7 @@ antiAlias Sharp Terävä General › Thumbnails Yleiset › Pikkukuvat Grayscale images Harmaasävykuvat Include non-printing layers Sisällytä tulostumattomat tasot + Write pages longer than 200 in at full size in points Kirjoita yli 200 in pitkät sivut täysikokoisina pisteinä Left: Vasen: Mark as trapped Merkitse ylitäytetyksi Marks and Bleeds Merkit ja leikkuuvarat @@ -3681,6 +3702,12 @@ theme Appearance Ulkoasu Real-time Drawing and Editing Reaaliaikainen piirto ja muokkaus Render Threads (-1 = Automatic) Renderöintisäikeet (-1 = automaattinen) Reset Preferences Palauta asetukset + Export Preferences… Vie asetukset… + Import Preferences… Tuo asetukset… + Save the applied preferences to a JSON file Tallenna käytössä olevat asetukset JSON-tiedostoon + Apply the preferences in a file Export Preferences saved Ota käyttöön asetukset tiedostosta, jonka Vie asetukset tallensi + Export Preferences Vie asetukset + Import Preferences Tuo asetukset Search Preferences Hae asetuksista No settings found Asetuksia ei löytynyt Scale Corners Skaalaa kulmat @@ -4055,6 +4082,8 @@ font-technology Variable Muuttuvat Preferences Asetukset Previous Edellinen Restore every preference to its default (applied on OK) Palauta kaikki asetukset oletuksiin (käytetään, kun valitaan OK) + Reset all preferences to their defaults? Palautetaanko kaikki asetukset oletuksiin? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Oletukset otetaan käyttöön, kun valitset Asetuksissa OK. Peruuta siellä säilyttää nykyiset asetuksesi. Smaller ↔ Larger Pienempi ↔ Suurempi Smart Guides (View → Smart Guides) Älykkäät apuviivat (Näytä → Älykkäät apuviivat) Couldn't save recovery data: {e} Palautustietoja ei voitu tallentaa: {e} @@ -4296,6 +4325,7 @@ imageTrace Outline Ääriviiva Stop Script Pysäytä komentosarja Show External Script Warning Näytä ulkoisten komentosarjojen varoitus A Script Is Running Komentosarja on käynnissä + Script Alert Komentosarjan ilmoitus A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Komentosarja voi muuttaa avoimia asiakirjoja ja kirjoittaa tiedostoja suorittamillaan komennoilla. Suorita vain komentosarjoja, joihin luotat. Do not show this warning again Älä näytä tätä varoitusta uudelleen Stop the script and undo what it did? Then quit again. Pysäytetäänkö komentosarja ja kumotaan sen muutokset? Lopeta sitten uudelleen. @@ -4309,6 +4339,9 @@ imageTrace Outline Ääriviiva @msg the script is too large to run komentosarja on liian suuri suoritettavaksi @msg a drag or an undo group is open; finish it before running a script vetäminen tai kumousryhmä on kesken; viimeistele se ennen komentosarjan suorittamista @msg the script left a document that cannot be kept komentosarja jätti asiakirjan, jota ei voi säilyttää +@msg the script is no longer in the Scripts folder komentosarja ei ole enää Scripts-kansiossa + Some scripts were not listed Kaikkia komentosarjoja ei ole lueteltu + The Scripts folder cannot be read Scripts-kansiota ei voi lukea @msg the script's run is no longer available komentosarjan suoritus ei ole enää käytettävissä @msg a failed command changed undo steps that could not be put back, so the script was stopped epäonnistunut komento muutti kumoamisvaiheita, joita ei voitu palauttaa, joten komentosarja pysäytettiin @msg a recovery copy could not be written, so the script was not run palautuskopiota ei voitu kirjoittaa, joten komentosarjaa ei suoritettu @@ -4491,6 +4524,7 @@ imageTrace Outline Ääriviiva @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis-arvon on oltava "vertical", "horizontal" tai kulma asteina, ei `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tausta {other}: transparent, white, black tai väri, kuten "#ff8800" @msg bad color {v} virheellinen väri {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` on null, tasovärin nimi tai #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio on oltava luku väliltä 0,1–10 @msg bad colour `{hex}` (#rrggbb) virheellinen väri `{hex}` (#rrggbb) @msg bad page tree virheellinen sivupuu @@ -4549,6 +4583,11 @@ imageTrace Outline Ääriviiva @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) sisäinen virhe kohteessa `{cmd}`: {msg} (asiakirja jätettiin ennalleen; ilmoita tästä virheestä) @msg internal error: {msg} (please report this bug) sisäinen virhe: {msg} (ilmoita tästä virheestä) @msg invalid PDF setting: {0} virheellinen PDF-asetus: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: se kirjoitetaan UserUnit-arvolla {unit}, joten UserUnitin ohittavat sovellukset näyttävät sen niin monta kertaa pienempänä +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: {label}-sivut kirjoitetaan ilman UserUnitia, pisteinä, ja rajaa noudattavat sovellukset rajaavat sivun 200 in:iin +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5:ssä ei ole UserUnitia, jota yli 14 400 pt:n (200 in) sivu tarvitsee säilyttääkseen kokonsa: tiedosto kirjoitetaan PDF 1.6:na +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units piirtoalustan {n} sivu on {size}, liian pitkä ja kapea PDF:n sivuille, jotka ovat 3:sta 14 400 yksikköön: se kirjoitetaan UserUnit-arvolla {unit}, joka pitää lyhyen sivun vähintään 3 yksikössä, ja rajaa noudattavat sovellukset leikkaavat pitkän sivun 14 400 yksikköön +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-tiedostot sallivat reaaliluvut enintään {max}, ja piirtoalustan {n} sivu, {side} pt pitkä, tarvitsee suurempia: valitse PDF 1.6 tai uudempi ilman PDF/X-standardia tai pienempi piirtoalusta @msg invalid image data for `{0}` virheellinen kuvadata kohteelle `{0}` @msg invalid parameters for `{cmd}`: {msg} virheelliset parametrit kohteelle `{cmd}`: {msg} @msg is not an image VectorCraft reads ei ole kuva, jota VectorCraft lukee @@ -4879,6 +4918,20 @@ imageTrace Outline Ääriviiva @msg unknown overprints `{o}` (preserve, simulate or discard) tuntematon päällepainatusten käsittely `{o}` (preserve, simulate tai discard) @msg unknown panel `{raw}` tuntematon paneeli `{raw}` @msg unknown preference `{key}` tuntematon asetus `{key}` +@msg Preferences imported. Asetukset tuotiin. +@msg not a preferences file: {e} ei ole asetustiedosto: {e} +@msg not a preferences file: no JSON object ei ole asetustiedosto: ei JSON-objektia +@msg not a preferences file (format `{format}`) ei ole asetustiedosto (muoto `{format}`) +@msg not a preferences file: no `format` ei ole asetustiedosto: `format` puuttuu +@msg not a preferences file: no `version` ei ole asetustiedosto: `version` puuttuu +@msg not a preferences file: no `prefs` ei ole asetustiedosto: `prefs` puuttuu +@msg the file was saved by a newer version of VectorCraft (version {v}) tiedosto on tallennettu VectorCraftin uudemmalla versiolla (versio {v}) +@msg the file holds no preferences tiedostossa ei ole asetuksia +@msg `{key}` names a folder, which preferences files leave out `{key}` nimeää kansion, eikä asetustiedostoissa ole kansioita +@msg the file is larger than 1 MB tiedosto on suurempi kuin 1 Mt +@msg give `path` or `data` anna `path` tai `data` +@msg `{key}` must be an object `{key}` on oltava objekti +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` ajetaan yksinään, ei erän vaiheena @msg unknown preset (one of {_1}) tuntematon esiasetus (jokin seuraavista: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) tuntematon esiasetus `{name}` (high, medium, low tai tallennettu: ks. flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) tuntematon esiasetus `{name}` (ks. flattener.presets.list) @@ -5059,6 +5112,8 @@ imageTrace Outline Ääriviiva @msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-luettelon kohde {index} on {value}, ei objektitunnus (ei-negatiivinen kokonaisluku) @msg no such object id {value} objektia tunnuksella {value} ei ole +@msg id {value} is a ruler guide, not an object tunnus {value} on apuviivan, ei objektin +@msg a ruler guide goes only directly in a layer or sublayer apuviiva voi olla vain suoraan tasossa tai alatasossa @msg id {value} is a layer, not an object tunnus {value} on taso, ei objekti @msg id {value} is a layer, not an object; move layers with layer.move tunnus {value} on taso, ei objekti; siirrä tasoja komennolla layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: lukuoikeus puuttuu: {path} @@ -5434,6 +5489,7 @@ file-filter Swatch libraries Värimallikirjastot file-filter Flattener presets Litistyksen esiasetukset file-filter PDF presets PDF-esiasetukset file-filter Print presets Tulostuksen esiasetukset +file-filter Preferences Asetukset file-filter Export presets Vientiesiasetukset file-filter SVG Compressed Pakattu SVG file-filter PDF-compatible .ai PDF-yhteensopiva .ai @@ -5493,7 +5549,8 @@ mark-style Japanese Japanilainen Bevel both sides Viiste molemmin puolin Presets Esiasetukset @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objektilla voi olla yksi 3D-tehoste. Muokkaa olemassa olevan tehosteen asetuksia tai poista se ensin. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} vaatii tällä hetkellä polun, yhdistelmäpolun, tekstin tai ryhmän. Muunna kuvat ja muut objektit ensin poluiksi. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} vaatii tällä hetkellä polun, yhdistelmäpolun, tekstin, ryhmän, sulautuksen tai symbolin ilmentymän. Laajenna muut objektit ensin. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ei voi luoda kappaletta kuvasta: jäljitä se ensin komennolla Objekti › Kuvan jäljitys › Luo. @msg {label} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria @msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle @msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa @@ -5568,6 +5625,7 @@ mark-style Japanese Japanilainen # Strings added upstream after this catalog was written (machine-drafted; review welcome). cancels | Double-click the tool for options peruuttaa | Kaksoisnapsauta työkalua avataksesi asetukset out from a highlighted edge to place its dimension | ulospäin korostetusta reunasta sijoittaaksesi sen mitan | + two points, then move and click to place an aligned, horizontal or vertical dimension | kahta pistettä, siirrä sitten ja napsauta sijoittaaksesi tasatun, vaaka- tai pystysuuntaisen mitan | A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Vanhassa .ai-tiedostossa on yksi piirtoalusta: jokainen valitsemasi piirtoalusta tallennetaan omaksi tiedostokseen. Arrange Intertwine Area Järjestä lomitusalue Batch… Erä… @@ -5664,14 +5722,17 @@ mark-style Japanese Japanilainen @msg Invalid drawing scale Virheellinen piirustuksen mittakaava @msg Invalid real scale Virheellinen todellinen mittakaava @msg Measured anchor no longer exists Mitattua ankkuripistettä ei ole enää +@msg Invalid measured point Virheellinen mitattu piste @msg Measured segment no longer exists Mitattua segmenttiä ei ole enää @msg Non-finite dimension Mitta ei ole äärellinen luku +@msg Only linear dimensions are horizontal or vertical Vain lineaariset mitat ovat vaaka- tai pystysuuntaisia @msg Scale Text Attributes: selected type has no scale to bake Skaalaa tekstin attribuutit: valitulla tekstillä ei ole sovellettavaa skaalausta @msg Scale must be drawing:real, for example 1:10 Mittakaavan on oltava muotoa piirustus:todellinen, esimerkiksi 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Mittakaavan suhteen on oltava välillä 1:1000000–1000000:1 @msg Second measured segment no longer exists Toista mitattua segmenttiä ei ole enää @msg Unknown dimension mode Tuntematon mitoitustila @msg Unknown dimension option {k} Tuntematon mitoitusasetus {k} +@msg Unknown dimension orientation Tuntematon mitan suunta @msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode`-arvon on oltava grayscale, rgb, hsb, cmyk tai websafe @msg `{key}` must be an array of these names: {_1} `{key}`-arvon on oltava taulukko, jossa on näitä nimiä: {_1} @msg choose a destination folder valitse kohdekansio @@ -5993,6 +6054,20 @@ axis Y Axis Y-akseli Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä Keep settings of its own Säilyttää omat asetuksensa Keep a cache of its own, which you can clear Säilyttää oman välimuistin, jonka voit tyhjentää + Run these commands on the document: {ids} Suorittaa nämä komennot asiakirjassa: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call ”{name}” luettelee `commandAllowlist`-luettelossa komennon `{id}`, jota laajennus ei saa kutsua +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: manifestin `commandAllowlist` ei luettele sitä +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: tunnus tai argumentit ovat moduulin muistin ulkopuolella +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: tunnus tai argumentit eivät ole UTF-8:aa +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumentit eivät ole JSON-objekti +@msg only a filter with the usual budget declares `commandAllowlist` vain tavallisen budjetin suodatin voi määrittää `commandAllowlist`-luettelon +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) komentokutsut ({MAX_CALLS} kutsua ja {MAX_CALL_BYTES} tavua ajoa kohden) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) komentokutsun koko (vc_call `{shown}`: {len} tavua, raja {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` enintään {MAX_ALLOWED_COMMANDS} komentoa `commandAllowlist`-luettelossa +@msg a plug-in may not call this command laajennus ei saa kutsua tätä komentoa +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` tarvitsee käyttöoikeuden `commands`, ja `commands` tarvitsee `commandAllowlist`-luettelon +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` mainitsee kohteen `{shown}`, joka ei ole komennon tunnus tai on luettelossa kahdesti Clear Plug-in Cache Tyhjennä laajennuksen välimuisti @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `{permission}` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature} @@ -6082,6 +6157,23 @@ axis Y Axis Y-akseli Button Painike Panel Paneeli UI Frames UI-kehykset + Map Markers Karttamerkit + Pin Nasta + Flag Lippu + Crosshair Tähtäin + Checkpoint Tarkastuskohta + Inventory Icons Inventaarioikonit + Skill Icons Taitoikonit + Attack Hyökkäys + Defend Puolustus + Heal Parannus + Buff Buffi + Ult Ult + Sword Miekka + Shield Kilpi + Potion Taikajuoma + Key Avain + Gem Jalokivi Open Symbol Library Avaa symbolikirjasto Save Symbol Library Tallenna symbolikirjasto Save Symbol Library… Tallenna symbolikirjasto… @@ -6136,6 +6228,16 @@ axis Y Axis Y-akseli User Brush Library 9 Käyttäjän sivellinkirjasto 9 User Brush Library 10 Käyttäjän sivellinkirjasto 10 Art Ribbons Kuvitusnauhat + Zigzag Siksak + Burst Ray Iskusäde + Dash Trail Katkoviiva + Speed Line Nopeusviiva + Game Trails Pelijäljet + Energy Beams Energiasäteet + Laser Bolt Laserpultti + Plasma Arc Plasma-kaari + Charge Wave Latausaalto + Spark Trail Kipinävanne Calligraphic Nibs Kalligrafiakärjet Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot @@ -6729,3 +6831,33 @@ breakText Glyphs Merkit Ignore All Ohita kaikki Add to Dictionary Lisää sanastoon No Suggestions Ei ehdotuksia + Edit Custom Dictionary Muokkaa omaa sanastoa + Entry: Merkintä: +# Script Console + Script Console Komentosarjakonsoli + Clear Script Console Tyhjennä komentosarjakonsoli + Copy Script Console Kopioi komentosarjakonsoli + Copy All Kopioi kaikki + Copy Selected Kopioi valitut + Finished Valmis + Ended with an error Päättyi virheeseen + Out of fuel Käskyraja täyttyi + Ended at a host limit Päättyi isännän rajaan + Ended at a limit Päättyi rajaan + Stopped Pysäytetty + Ended by an internal fault Päättyi sisäiseen virheeseen + time unavailable aika ei käytettävissä + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} hitaimmat: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} isäntäkutsu|{n} isäntäkutsua +@plural {n} earlier line dropped|{n} earlier lines dropped {n} aiempi rivi poistettu|{n} aiempaa riviä poistettu +@plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last` + +# Deferred plug-in effects + Refresh Deferred Effects Päivitä lykätyt tehosteet + Deferred Effects Lykätyt tehosteet +@msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c2c333b6b..807b3363b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -290,6 +290,7 @@ font-technology Variable Variables Average… Alignement… Axis: Axe : Background Color: Couleur d’arrière-plan : + Guide Label: Étiquette des repères : Show Center Mark Afficher le repère central Show Cross Hairs Afficher le réticule Show Video Safe Areas Afficher les zones de sécurité vidéo @@ -1652,6 +1653,7 @@ font-technology Variable Variables Include Linked Files Inclure les fichiers liés Include Metadata Inclure les métadonnées Include non-printing layers Inclure les calques non imprimables + Write pages longer than 200 in at full size in points Écrire les pages de plus de 200 in en taille réelle, en points Include SVG Code Inclure le code SVG Include Swatches for Tints Inclure les nuances des teintes Inflate… Gonfler… @@ -1890,6 +1892,7 @@ font-technology Variable Variables Lock Grid Verrouiller la grille Lock Guides Verrouiller les repères Lock Guides by Default Verrouiller les repères par défaut + Artboard Guides with Cmd/Ctrl + Ruler Drag Repères de plan de travail avec Cmd/Ctrl + glissement depuis la règle Lock Others Verrouiller les autres Lock Slices Verrouiller les tranches Lock Station Point Verrouiller le point d’observation @@ -2324,6 +2327,8 @@ font-technology Variable Variables Open… Ouvrir… Optical Optique Optical Margin Alignment Alignement optique des marges + Dynamic Text Texte dynamique +@msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames} Optimize for fast web view Optimiser pour un affichage web rapide Optimized Optimisé Options Options @@ -2811,6 +2816,12 @@ digits Malayalam Malayalam Reset OpenType Features Réinitialiser les fonctions OpenType Reset Panel Réinitialiser le panneau Reset Preferences Réinitialiser les préférences + Export Preferences… Exporter les préférences… + Import Preferences… Importer des préférences… + Save the applied preferences to a JSON file Enregistrer les préférences appliquées dans un fichier JSON + Apply the preferences in a file Export Preferences saved Appliquer les préférences d’un fichier enregistré par Exporter les préférences + Export Preferences Exporter les préférences + Import Preferences Importer des préférences Search Preferences Rechercher dans les préférences No settings found Aucun réglage trouvé Reset Profiles Réinitialiser les profils @@ -2833,6 +2844,8 @@ digits Malayalam Malayalam Responsive Adaptatif Restore Restaurer Restore every preference to its default (applied on OK) Rétablir toutes les préférences à leur valeur par défaut (appliqué avec OK) + Reset all preferences to their defaults? Rétablir toutes les préférences à leur valeur par défaut ? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Les valeurs par défaut sont appliquées lorsque vous cliquez sur OK dans Préférences. Annuler y conserve vos préférences actuelles. Restore Recovered Documents Restaurer les documents récupérés Restored documents open unsaved: save them to keep them. Les documents restaurés s’ouvrent sans être enregistrés : enregistrez-les pour les conserver. Restrict printing, editing and other tasks Restreindre l’impression, la modification et d’autres tâches @@ -3131,6 +3144,20 @@ digits Malayalam Malayalam Settings… Réglages… Setup… Configuration… Shadows and Glows Ombres et lueurs + Game HUD HUD de jeu + Health Fill Santé + Mana Glow Mana + Damage Flash Dégâts + Quest Outline Quête + Coin Shine Pièce + Boss Alert Boss + Status Effects Effets de statut + Poison Tint Poison + Freeze Edge Gel + Burn Aura Brûlure + Stun Pulse Étourdissement + Stealth Fade Furtivité + Shield Buff Bouclier Shape Forme Shape Builder Concepteur de forme Shape Builder Regions Zones du Concepteur de forme @@ -3549,6 +3576,8 @@ digits Malayalam Malayalam Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille) Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux. Tint Teinte + Fill Tint Teinte du fond + Stroke Tint Teinte du contour Tip extends past the end point La pointe dépasse le point d’extrémité Tip on the end point Pointe sur le point d’extrémité Title Case Majuscule à chaque mot @@ -4061,6 +4090,7 @@ axis Both Les deux Stop Script Arrêter le script Show External Script Warning Afficher l’avertissement des scripts externes A Script Is Running Un script est en cours d’exécution + Script Alert Alerte de script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script peut modifier les documents ouverts et écrire des fichiers au moyen des commandes qu’il exécute. N’exécutez que des scripts de confiance. Do not show this warning again Ne plus afficher cet avertissement Stop the script and undo what it did? Then quit again. Arrêter le script et annuler ce qu’il a fait ? Quittez ensuite à nouveau. @@ -4074,6 +4104,9 @@ axis Both Les deux @msg the script is too large to run le script est trop volumineux pour être exécuté @msg a drag or an undo group is open; finish it before running a script un glissement ou un groupe d’annulation est ouvert ; terminez-le avant d’exécuter un script @msg the script left a document that cannot be kept le script a laissé un document qui ne peut pas être conservé +@msg the script is no longer in the Scripts folder le script ne se trouve plus dans le dossier Scripts + Some scripts were not listed Certains scripts ne sont pas répertoriés + The Scripts folder cannot be read Impossible de lire le dossier Scripts @msg the script's run is no longer available l’exécution du script n’est plus disponible @msg a failed command changed undo steps that could not be put back, so the script was stopped une commande en échec a modifié des étapes d’annulation qui n’ont pas pu être rétablies, le script a donc été arrêté @msg a recovery copy could not be written, so the script was not run une copie de récupération n’a pas pu être écrite, le script n’a donc pas été exécuté @@ -4258,6 +4291,7 @@ axis Both Les deux @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis doit être "vertical", "horizontal" ou un angle en degrés, pas `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" arrière-plan {other} : transparent, white, black ou une couleur comme "#ff8800" @msg bad color {v} couleur non valide {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` est null, le nom d’une couleur de calque ou #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio doit être un nombre de 0,1 à 10 @msg the Color Picker is not open le Sélecteur de couleurs n’est pas ouvert @msg the Simplify slider is not open le curseur Simplifier n’est pas ouvert @@ -4320,6 +4354,11 @@ axis Both Les deux @msg installing from a path is not available on the web; pass the module as `dataBase64` l’installation depuis un chemin n’est pas disponible sur le web ; transmettez le module sous forme de `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) erreur interne dans `{cmd}` : {msg} (le document est resté tel qu’il était ; veuillez signaler ce bogue) @msg internal error: {msg} (please report this bug) erreur interne : {msg} (veuillez signaler ce bogue) +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : elle est écrite avec une UserUnit de {unit}, donc les applications qui ignorent UserUnit l’affichent autant de fois plus petite +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : les pages {label} sont écrites sans UserUnit, en points, et les applications qui respectent la limite coupent la page à 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 n’a pas d’UserUnit, dont une page de plus de 14 400 pt (200 in) a besoin pour garder sa taille : le fichier est écrit en PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la page du plan de travail {n} mesure {size}, trop longue et étroite pour les côtés de page PDF de 3 à 14 400 unités : elle est écrite avec une UserUnit de {unit}, qui garde le petit côté à 3 unités ou plus, et les apps qui respectent la limite coupent le grand côté à 14 400 unités +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard les fichiers {label} acceptent des nombres réels jusqu’à {max}, et la page du plan de travail {n}, longue de {side} pt, en demande de plus grands : choisissez PDF 1.6 ou ultérieur sans norme PDF/X, ou un plan de travail plus petit @msg invalid PDF setting: {0} réglage PDF non valide : {0} @msg invalid image data for `{0}` données d’image non valides pour `{0}` @msg invalid parameters for `{cmd}`: {msg} paramètres non valides pour `{cmd}` : {msg} @@ -4658,6 +4697,20 @@ axis Both Les deux @msg unknown overprints `{o}` (preserve, simulate or discard) surimpressions inconnues `{o}` (preserve, simulate ou discard) @msg unknown panel `{raw}` panneau inconnu `{raw}` @msg unknown preference `{key}` préférence inconnue `{key}` +@msg Preferences imported. Préférences importées. +@msg not a preferences file: {e} ce n’est pas un fichier de préférences : {e} +@msg not a preferences file: no JSON object ce n’est pas un fichier de préférences : aucun objet JSON +@msg not a preferences file (format `{format}`) ce n’est pas un fichier de préférences (format `{format}`) +@msg not a preferences file: no `format` ce n’est pas un fichier de préférences : `format` manquant +@msg not a preferences file: no `version` ce n’est pas un fichier de préférences : `version` manquant +@msg not a preferences file: no `prefs` ce n’est pas un fichier de préférences : `prefs` manquant +@msg the file was saved by a newer version of VectorCraft (version {v}) le fichier a été enregistré par une version plus récente de VectorCraft (version {v}) +@msg the file holds no preferences le fichier ne contient aucune préférence +@msg `{key}` names a folder, which preferences files leave out `{key}` désigne un dossier, et les fichiers de préférences n’incluent pas les dossiers +@msg the file is larger than 1 MB le fichier dépasse 1 Mo +@msg give `path` or `data` indiquez `path` ou `data` +@msg `{key}` must be an object `{key}` doit être un objet +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` s’exécute seul, pas comme étape d’un lot @msg unknown preset (one of {_1}) paramètre prédéfini inconnu (valeurs possibles : {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) paramètre prédéfini inconnu `{name}` (high, medium, low ou un paramètre enregistré : voir flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) paramètre prédéfini inconnu `{name}` (voir flattener.presets.list) @@ -4882,6 +4935,8 @@ axis Both Les deux @msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu) @msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrée {index} de `{key}` vaut {value}, pas un identifiant d’objet (entier positif ou nul) @msg no such object id {value} aucun objet ne porte l’identifiant {value} +@msg id {value} is a ruler guide, not an object l’identifiant {value} est celui d’un repère, pas d’un objet +@msg a ruler guide goes only directly in a layer or sublayer un repère ne peut se trouver que directement dans un calque ou un sous-calque @msg id {value} is a layer, not an object l’identifiant {value} désigne un calque, pas un objet @msg id {value} is a layer, not an object; move layers with layer.move l’identifiant {value} désigne un calque, pas un objet ; déplacez les calques avec layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path} @@ -5180,7 +5235,8 @@ lighting Light Lumière Bevel both sides Biseau des deux côtés Presets Paramètres prédéfinis @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objet n'accepte qu'un seul effet 3D. Modifiez les options de l'effet existant ou supprimez-le d'abord. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} nécessite pour l'instant un tracé, un tracé transparent, du texte ou un groupe. Convertissez d'abord les images et les autres objets en tracés. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} nécessite pour l'instant un tracé, un tracé transparent, du texte, un groupe, un dégradé de formes ou une instance de symbole. Décomposez d'abord les autres objets. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ne peut pas créer de solide à partir d'une image : vectorisez-la d'abord avec Objet › Vectorisation de l’image › Créer. @msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme @@ -5610,6 +5666,7 @@ file-filter Flattener presets Paramètres prédéfinis d’aplatissement file-filter PDF presets Paramètres prédéfinis PDF file-filter PDF-compatible .ai .ai compatible PDF file-filter Print presets Paramètres prédéfinis d’impression +file-filter Preferences Préférences file-filter Export presets Paramètres prédéfinis d’export file-filter SVG Compressed SVG compressé file-filter Swatch libraries Bibliothèques de nuances @@ -5663,6 +5720,7 @@ theme Appearance Apparence Preview (1 inch on the page) Aperçu (1 pouce sur la page) Set as Default Définir par défaut out from a highlighted edge to place its dimension | vers l'extérieur depuis un bord mis en évidence pour placer sa cote | + two points, then move and click to place an aligned, horizontal or vertical dimension | sur deux points, puis déplacez et cliquez pour placer une cote alignée, horizontale ou verticale | cancels | Double-click the tool for options annule | Double-clic sur l'outil pour les options Angular Angulaire Away from the Line À l'écart de la ligne @@ -5692,14 +5750,17 @@ theme Appearance Apparence @msg Invalid drawing scale Échelle du dessin non valide @msg Invalid real scale Échelle réelle non valide @msg Measured anchor no longer exists Le point d’ancrage mesuré n’existe plus +@msg Invalid measured point Point mesuré non valide @msg Measured segment no longer exists Le segment mesuré n’existe plus @msg Non-finite dimension Cote non finie +@msg Only linear dimensions are horizontal or vertical Seules les cotes linéaires sont horizontales ou verticales @msg Scale Text Attributes: selected type has no scale to bake Mettre à l’échelle les attributs de texte : le texte sélectionné n’a pas d’échelle à intégrer @msg Scale must be drawing:real, for example 1:10 L’échelle doit être dessin:réel, par exemple 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Le rapport d’échelle doit être compris entre 1:1000000 et 1000000:1 @msg Second measured segment no longer exists Le second segment mesuré n’existe plus @msg Unknown dimension mode Mode de cote inconnu @msg Unknown dimension option {k} Option de cote inconnue {k} +@msg Unknown dimension orientation Orientation de cote inconnue @msg Unknown dimension style Style de cote inconnu @msg Unknown unit Unité inconnue # Revolve cap description @@ -5958,6 +6019,20 @@ axis Y Axis Axe Y Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal Keep settings of its own Conserver ses propres réglages Keep a cache of its own, which you can clear Conserver son propre cache, que vous pouvez vider + Run these commands on the document: {ids} Exécuter ces commandes sur le document : {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call « {name} » indique `{id}` dans `commandAllowlist`, qu’un module externe ne peut pas appeler +@msg vc_call `{shown}`: {why} vc_call `{shown}` : {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}` : le `commandAllowlist` du manifeste ne l’indique pas +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}` : l’ID ou les arguments se trouvent hors de la mémoire du module +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}` : l’ID ou les arguments ne sont pas en UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}` : les arguments ne sont pas un objet JSON +@msg only a filter with the usual budget declares `commandAllowlist` seul un filtre au budget habituel déclare `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) appels de commandes ({MAX_CALLS} appels et {MAX_CALL_BYTES} octets par exécution) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) taille d’un appel de commande (vc_call `{shown}` : {len} octets, limite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` au plus {MAX_ALLOWED_COMMANDS} commandes dans `commandAllowlist` +@msg a plug-in may not call this command un module externe ne peut pas appeler cette commande +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` a besoin de l’autorisation `commands`, et `commands` a besoin d’un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` indique `{shown}`, qui n’est pas un ID de commande ou figure deux fois Clear Plug-in Cache Vider le cache du module externe @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `{permission}` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature} @@ -6047,6 +6122,23 @@ axis Y Axis Axe Y Button Bouton Panel Panneau UI Frames Cadres d’IU + Map Markers Marqueurs de carte + Pin Épingle + Flag Drapeau + Crosshair Réticule + Checkpoint Point de contrôle + Inventory Icons Icônes d’inventaire + Skill Icons Icônes de compétences + Attack Attaque + Defend Défense + Heal Soin + Buff Bonus + Ult Ult + Sword Épée + Shield Bouclier + Potion Potion + Key Clé + Gem Gemme Open Symbol Library Ouvrir une bibliothèque de symboles Save Symbol Library Enregistrer la bibliothèque de symboles Save Symbol Library… Enregistrer la bibliothèque de symboles… @@ -6101,6 +6193,16 @@ axis Y Axis Axe Y User Brush Library 9 Bibliothèque de formes utilisateur 9 User Brush Library 10 Bibliothèque de formes utilisateur 10 Art Ribbons Rubans d'illustration + Zigzag Zigzag + Burst Ray Rayon d’impact + Dash Trail Traînée tiretée + Speed Line Ligne de vitesse + Game Trails Traînées de jeu + Energy Beams Faisceaux d'énergie + Laser Bolt Trait laser + Plasma Arc Arc de plasma + Charge Wave Vague de charge + Spark Trail Traînée d'étincelles Calligraphic Nibs Plumes calligraphiques Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion @@ -6689,3 +6791,33 @@ breakText Glyphs Glyphes Ignore All Tout ignorer Add to Dictionary Ajouter au dictionnaire No Suggestions Aucune suggestion + Edit Custom Dictionary Modifier le dictionnaire personnalisé + Entry: Entrée : +# Script Console + Script Console Console de scripts + Clear Script Console Effacer la console de scripts + Copy Script Console Copier la console de scripts + Copy All Tout copier + Copy Selected Copier la sélection + Finished Terminé + Ended with an error Terminé avec une erreur + Out of fuel Limite d’instructions atteinte + Ended at a host limit Terminé à une limite de l’hôte + Ended at a limit Terminé à une limite + Stopped Arrêté + Ended by an internal fault Terminé par une erreur interne + time unavailable temps indisponible + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} plus lentes : {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} appel à l’hôte|{n} appels à l’hôte +@plural {n} earlier line dropped|{n} earlier lines dropped {n} ligne antérieure supprimée|{n} lignes antérieures supprimées +@plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last` + +# Deferred plug-in effects + Refresh Deferred Effects Actualiser les effets différés + Deferred Effects Effets différés +@msg no plug-in effect is deferred aucun effet de plug-in n’est différé +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index d39ab857f..572a19779 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -27,6 +27,7 @@ on the canvas to create one बनाने के लिए कैनवास पर on the canvas to create one ( बनाने के लिए कैनवास पर ( out from a highlighted edge to place its dimension | माप रखने के लिए हाइलाइट किए किनारे से बाहर की ओर | + two points, then move and click to place an aligned, horizontal or vertical dimension | दो बिंदुओं पर, फिर संरेखित, क्षैतिज या ऊर्ध्वाधर आयाम रखने के लिए पॉइंटर ले जाएँ और क्लिक करें | over paths to roughen their outlines | आउटलाइन खुरदरी करने के लिए पाथ पर | over paths, and hold still to keep going | पाथ पर, और जारी रखने के लिए स्थिर रखें | right or left to zoom in or out | ज़ूम इन या आउट करने के लिए दाएँ या बाएँ | @@ -421,6 +422,16 @@ antiAlias Sharp शार्प Art Brush Options आर्ट ब्रश ऑप्शन्स Art Optimized आर्टवर्क के लिए अनुकूलित Art Ribbons आर्ट रिबन + Zigzag ज़िगज़ैग + Burst Ray बर्स्ट रे + Dash Trail डैश ट्रेल + Speed Line स्पीड लाइन + Game Trails गेम ट्रेल्स + Energy Beams ऊर्जा किरणें + Laser Bolt लेज़र बोल्ट + Plasma Arc प्लाज़्मा चाप + Charge Wave चार्ज तरंग + Spark Trail चिंगारी पथ Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} @@ -491,6 +502,7 @@ antiAlias Sharp शार्प B5 B5 Back वापस Background Color: बैकग्राउंड रंग: + Guide Label: गाइड लेबल: Background Contents बैकग्राउंड सामग्री Background Level बैकग्राउंड स्तर Background: बैकग्राउंड: @@ -514,6 +526,23 @@ antiAlias Sharp शार्प Button बटन Panel पैनल UI Frames UI फ़्रेम + Map Markers मानचित्र चिह्न + Pin पिन + Flag झंडा + Crosshair क्रॉसहेयर + Checkpoint चेकपॉइंट + Inventory Icons इन्वेंटरी आइकन + Skill Icons कौशल आइकन + Attack आक्रमण + Defend रक्षा + Heal उपचार + Buff बफ़ + Ult अल्ट + Sword तलवार + Shield ढाल + Potion औषधि + Key कुंजी + Gem रत्न Batch बैच Batch Trace Folder फ़ोल्डर बैच ट्रेस करें Batch… बैच… @@ -2239,6 +2268,7 @@ antiAlias Sharp शार्प Include Linked Files लिंक्ड फ़ाइलें शामिल करें Include Metadata मेटाडाटा शामिल Include non-printing layers गैर-प्रिंटिंग लेयर्स को शामिल करें + Write pages longer than 200 in at full size in points 200 in से लंबे पेज पूरे आकार में, पॉइंट में लिखें Include Objects On Canvas कैनवास पर ऑब्जेक्ट्स शामिल करें Include SVG Code SVG कोड शामिल करें Include Swatches for Tints टिंट्स के लिए स्वॉचेस शामिल करें @@ -2556,6 +2586,7 @@ antiAlias Sharp शार्प Lock Grid लॉक ग्रिड Lock Guides गाइड्स लॉक करें Lock Guides by Default गाइड्स डिफ़ॉल्ट रूप से लॉक करें + Artboard Guides with Cmd/Ctrl + Ruler Drag Cmd/Ctrl + रूलर से ड्रैग करके आर्टबोर्ड गाइड्स Lock Others लॉक अन्य Lock Selected लॉक सिलेक्टेड Lock Slices स्लाइस लॉक करें @@ -3106,6 +3137,8 @@ antiAlias Sharp शार्प OpenType Features ओपन टाइप सुविधाएँ Optical ऑप्टिकल Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण + Dynamic Text डायनैमिक टेक्स्ट +@msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames} Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़ Optimized ऑप्टिमाइज़्ड Options ऑप्शन्स @@ -3768,6 +3801,12 @@ antiAlias Sharp शार्प Reset Painting पेंटिंग रीसेट करें Reset Panel पैनल रीसेट करें Reset Preferences प्रेफ़रेंसेज़ रीसेट करें + Export Preferences… प्रेफ़रेंसेज़ निर्यात करें… + Import Preferences… प्रेफ़रेंसेज़ आयात करें… + Save the applied preferences to a JSON file लागू प्रेफ़रेंसेज़ को JSON फ़ाइल में सहेजें + Apply the preferences in a file Export Preferences saved प्रेफ़रेंसेज़ निर्यात करें से सहेजी गई फ़ाइल की प्रेफ़रेंसेज़ लागू करें + Export Preferences प्रेफ़रेंसेज़ निर्यात करें + Import Preferences प्रेफ़रेंसेज़ आयात करें Search Preferences प्रेफ़रेंसेज़ में खोजें No settings found कोई सेटिंग नहीं मिली Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें @@ -3810,6 +3849,8 @@ antiAlias Sharp शार्प Responsive उत्तरदायी Restore पुनर्स्थापित करें Restore every preference to its default (applied on OK) प्रत्येक वरीयता को उसके डिफ़ॉल्ट में पुनर्स्थापित करें ( OK पर लागू) + Reset all preferences to their defaults? सभी प्रेफ़रेंसेज़ को उनके डिफ़ॉल्ट पर रीसेट करें? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. डिफ़ॉल्ट तब लागू होते हैं जब आप प्रेफ़रेंसेज़ में ठीक है पर क्लिक करते हैं। वहाँ रद्द करें आपकी मौजूदा प्रेफ़रेंसेज़ बनाए रखता है। Restore Recovered Documents पुनर्स्थापना डॉक्यूमेंट Restored documents open unsaved: save them to keep them. रिकवर किए डॉक्यूमेंट बिना सेव किए ओपन होते हैं। रखने के लिए उन्हें सेव करें। Restrict printing, editing and other tasks प्रिंटिंग, एडिटिंग और बाकी कामों पर पाबंदी लगाएँ @@ -4233,6 +4274,20 @@ antiAlias Sharp शार्प Shading शेडिंग Shadow Intensity शैडो तीव्रता Shadows and Glows छाया और चमक + Game HUD गेम HUD + Health Fill स्वास्थ्य + Mana Glow माना + Damage Flash क्षति + Quest Outline क्वेस्ट + Coin Shine सिक्का + Boss Alert बॉस + Status Effects स्थिति प्रभाव + Poison Tint ज़हर + Freeze Edge ठंड + Burn Aura जलन + Stun Pulse स्तब्ध + Stealth Fade छिपना + Shield Buff ढाल Shaft: दस्ता: Shape शेप Shape Builder शेप बिल्डर @@ -4825,6 +4880,8 @@ antiAlias Sharp शार्प Tilt झुकाव Time (hhmmss) समय (hhmms) Tint टिंट + Fill Tint फ़िल टिंट + Stroke Tint स्ट्रोक टिंट Tint Reduction टिंट में कमी Tints टिंट्स Tints and Shades टिंट्स और शेड्स @@ -5275,6 +5332,20 @@ antiAlias Sharp शार्प Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना Keep settings of its own अपनी सेटिंग्स रखना Keep a cache of its own, which you can clear अपना कैश रखना, जिसे आप साफ़ कर सकते हैं + Run these commands on the document: {ids} दस्तावेज़ पर ये कमांड चलाना: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” `commandAllowlist` में `{id}` सूचीबद्ध करता है, जिसे कोई प्लग-इन कॉल नहीं कर सकता +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: मैनिफ़ेस्ट की `commandAllowlist` में यह सूचीबद्ध नहीं है +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: आईडी या आर्ग्युमेंट मॉड्यूल की मेमोरी के बाहर हैं +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: आईडी या आर्ग्युमेंट UTF-8 नहीं हैं +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: आर्ग्युमेंट JSON ऑब्जेक्ट नहीं हैं +@msg only a filter with the usual budget declares `commandAllowlist` केवल सामान्य बजट वाला फ़िल्टर `commandAllowlist` घोषित करता है +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) कमांड कॉल (हर रन में {MAX_CALLS} कॉल और {MAX_CALL_BYTES} बाइट) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) कमांड कॉल का आकार (vc_call `{shown}`: {len} बाइट, सीमा {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` में अधिकतम {MAX_ALLOWED_COMMANDS} कमांड +@msg a plug-in may not call this command कोई प्लग-इन इस कमांड को कॉल नहीं कर सकता +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` को `commands` अनुमति चाहिए, और `commands` को `commandAllowlist` चाहिए +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` में `{shown}` है, जो कमांड आईडी नहीं है या दो बार सूचीबद्ध है Clear Plug-in Cache प्लग-इन कैश साफ़ करें @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `{permission}` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें @msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है @@ -5400,7 +5471,8 @@ antiAlias Sharp शार्प @msg {key} must name a registered panel {key} एक पंजीकृत पैनल का नाम होना चाहिए @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} फ़ाइलों में पारदर्शिता नहीं होती और इस फ़ाइल में {what} होगा: पहले इसे फ़्लैट करें (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) {kind} इमेज ओपन नहीं हो सकतीं (document.formats देखें) -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप या समूह आवश्यक है। पहले इमेज और अन्य ऑब्जेक्ट को पाथ में परिवर्तित करें। +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप, समूह, ब्लेंड या सिंबल इंस्टेंस आवश्यक है। पहले अन्य ऑब्जेक्ट को एक्सपैंड करें। +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} इमेज से ठोस नहीं बना सकता: पहले ऑब्जेक्ट › इमेज ट्रेस › बनाएँ से उसे ट्रेस करें। @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए @@ -5998,6 +6070,7 @@ antiAlias Sharp शार्प @msg bad base64 बुरा आधार64 @msg bad color {c} बुरा रंग {c} @msg bad color {v} बुरा रंग {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` null, किसी लेयर रंग का नाम या #rrggbb होता है @msg bad colour बुरा रंग @msg bad colour {v} बुरा रंग {v} @msg bad colour {v} in groupColors groupColors में अमान्य रंग {v} @@ -6134,6 +6207,7 @@ antiAlias Sharp शार्प Stop Script स्क्रिप्ट रोकें Show External Script Warning बाहरी स्क्रिप्ट की चेतावनी दिखाएँ A Script Is Running एक स्क्रिप्ट चल रही है + Script Alert स्क्रिप्ट सूचना A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. स्क्रिप्ट खुले दस्तावेज़ बदल सकती है और अपने चलाए कमांड से फ़ाइलें लिख सकती है। केवल भरोसेमंद स्क्रिप्ट चलाएँ। Do not show this warning again यह चेतावनी फिर न दिखाएँ Stop the script and undo what it did? Then quit again. स्क्रिप्ट रोककर उसके बदलाव पूर्ववत करें? फिर दोबारा बाहर निकलें। @@ -6147,6 +6221,9 @@ antiAlias Sharp शार्प @msg the script is too large to run स्क्रिप्ट चलाने के लिए बहुत बड़ी है @msg a drag or an undo group is open; finish it before running a script कोई ड्रैग या पूर्ववत समूह खुला है; स्क्रिप्ट चलाने से पहले उसे पूरा करें @msg the script left a document that cannot be kept स्क्रिप्ट ने ऐसा दस्तावेज़ छोड़ा है जिसे रखा नहीं जा सकता +@msg the script is no longer in the Scripts folder स्क्रिप्ट अब Scripts फ़ोल्डर में नहीं है + Some scripts were not listed कुछ स्क्रिप्ट सूचीबद्ध नहीं की गईं + The Scripts folder cannot be read Scripts फ़ोल्डर पढ़ा नहीं जा सकता @msg the script's run is no longer available स्क्रिप्ट का रन अब उपलब्ध नहीं है @msg a failed command changed undo steps that could not be put back, so the script was stopped एक विफल कमांड ने पूर्ववत चरण बदल दिए जिन्हें वापस नहीं रखा जा सका, इसलिए स्क्रिप्ट रोक दी गई @msg a recovery copy could not be written, so the script was not run रिकवरी प्रति नहीं लिखी जा सकी, इसलिए स्क्रिप्ट नहीं चलाई गई @@ -6476,6 +6553,11 @@ antiAlias Sharp शार्प @msg invalid parameters for `{cmd}`: {msg} `{cmd}` के लिए अमान्य पैरामीटर: {msg} @msg invalid pattern tile choice अमान्य पैटर्न टाइल ऑप्शन @msg invalid PDF setting: {0} Invalid PDF सेटिंग: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: इसे {unit} के UserUnit के साथ लिखा जाता है, इसलिए UserUnit को अनदेखा करने वाले ऐप इसे उतने गुना छोटा दिखाते हैं +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: {label} पेज बिना UserUnit के, पॉइंट में लिखे जाते हैं, और सीमा मानने वाले ऐप पेज को 200 in पर काट देते हैं +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को अपना आकार बनाए रखने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units आर्टबोर्ड {n} का पेज {size} है, जो PDF की 3 से 14,400 इकाई की पेज भुजाओं के लिए बहुत लंबा और संकरा है: इसे {unit} की UserUnit के साथ लिखा जाता है, जो छोटी भुजा को 3 इकाई या अधिक पर रखती है, और सीमा का पालन करने वाले ऐप लंबी भुजा को 14,400 इकाई पर काट देते हैं +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} फ़ाइलें {max} तक की वास्तविक संख्याएँ रखती हैं, और आर्टबोर्ड {n} का {side} pt लंबा पेज इससे बड़ी संख्याएँ माँगता है: बिना PDF/X मानक के PDF 1.6 या बाद का चुनें, या छोटा आर्टबोर्ड @msg invalid point अमानवीय बिंदु @msg Invalid point अमानवीय बिंदु @msg Invalid real scale अमानवीय वास्तविक पैमाने @@ -6571,6 +6653,7 @@ antiAlias Sharp शार्प @msg matrix must be finite and invertible, depth finite matrix: मैट्रिक्स को परिमित और उलटा, गहराई परिमित होना चाहिए @msg matte: {e} मैट: {e} @msg Measured anchor no longer exists मापने वाला एंकर अब मौजूद नहीं है +@msg Invalid measured point मापा गया बिंदु अमान्य है @msg Measured segment no longer exists मापदंड अब मौजूद नहीं है @msg member is for order front, forward, backward or back member केवल order front, forward, backward या back के लिए है @msg memory budget (the selection is too large to hand over) स्मृति बजट (चुनाव बहुत बड़ा है) @@ -6875,6 +6958,8 @@ antiAlias Sharp शार्प @msg no such layer or object ऐसी कोई लेयर या ऑब्जेक्ट नहीं @msg no such object {0} ऐसी कोई ऑब्जेक्ट नहीं {0} @msg no such object id {value} ऐसी कोई ऑब्जेक्ट नहीं id {value} +@msg id {value} is a ruler guide, not an object id {value} एक गाइड है, ऑब्जेक्ट नहीं +@msg a ruler guide goes only directly in a layer or sublayer गाइड केवल सीधे किसी लेयर या सबलेयर में हो सकता है @msg no such objects ऐसी कोई ऑब्जेक्ट नहीं @msg no such path anchor, or the path would reach past the canvas इस तरह के कोई पाथ एंकर नहीं, या पाथ कैनवास तक पहुंच जाएगा @msg no such perspective grid preset ऐसा कोई पर्सपेक्टिव ग्रिड पूर्व निर्धारित नहीं है @@ -6926,6 +7011,7 @@ antiAlias Sharp शार्प @msg node {_1} is not type नोड {_1} टाइप नहीं है @msg node {_1} is on a locked artboard नोड {_1} एक लॉक्ड आर्टबोर्ड पर है @msg Non-finite dimension गैर-परिष्कृत आयाम +@msg Only linear dimensions are horizontal or vertical केवल रैखिक आयाम क्षैतिज या ऊर्ध्वाधर होते हैं @msg None of these issues are left: check again इन मुद्दों में से कोई भी छोड़ दिया जाता है: फिर से जांच करें @msg not a blend मिश्रण नहीं @msg not a closed path एक बंद पाथ नहीं @@ -7772,6 +7858,7 @@ antiAlias Sharp शार्प @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode अज्ञात नियंत्रण `{name}`: fillStroke, रंग, drawMode या screenMode @msg Unknown dimension mode अज्ञात आयाम मोड @msg Unknown dimension option {k} अज्ञात आयाम ऑप्शन {k} +@msg Unknown dimension orientation अज्ञात आयाम अभिविन्यास @msg Unknown dimension style अज्ञात आयाम स्टाइल @msg unknown effect `{e}` अज्ञात इफ़ेक्ट `{e}` @msg unknown effect `{id}` अज्ञात इफ़ेक्ट `{id}` @@ -7829,6 +7916,20 @@ antiAlias Sharp शार्प @msg unknown plate `{n}` अज्ञात प्लेट `{n}` @msg unknown preference `{k}` अज्ञात वरीयता `{k}` @msg unknown preference `{key}` अज्ञात वरीयता `{key}` +@msg Preferences imported. प्रेफ़रेंसेज़ आयात की गईं। +@msg not a preferences file: {e} प्रेफ़रेंसेज़ फ़ाइल नहीं: {e} +@msg not a preferences file: no JSON object प्रेफ़रेंसेज़ फ़ाइल नहीं: कोई JSON ऑब्जेक्ट नहीं +@msg not a preferences file (format `{format}`) प्रेफ़रेंसेज़ फ़ाइल नहीं (फ़ॉर्मैट `{format}`) +@msg not a preferences file: no `format` प्रेफ़रेंसेज़ फ़ाइल नहीं: `format` नहीं है +@msg not a preferences file: no `version` प्रेफ़रेंसेज़ फ़ाइल नहीं: `version` नहीं है +@msg not a preferences file: no `prefs` प्रेफ़रेंसेज़ फ़ाइल नहीं: `prefs` नहीं है +@msg the file was saved by a newer version of VectorCraft (version {v}) फ़ाइल VectorCraft के नए संस्करण से सहेजी गई थी (संस्करण {v}) +@msg the file holds no preferences फ़ाइल में कोई प्रेफ़रेंस नहीं है +@msg `{key}` names a folder, which preferences files leave out `{key}` एक फ़ोल्डर बताता है, और प्रेफ़रेंसेज़ फ़ाइलों में फ़ोल्डर नहीं होते +@msg the file is larger than 1 MB फ़ाइल 1 MB से बड़ी है +@msg give `path` or `data` `path` या `data` दें +@msg `{key}` must be an object `{key}` एक ऑब्जेक्ट होना चाहिए +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` अकेले चलता है, बैच के चरण के रूप में नहीं @msg unknown preset (one of {_1}) अज्ञात प्रीसेट ({_1} में से एक) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) अज्ञात प्रीसेट `{name}` (उच्च, मध्यम, निम्न या एक बचाया: flattener.presets.list देखें) @msg unknown preset `{name}` (see flattener.presets.list) अज्ञात प्रीसेट `{name}` (देखें flattener.presets.list) @@ -8028,6 +8129,7 @@ file-filter Flattener presets फ़्लैटनर प्रीसेट् file-filter PDF presets PDF प्रीसेट्स file-filter PDF-compatible .ai PDF-संगत .ai file-filter Print presets प्रिंट प्रीसेट्स +file-filter Preferences प्रेफ़रेंसेज़ file-filter SVG Compressed SVG संपीड़ित file-filter Swatch libraries स्वॉच लाइब्रेरीज़ file-filter Templates टेम्पलेट्स @@ -8135,3 +8237,33 @@ theme Appearance अपीयरेंस Ignore All सभी को अनदेखा करें Add to Dictionary शब्दकोश में जोड़ें No Suggestions कोई सुझाव नहीं + Edit Custom Dictionary कस्टम शब्दकोश संपादित करें + Entry: प्रविष्टि: +# Script Console + Script Console स्क्रिप्ट कंसोल + Clear Script Console स्क्रिप्ट कंसोल साफ़ करें + Copy Script Console स्क्रिप्ट कंसोल कॉपी करें + Copy All सब कॉपी करें + Copy Selected चयनित कॉपी करें + Finished पूरा हुआ + Ended with an error त्रुटि के साथ समाप्त + Out of fuel निर्देश सीमा समाप्त + Ended at a host limit होस्ट सीमा पर समाप्त + Ended at a limit सीमा पर समाप्त + Stopped रोका गया + Ended by an internal fault आंतरिक त्रुटि से समाप्त + time unavailable समय उपलब्ध नहीं + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} सबसे धीमे: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} होस्ट कॉल|{n} होस्ट कॉल +@plural {n} earlier line dropped|{n} earlier lines dropped {n} पुरानी पंक्ति हटाई गई|{n} पुरानी पंक्तियाँ हटाई गईं +@plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में + +# Deferred plug-in effects + Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें + Deferred Effects स्थगित इफ़ेक्ट्स +@msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2b70f11db..8c2d2339b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -385,6 +385,7 @@ font-technology Variable Változó B4 B4 B5 B5 Background Color: Háttérszín: + Guide Label: Segédvonalak címkéje: Show Center Mark Középjel megjelenítése Show Cross Hairs Szálkereszt megjelenítése Show Video Safe Areas Biztonságos videoterületek megjelenítése @@ -1572,6 +1573,7 @@ file-filter Swatch libraries Színminta-könyvtárak file-filter Flattener presets Lapítási előbeállítások file-filter PDF presets PDF-előbeállítások file-filter Print presets Nyomtatási előbeállítások +file-filter Preferences Beállítások file-filter Export presets Exportálási előbeállítások file-filter SVG Compressed Tömörített SVG file-filter PDF-compatible .ai PDF-kompatibilis .ai @@ -2067,6 +2069,7 @@ file-filter Templates Sablonok Include Linked Files Csatolt fájlok hozzáadása Include Metadata Metaadatok hozzáadása Include non-printing layers Nem nyomtatandó rétegek hozzáadása + Write pages longer than 200 in at full size in points 200 in-nél hosszabb oldalak írása teljes méretben, pontban Include SVG Code SVG-kód hozzáadása Include Swatches for Tints Tónusok színmintáinak felvétele Include tagged source profiles Címkézett forrásprofilok beágyazása @@ -2377,6 +2380,7 @@ file-filter Templates Sablonok Lock Grid Rács zárolása Lock Guides Segédvonalak zárolása Lock Guides by Default Segédvonalak zárolása alapértelmezetten + Artboard Guides with Cmd/Ctrl + Ruler Drag Rajztábla-segédvonalak Cmd/Ctrl + húzással a vonalzóból Lock Others Többi zárolása Lock Slices Szeletek zárolása Lock Station Point Nézőpont zárolása @@ -2818,6 +2822,8 @@ file-filter Templates Sablonok Open… Megnyitás… Optical Optikai Optical Margin Alignment Optikai margóigazítás + Dynamic Text Dinamikus szöveg +@msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames} Optimize for fast web view Optimalizálás gyors webes megtekintéshez Optimized Optimalizált Options Beállítások @@ -3361,6 +3367,12 @@ mark-style Japanese Japán Reset Painting Painting visszaállítása Reset Panel Panel visszaállítása Reset Preferences Beállítások visszaállítása + Export Preferences… Beállítások exportálása… + Import Preferences… Beállítások importálása… + Save the applied preferences to a JSON file Az alkalmazott beállítások mentése JSON-fájlba + Apply the preferences in a file Export Preferences saved A Beállítások exportálása paranccsal mentett fájl beállításainak alkalmazása + Export Preferences Beállítások exportálása + Import Preferences Beállítások importálása Search Preferences Keresés a beállításokban No settings found Nincs ilyen beállítás Reset Printing and Proofing Printing and Proofing visszaállítása @@ -3397,6 +3409,8 @@ mark-style Japanese Japán Responsive Reszponzív Restore Visszaállít Restore every preference to its default (applied on OK) Minden beállítás visszaállítása az alapértékre (az OK gombbal lép életbe) + Reset all preferences to their defaults? Visszaállítja az összes beállítást az alapértékre? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Az alapértékek akkor lépnek életbe, amikor a Beállításokban az OK gombra kattint. Az ottani Mégse megtartja a jelenlegi beállításokat. Restore Recovered Documents Helyreállított dokumentumok visszaállítása Restored documents open unsaved: save them to keep them. A visszaállított dokumentumok mentetlenül nyílnak meg: mentsd őket, ha meg akarod tartani. Restrict printing, editing and other tasks Nyomtatás, szerkesztés és más műveletek korlátozása @@ -3753,6 +3767,20 @@ mark-style Japanese Japán Shades of Gray Szürkeárnyalatok Shading Árnyékolás Shadows and Glows Árnyékok és ragyogások + Game HUD Játék HUD + Health Fill Életerő + Mana Glow Mana + Damage Flash Sebzés + Quest Outline Küldetés + Coin Shine Érme + Boss Alert Főellenség + Status Effects Állapot hatások + Poison Tint Méreg + Freeze Edge Fagy + Burn Aura Égés + Stun Pulse Kábítás + Stealth Fade Lopakodás + Shield Buff Pajzs Shape Alakzat Shape Builder Alakzatépítő Shape Builder Regions Alakzatépítő területei @@ -4231,6 +4259,8 @@ mark-style Japanese Japán Tilt Dőlés Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban) Tint Tónus + Fill Tint Kitöltés tónusa + Stroke Tint Körvonal tónusa Tints Tónusok Tints and Shades Tónusok és árnyalatok Tips Tippek @@ -4816,6 +4846,7 @@ imageTrace Outline Körvonal @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` az axis értéke "vertical", "horizontal" vagy fokban megadott szög lehet, ez nem az: `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black vagy egy szín, például "#ff8800" @msg bad color {v} hibás szín: {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb a `guideLabel` értéke null, egy rétegszín neve vagy #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio: 0,1 és 10 közötti szám kell @msg the Color Picker is not open a Színválasztó nincs megnyitva @msg the Simplify slider is not open az Egyszerűsítés csúszka nincs megnyitva @@ -4867,6 +4898,7 @@ imageTrace Outline Körvonal Stop Script Parancsfájl leállítása Show External Script Warning Figyelmeztetés külső parancsfájlok előtt A Script Is Running Egy parancsfájl fut + Script Alert Parancsfájl-értesítés A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. A parancsfájl módosíthatja a megnyitott dokumentumokat, és a futtatott parancsokkal fájlokat írhat. Csak megbízható parancsfájlokat futtasson. Do not show this warning again Ne jelenjen meg többé ez a figyelmeztetés Stop the script and undo what it did? Then quit again. Leállítja a parancsfájlt, és visszavonja a módosításait? Ezután lépjen ki újra. @@ -4880,6 +4912,9 @@ imageTrace Outline Körvonal @msg the script is too large to run a parancsfájl túl nagy a futtatáshoz @msg a drag or an undo group is open; finish it before running a script húzás vagy visszavonási csoport van folyamatban; fejezze be a parancsfájl futtatása előtt @msg the script left a document that cannot be kept a parancsfájl olyan dokumentumot hagyott hátra, amely nem tartható meg +@msg the script is no longer in the Scripts folder a parancsfájl már nincs a Scripts mappában + Some scripts were not listed Néhány szkript nem szerepel a listában + The Scripts folder cannot be read A Scripts mappa nem olvasható @msg the script's run is no longer available a parancsfájl futása már nem érhető el @msg a failed command changed undo steps that could not be put back, so the script was stopped egy sikertelen parancs olyan visszavonási lépéseket módosított, amelyeket nem lehetett visszaállítani, ezért a parancsfájl leállt @msg a recovery copy could not be written, so the script was not run a helyreállítási másolatot nem sikerült kiírni, ezért a parancsfájl nem futott le @@ -4970,6 +5005,11 @@ imageTrace Outline Körvonal @msg Invalid panel layout; restored the default dock Érvénytelen panelelrendezés; az alapértelmezett dokk visszaállítva @msg invalid parameters for `{cmd}`: {msg} érvénytelen paraméterek (`{cmd}`): {msg} @msg invalid PDF setting: {0} érvénytelen PDF-beállítás: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: {unit} UserUnit értékkel íródik, így a UserUnit-ot figyelmen kívül hagyó alkalmazások ennyiszer kisebbnek mutatják +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: a(z) {label} oldalak UserUnit nélkül, pontokban íródnak, és a korlátot betartó alkalmazások 200 in-re vágják az oldalt +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 a PDF 1.5-ben nincs UserUnit, amelyre egy 14 400 pt-nál (200 in) hosszabb oldalnak szüksége van a mérete megtartásához: a fájl PDF 1.6-ként íródik +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units a(z) {n}. rajztábla oldala {size}, túl hosszú és keskeny a PDF 3 és 14 400 egység közötti oldalaihoz: {unit} UserUnit értékkel íródik, amely a rövid oldalt legalább 3 egységen tartja, és a korlátot betartó alkalmazások a hosszú oldalt 14 400 egységre vágják +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard a(z) {label} fájlok legfeljebb {max} értékű valós számokat tárolnak, a(z) {n}. rajztábla {side} pt hosszú oldalához pedig nagyobbak kellenek: válassza a PDF 1.6-os vagy újabb verziót PDF/X szabvány nélkül, vagy egy kisebb rajztáblát @msg invalid restored panel group érvénytelen visszaállított panelcsoport @msg is not an image VectorCraft reads nem olyan kép, amelyet a VectorCraft olvas @msg it ends inside a group or compound path egy csoporton vagy összetett útvonalon belül ér véget @@ -5185,6 +5225,8 @@ imageTrace Outline Körvonal @msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell) @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` {index} indexű eleme {value}, nem objektumazonosító (nemnegatív egész szám kell) @msg no such object id {value} nincs ilyen objektumazonosító: {value} +@msg id {value} is a ruler guide, not an object ez az azonosító segédvonalé, nem objektumé: {value} +@msg a ruler guide goes only directly in a layer or sublayer segédvonal csak közvetlenül rétegben vagy alrétegben lehet @msg id {value} is a layer, not an object a(z) {value} azonosító egy réteg, nem objektum @msg id {value} is a layer, not an object; move layers with layer.move a(z) {value} azonosító egy réteg, nem objektum; a rétegeket a layer.move paranccsal helyezheti át @msg object {0} cannot have children ennek az objektumnak nem lehetnek gyermekei: {0} @@ -5479,6 +5521,20 @@ imageTrace Outline Körvonal @msg unknown overprints `{o}` (preserve, simulate or discard) ismeretlen felülnyomás-kezelés: `{o}` (preserve, simulate vagy discard) @msg unknown panel `{raw}` ismeretlen panel: `{raw}` @msg unknown preference `{key}` ismeretlen beállítás: `{key}` +@msg Preferences imported. A beállítások importálva. +@msg not a preferences file: {e} nem beállításfájl: {e} +@msg not a preferences file: no JSON object nem beállításfájl: nincs JSON-objektum +@msg not a preferences file (format `{format}`) nem beállításfájl (formátum: `{format}`) +@msg not a preferences file: no `format` nem beállításfájl: nincs `format` +@msg not a preferences file: no `version` nem beállításfájl: nincs `version` +@msg not a preferences file: no `prefs` nem beállításfájl: nincs `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) a fájlt a VectorCraft újabb verziója mentette (verzió: {v}) +@msg the file holds no preferences a fájl nem tartalmaz beállításokat +@msg `{key}` names a folder, which preferences files leave out a(z) `{key}` mappát ad meg, a beállításfájlok pedig nem tartalmaznak mappákat +@msg the file is larger than 1 MB a fájl nagyobb 1 MB-nál +@msg give `path` or `data` adja meg a `path` vagy a `data` értékét +@msg `{key}` must be an object a(z) `{key}` értékének objektumnak kell lennie +@msg `prefs.import` runs on its own, not as a batch step a `prefs.import` önállóan fut, nem kötegelt lépésként @msg unknown preset (one of {_1}) ismeretlen előbeállítás (ezek egyike lehet: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) ismeretlen előbeállítás: `{name}` (high, medium, low vagy egy mentett; lásd: flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) ismeretlen előbeállítás: `{name}` (lásd: flattener.presets.list) @@ -5592,7 +5648,8 @@ imageTrace Outline Körvonal Bevel both sides Élletörés mindkét oldalon Presets Készletek @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Egy objektumnak csak egy 3D effektusa lehet. Szerkessze a meglévő effektus beállításait, vagy előbb távolítsa el. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} egyelőre görbét, összetett görbét, szöveget vagy csoportot igényel. Előbb alakítsa görbévé a képeket és az egyéb objektumokat. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} egyelőre görbét, összetett görbét, szöveget, csoportot, összemosást vagy szimbólumpéldányt igényel. Előbb bontsa ki az egyéb objektumokat. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} képből nem tud testet létrehozni: előbb vektorizálja az Objektum › Képvektorizálás › Létrehozás paranccsal. @msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot @@ -5650,6 +5707,7 @@ imageTrace Outline Körvonal Preview (1 inch on the page) Előnézet (1 hüvelyk az oldalon) Set as Default Beállítás alapértelmezettként out from a highlighted edge to place its dimension | egy kiemelt élről kifelé a méret elhelyezéséhez | + two points, then move and click to place an aligned, horizontal or vertical dimension | két pontra, majd mozgasd a mutatót, és kattints az igazított, vízszintes vagy függőleges méret elhelyezéséhez | cancels | Double-click the tool for options megszakítja | Dupla kattintás az eszközre a beállításokhoz Angular Szög Away from the Line A vonaltól távol @@ -5679,14 +5737,17 @@ imageTrace Outline Körvonal @msg Invalid drawing scale Érvénytelen rajzlépték @msg Invalid real scale Érvénytelen valós lépték @msg Measured anchor no longer exists A mért horgonypont már nem létezik +@msg Invalid measured point Érvénytelen mért pont @msg Measured segment no longer exists A mért szakasz már nem létezik @msg Non-finite dimension Nem véges méret +@msg Only linear dimensions are horizontal or vertical Csak a lineáris méretek lehetnek vízszintesek vagy függőlegesek @msg Scale Text Attributes: selected type has no scale to bake Szövegattribútumok méretezése: a kijelölt szövegnek nincs alkalmazható méretezése @msg Scale must be drawing:real, for example 1:10 A léptéknek rajz:valós alakúnak kell lennie, például 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 A lépték aránya 1:1000000 és 1000000:1 között lehet @msg Second measured segment no longer exists A második mért szakasz már nem létezik @msg Unknown dimension mode Ismeretlen méretezési mód @msg Unknown dimension option {k} Ismeretlen méretezési beállítás: {k} +@msg Unknown dimension orientation Ismeretlen méretezési tájolás @msg Unknown dimension style Ismeretlen méretezési stílus @msg Unknown unit Ismeretlen mértékegység # Revolve cap description @@ -5946,6 +6007,20 @@ axis Y Axis Y tengely Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat Keep settings of its own Saját beállításokat tárolhat Keep a cache of its own, which you can clear Saját gyorsítótárat tárolhat, amelyet Ön kiüríthet + Run these commands on the document: {ids} Ezeket a parancsokat futtathatja a dokumentumon: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call a(z) „{name}” a(z) `{id}` parancsot sorolja fel a `commandAllowlist` listában, amelyet bővítmény nem hívhat meg +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: a jegyzék `commandAllowlist` listája nem sorolja fel +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: az azonosító vagy az argumentumok a modul memóriáján kívül esnek +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: az azonosító vagy az argumentumok nem UTF-8 kódolásúak +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: az argumentumok nem JSON-objektumot alkotnak +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` listát csak a szokásos keretű szűrő adhat meg +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) parancshívások (futásonként {MAX_CALLS} hívás és {MAX_CALL_BYTES} bájt) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) parancshívás mérete (vc_call `{shown}`: {len} bájt, korlát {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` legfeljebb {MAX_ALLOWED_COMMANDS} parancs a `commandAllowlist` listában +@msg a plug-in may not call this command bővítmény nem hívhatja meg ezt a parancsot +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` a `commandAllowlist` listához a `commands` engedély szükséges, a `commands` engedélyhez pedig `commandAllowlist` lista +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice a `commandAllowlist` a(z) `{shown}` elemet nevezi meg, amely nem parancsazonosító, vagy kétszer szerepel Clear Plug-in Cache Bővítmény gyorsítótárának kiürítése @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a(z) `{permission}` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket @msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature} @@ -6035,6 +6110,23 @@ axis Y Axis Y tengely Button Gomb Panel Panel UI Frames UI keretek + Map Markers Térképjelölők + Pin Gombostű + Flag Zászló + Crosshair Célkereszt + Checkpoint Ellenőrzőpont + Inventory Icons Leltári ikonok + Skill Icons Képességikonok + Attack Támadás + Defend Védelem + Heal Gyógyítás + Buff Erősítés + Ult Ult + Sword Kard + Shield Pajzs + Potion Bájital + Key Kulcs + Gem Drágakő Open Symbol Library Szimbólumkönyvtár megnyitása Save Symbol Library Szimbólumkönyvtár mentése Save Symbol Library… Szimbólumkönyvtár mentése… @@ -6089,6 +6181,16 @@ axis Y Axis Y tengely User Brush Library 9 Felhasználói ecsetkönyvtár 9 User Brush Library 10 Felhasználói ecsetkönyvtár 10 Art Ribbons Grafikus szalagok + Zigzag Cikkcakk + Burst Ray Ütéssugár + Dash Trail Szaggatott nyom + Speed Line Sebességvonal + Game Trails Játéknyomok + Energy Beams Energianyalábok + Laser Bolt Lézervillám + Plasma Arc Plazmaív + Charge Wave Töltéshullám + Spark Trail Szikracsík Calligraphic Nibs Kalligrafikus tollhegyek Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok @@ -6678,3 +6780,33 @@ breakText Glyphs Glifák Ignore All Az összes kihagyása Add to Dictionary Hozzáadás a szótárhoz No Suggestions Nincs javaslat + Edit Custom Dictionary Egyéni szótár szerkesztése + Entry: Bejegyzés: +# Script Console + Script Console Parancsfájlkonzol + Clear Script Console Parancsfájlkonzol törlése + Copy Script Console Parancsfájlkonzol másolása + Copy All Összes másolása + Copy Selected Kijelölés másolása + Finished Befejeződött + Ended with an error Hibával ért véget + Out of fuel Elfogyott az utasításkeret + Ended at a host limit Gazdakorlátnál ért véget + Ended at a limit Korlátnál ért véget + Stopped Leállítva + Ended by an internal fault Belső hiba miatt ért véget + time unavailable az idő nem érhető el + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} leglassabbak: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} gazdahívás +@plural {n} earlier line dropped|{n} earlier lines dropped {n} korábbi sor elvetve +@plural {n} line cut|{n} lines cut {n} sor levágva +@msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt + +# Deferred plug-in effects + Refresh Deferred Effects Elhalasztott effektusok frissítése + Deferred Effects Elhalasztott effektusok +@msg no plug-in effect is deferred nincs elhalasztott bővítményeffektus +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Megnyitva, elhalasztott bővítményeffektusok: {n}. Az Effektus › Elhalasztott effektusok frissítése futtatja őket. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ed0ce6d4e..606ffae97 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -55,6 +55,8 @@ Scripts Skrip Check Script… Periksa skrip… Check Script Periksa skrip + Some scripts were not listed Beberapa skrip tidak dicantumkan + The Scripts folder cannot be read Folder Scripts tidak dapat dibaca Other Script… Skrip lain… Document Setup… Pengaturan dokumen… Switch Units Ganti satuan @@ -365,6 +367,7 @@ Smart Punctuation… Tanda baca cerdas… Create Outlines Buat garis luar Optical Margin Alignment Perataan margin optik + Dynamic Text Teks dinamis Fill with Placeholder Text Isi dengan teks placeholder Insert Inline Symbol Sisipkan simbol sebaris Show Hidden Characters Tampilkan karakter tersembunyi @@ -560,6 +563,7 @@ Show Guides Tampilkan panduan Lock Guides Kunci panduan Lock Guides by Default Kunci panduan secara default + Artboard Guides with Cmd/Ctrl + Ruler Drag Panduan artboard dengan Cmd/Ctrl + seret dari penggaris Unlock Guides Buka kunci panduan Make Guides Buat panduan Release Guides Lepaskan panduan @@ -712,6 +716,20 @@ Other Library… Pustaka lain… Graphic Style Libraries Pustaka gaya grafis Shadows and Glows Bayangan dan pendar + Game HUD HUD Game + Health Fill Nyawa + Mana Glow Mana + Damage Flash Damage + Quest Outline Quest + Coin Shine Koin + Boss Alert Boss + Status Effects Efek Status + Poison Tint Racun + Freeze Edge Beku + Burn Aura Bakar + Stun Pulse Pingsan + Stealth Fade Menghilang + Shield Buff Perisai Outlines and Rules Garis luar dan garis Hand-Drawn Gambar tangan Gradient Finishes Finishing gradien @@ -2081,6 +2099,8 @@ digits Malayalam Malayalam Green Hijau Shifts the tints of global and spot colors; other colors stay. Menggeser tint warna global dan warna spot; warna lainnya tetap. Tint Tint + Fill Tint Tint isian + Stroke Tint Tint stroke Color Guide Options Opsi panduan warna Less Lebih sedikit Steps: Langkah: @@ -2434,6 +2454,7 @@ digits Malayalam Malayalam File Dimensions Dimensi file Transforms Transformasi Background Color: Warna latar belakang: + Guide Label: Label panduan: Show Center Mark Tampilkan tanda tengah Show Cross Hairs Tampilkan garis silang Show Video Safe Areas Tampilkan area aman video @@ -2678,6 +2699,7 @@ antiAlias Sharp Tajam General › Thumbnails Umum › Miniatur Grayscale images Gambar skala abu-abu Include non-printing layers Sertakan lapisan non-cetak + Write pages longer than 200 in at full size in points Tulis halaman yang lebih panjang dari 200 in dalam ukuran penuh, dalam poin Left: Kiri: Mark as trapped Tandai sebagai ter-trap Marks and Bleeds Tanda dan bleed @@ -3480,6 +3502,12 @@ theme Appearance Tampilan Real-time Drawing and Editing Penggambaran dan pengeditan waktu nyata Render Threads (-1 = Automatic) Utas render (-1 = Otomatis) Reset Preferences Atur ulang preferensi + Export Preferences… Ekspor preferensi… + Import Preferences… Impor preferensi… + Save the applied preferences to a JSON file Simpan preferensi yang diterapkan ke file JSON + Apply the preferences in a file Export Preferences saved Terapkan preferensi dari file yang disimpan oleh Ekspor preferensi + Export Preferences Ekspor preferensi + Import Preferences Impor preferensi Search Preferences Cari preferensi No settings found Tidak ada pengaturan yang ditemukan Scale Corners Skalakan sudut @@ -3850,6 +3878,8 @@ font-technology Variable Variabel Preferences Preferensi Previous Sebelumnya Restore every preference to its default (applied on OK) Kembalikan semua preferensi ke defaultnya (diterapkan saat OK) + Reset all preferences to their defaults? Atur ulang semua preferensi ke defaultnya? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Nilai default diterapkan saat Anda mengklik OK di Preferensi. Batal di sana mempertahankan preferensi Anda saat ini. Smaller ↔ Larger Lebih kecil ↔ Lebih besar Smart Guides (View → Smart Guides) Panduan pintar (Tampilan → Panduan pintar) Couldn't save recovery data: {e} Tidak dapat menyimpan data pemulihan: {e} @@ -4078,6 +4108,7 @@ imageTrace Outline Kontur Stop Script Hentikan Skrip Show External Script Warning Tampilkan Peringatan Skrip Eksternal A Script Is Running Skrip Sedang Berjalan + Script Alert Peringatan Skrip A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrip dapat mengubah dokumen yang terbuka dan menulis file melalui perintah yang dijalankannya. Jalankan hanya skrip yang Anda percayai. Do not show this warning again Jangan tampilkan peringatan ini lagi Stop the script and undo what it did? Then quit again. Hentikan skrip dan batalkan yang telah dilakukannya? Lalu keluar lagi. @@ -4595,6 +4626,20 @@ imageTrace Outline Kontur @msg unknown overprints `{o}` (preserve, simulate or discard) overprint `{o}` tidak dikenal (pertahankan, simulasikan, atau buang) @msg unknown panel `{raw}` panel `{raw}` tidak dikenal @msg unknown preference `{key}` preferensi `{key}` tidak dikenal +@msg Preferences imported. Preferensi diimpor. +@msg not a preferences file: {e} bukan file preferensi: {e} +@msg not a preferences file: no JSON object bukan file preferensi: tidak ada objek JSON +@msg not a preferences file (format `{format}`) bukan file preferensi (format `{format}`) +@msg not a preferences file: no `format` bukan file preferensi: tidak ada `format` +@msg not a preferences file: no `version` bukan file preferensi: tidak ada `version` +@msg not a preferences file: no `prefs` bukan file preferensi: tidak ada `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) file disimpan oleh versi VectorCraft yang lebih baru (versi {v}) +@msg the file holds no preferences file tidak berisi preferensi +@msg `{key}` names a folder, which preferences files leave out `{key}` menunjuk folder, dan file preferensi tidak menyertakan folder +@msg the file is larger than 1 MB file lebih besar dari 1 MB +@msg give `path` or `data` berikan `path` atau `data` +@msg `{key}` must be an object `{key}` harus berupa objek +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` dijalankan sendiri, bukan sebagai langkah batch @msg unknown preset (one of {_1}) preset tidak dikenal (salah satu dari {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preset `{name}` tidak dikenal (tinggi, sedang, rendah, atau yang tersimpan: lihat flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) preset `{name}` tidak dikenal (lihat flattener.presets.list) @@ -4759,6 +4804,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif @msg `{key}` entry {index} is {value}, not a non-negative integer object id entri {index} dari `{key}` adalah {value}, bukan id objek (bilangan bulat non-negatif) @msg no such object id {value} tidak ada objek dengan id {value} +@msg id {value} is a ruler guide, not an object id {value} adalah panduan, bukan objek +@msg a ruler guide goes only directly in a layer or sublayer panduan hanya dapat berada langsung di lapisan atau sublapisan @msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} @msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} @msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path} @@ -5091,6 +5138,7 @@ file-filter Swatch libraries Pustaka swatch file-filter Flattener presets Preset perata file-filter PDF presets Preset PDF file-filter Print presets Preset cetak +file-filter Preferences Preferensi file-filter Export presets Preset ekspor file-filter SVG Compressed SVG terkompresi file-filter PDF-compatible .ai .ai kompatibel PDF @@ -5150,7 +5198,8 @@ mark-style Japanese Jepang Bevel both sides Bevel di kedua sisi Presets Preset @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Sebuah objek hanya menerima satu efek 3D. Edit opsi efek yang sudah ada, atau hapus dulu efek tersebut. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} saat ini memerlukan jalur atau jalur majemuk. Konversikan dulu teks, gambar, dan grup menjadi jalur. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} saat ini memerlukan jalur, jalur majemuk, teks, grup, blend, atau instans simbol. Perluas dulu objek lainnya. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} tidak dapat membuat benda padat dari gambar: lacak dulu dengan Objek › Image Trace › Buat. @msg {label} requires a shape with at least two anchors {label} memerlukan bentuk dengan setidaknya dua jangkar @msg The shape is too complex for a 3D effect Bentuknya terlalu kompleks untuk efek 3D @msg The 3D surface is too complex; simplify the shape Permukaan 3D terlalu kompleks; sederhanakan bentuknya @@ -5264,6 +5313,7 @@ mark-style Japanese Jepang Preview (1 inch on the page) Pratinjau (1 inci di halaman) Set as Default Atur sebagai default out from a highlighted edge to place its dimension | keluar dari tepi yang disorot untuk menempatkan dimensinya | + two points, then move and click to place an aligned, horizontal or vertical dimension | dua titik, lalu gerakkan dan klik untuk menempatkan dimensi sejajar, horizontal, atau vertikal | cancels | Double-click the tool for options membatalkan | Klik dua kali alat untuk opsi Angular Sudut Away from the Line Menjauhi garis @@ -5293,14 +5343,17 @@ mark-style Japanese Jepang @msg Invalid drawing scale Skala gambar tidak valid @msg Invalid real scale Skala sebenarnya tidak valid @msg Measured anchor no longer exists Titik jangkar yang diukur sudah tidak ada +@msg Invalid measured point Titik yang diukur tidak valid @msg Measured segment no longer exists Segmen yang diukur sudah tidak ada @msg Non-finite dimension Dimensi bukan bilangan berhingga +@msg Only linear dimensions are horizontal or vertical Hanya dimensi linear yang horizontal atau vertikal @msg Scale Text Attributes: selected type has no scale to bake Skalakan atribut teks: teks yang dipilih tidak memiliki skala untuk diterapkan @msg Scale must be drawing:real, for example 1:10 Skala harus dalam format gambar:sebenarnya, misalnya 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Rasio skala harus antara 1:1000000 dan 1000000:1 @msg Second measured segment no longer exists Segmen yang diukur kedua sudah tidak ada @msg Unknown dimension mode Mode dimensi tidak dikenal @msg Unknown dimension option {k} Opsi dimensi {k} tidak dikenal +@msg Unknown dimension orientation Orientasi dimensi tidak dikenal @msg Unknown dimension style Gaya dimensi tidak dikenal @msg Unknown unit Satuan tidak dikenal Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Menutup ujung melingkar profil terbuka. Sapuan parsial mempertahankan sisi potongannya tetap terbuka. @@ -5810,6 +5863,7 @@ axis Y Axis Sumbu Y Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log Keep settings of its own Menyimpan pengaturannya sendiri Keep a cache of its own, which you can clear Menyimpan cache-nya sendiri, yang dapat Anda kosongkan + Run these commands on the document: {ids} Menjalankan perintah ini pada dokumen: {ids} Clear Plug-in Cache Kosongkan cache plug-in # Actions panel: action sets (#3712). New Set… Set baru… @@ -5859,6 +5913,23 @@ axis Y Axis Sumbu Y Button Tombol Panel Panel UI Frames Bingkai UI + Map Markers Penanda Peta + Pin Pin + Flag Bendera + Crosshair Bidik silang + Checkpoint Pos pemeriksaan + Inventory Icons Ikon Inventaris + Skill Icons Ikon Keahlian + Attack Serang + Defend Bertahan + Heal Pulihkan + Buff Buff + Ult Ult + Sword Pedang + Shield Perisai + Potion Ramuan + Key Kunci + Gem Permata Open Symbol Library Buka pustaka simbol Save Symbol Library Simpan pustaka simbol Save Symbol Library… Simpan pustaka simbol… @@ -5907,6 +5978,16 @@ axis Y Axis Sumbu Y User Brush Library 9 Pustaka kuas pengguna 9 User Brush Library 10 Pustaka kuas pengguna 10 Art Ribbons Pita seni + Zigzag Zigzag + Burst Ray Sinar ledakan + Dash Trail Jejak putus-putus + Speed Line Garis kecepatan + Game Trails Jejak game + Energy Beams Sinar Energi + Laser Bolt Bolt Laser + Plasma Arc Busur Plasma + Charge Wave Gelombang Muatan + Spark Trail Jejak Percikan Calligraphic Nibs Pena kaligrafi Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar @@ -6454,3 +6535,30 @@ breakText Glyphs Glif # Align to Pixel Grid Align to Pixel Grid Sejajarkan ke grid piksel @msg side is left or right, not `{s}` side adalah left atau right, bukan `{s}` + Edit Custom Dictionary Edit kamus kustom + Entry: Entri: +# Script Console + Script Console Konsol Skrip + Clear Script Console Bersihkan Konsol Skrip + Copy Script Console Salin Konsol Skrip + Copy All Salin Semua + Copy Selected Salin yang Dipilih + Finished Selesai + Ended with an error Berakhir dengan galat + Out of fuel Batas instruksi tercapai + Ended at a host limit Berakhir pada batas host + Ended at a limit Berakhir pada batas + Stopped Dihentikan + Ended by an internal fault Berakhir karena galat internal + time unavailable waktu tidak tersedia + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} paling lambat: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} panggilan host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang +@plural {n} line cut|{n} lines cut {n} baris dipotong + +# Deferred plug-in effects + Refresh Deferred Effects Segarkan efek yang ditunda + Deferred Effects Efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 975800f1f..bf4c9e7b5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -289,6 +289,7 @@ font-technology Variable Variabili Average… Media… Axis: Asse: Background Color: Colore di sfondo: + Guide Label: Etichetta delle guide: Show Center Mark Mostra segno centrale Show Cross Hairs Mostra mirino Show Video Safe Areas Mostra aree sicure video @@ -1278,6 +1279,7 @@ file-filter Swatch libraries Librerie campioni file-filter Flattener presets Preset di appiattimento file-filter PDF presets Preset PDF file-filter Print presets Preset di stampa +file-filter Preferences Preferenze file-filter Export presets Predefiniti di esportazione file-filter SVG Compressed SVG compresso file-filter PDF-compatible .ai .ai compatibile con PDF @@ -1684,6 +1686,7 @@ file-filter Templates Modelli Include Linked Files Includi file collegati Include Metadata Includi metadati Include non-printing layers Includi livelli non stampabili + Write pages longer than 200 in at full size in points Scrivi le pagine più lunghe di 200 in a grandezza reale, in punti Include SVG Code Includi codice SVG Include Swatches for Tints Includi campioni per tinte Inflate… Gonfia… @@ -1925,6 +1928,7 @@ file-filter Templates Modelli Lock Grid Blocca griglia Lock Guides Blocca guide Lock Guides by Default Blocca guide per impostazione predefinita + Artboard Guides with Cmd/Ctrl + Ruler Drag Guide della tavola da disegno con Cmd/Ctrl + trascinamento dal righello Lock Others Blocca altri Lock Slices Blocca sezioni Lock Station Point Blocca punto di osservazione @@ -2361,6 +2365,8 @@ file-filter Templates Modelli Open… Apri… Optical Ottica Optical Margin Alignment Allineamento ottico dei margini + Dynamic Text Testo dinamico +@msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames} Optimize for fast web view Ottimizza per visualizzazione web veloce Optimized Ottimizzato Options Opzioni @@ -2852,6 +2858,12 @@ digits Malayalam Malayalam Reset OpenType Features Reimposta funzioni OpenType Reset Panel Reimposta pannello Reset Preferences Reimposta preferenze + Export Preferences… Esporta preferenze… + Import Preferences… Importa preferenze… + Save the applied preferences to a JSON file Salva le preferenze applicate in un file JSON + Apply the preferences in a file Export Preferences saved Applica le preferenze di un file salvato con Esporta preferenze + Export Preferences Esporta preferenze + Import Preferences Importa preferenze Search Preferences Cerca nelle preferenze No settings found Nessuna impostazione trovata Reset Profiles Reimposta profili @@ -2875,6 +2887,8 @@ digits Malayalam Malayalam Responsive Adattivo Restore Ripristina Restore every preference to its default (applied on OK) Ripristina tutte le preferenze ai valori predefiniti (applicato con OK) + Reset all preferences to their defaults? Ripristinare tutte le preferenze ai valori predefiniti? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. I valori predefiniti vengono applicati quando fai clic su OK in Preferenze. Annulla lì mantiene le preferenze attuali. Restore Recovered Documents Ripristina documenti recuperati Restored documents open unsaved: save them to keep them. I documenti ripristinati si aprono senza essere salvati: salvali per conservarli. Restrict printing, editing and other tasks Limita la stampa, la modifica e altre operazioni @@ -3175,6 +3189,20 @@ digits Malayalam Malayalam Settings… Impostazioni… Setup… Configurazione… Shadows and Glows Ombre e bagliori + Game HUD HUD di gioco + Health Fill Salute + Mana Glow Mana + Damage Flash Danno + Quest Outline Missione + Coin Shine Moneta + Boss Alert Boss + Status Effects Effetti di stato + Poison Tint Veleno + Freeze Edge Gelo + Burn Aura Bruciatura + Stun Pulse Stordimento + Stealth Fade Furtività + Shield Buff Scudo Shape Forma Shape Builder Generatore forme Shape Builder Regions Aree del Generatore forme @@ -3594,6 +3622,8 @@ digits Malayalam Malayalam Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia) Gutter: Spazio intermedio: Tint Tinta + Fill Tint Tinta riempimento + Stroke Tint Tinta traccia Tip extends past the end point La punta si estende oltre il punto finale Tip on the end point Punta sul punto finale Title Case Iniziali maiuscole @@ -4114,6 +4144,7 @@ axis Both Entrambi Stop Script Interrompi script Show External Script Warning Mostra avviso per gli script esterni A Script Is Running È in esecuzione uno script + Script Alert Avviso dello script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Uno script può modificare i documenti aperti e scrivere file tramite i comandi che esegue. Esegui solo script di cui ti fidi. Do not show this warning again Non mostrare più questo avviso Stop the script and undo what it did? Then quit again. Interrompere lo script e annullare ciò che ha fatto? Poi esci di nuovo. @@ -4127,6 +4158,9 @@ axis Both Entrambi @msg the script is too large to run lo script è troppo grande per essere eseguito @msg a drag or an undo group is open; finish it before running a script è aperto un trascinamento o un gruppo di annullamento; completalo prima di eseguire uno script @msg the script left a document that cannot be kept lo script ha lasciato un documento che non può essere conservato +@msg the script is no longer in the Scripts folder lo script non è più nella cartella Scripts + Some scripts were not listed Alcuni script non sono stati elencati + The Scripts folder cannot be read Impossibile leggere la cartella Scripts @msg the script's run is no longer available l'esecuzione dello script non è più disponibile @msg a failed command changed undo steps that could not be put back, so the script was stopped un comando non riuscito ha modificato passaggi di annullamento che non è stato possibile ripristinare, quindi lo script è stato interrotto @msg a recovery copy could not be written, so the script was not run non è stato possibile scrivere una copia di recupero, quindi lo script non è stato eseguito @@ -4311,6 +4345,7 @@ axis Both Entrambi @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve essere "vertical", "horizontal" o un angolo in gradi, non `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" sfondo {other}: transparent, white, black o un colore come "#ff8800" @msg bad color {v} colore non valido {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` è null, il nome di un colore di livello o #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio deve essere un numero da 0,1 a 10 @msg the Color Picker is not open il Selettore colore non è aperto @msg the Simplify slider is not open il cursore Semplifica non è aperto @@ -4374,6 +4409,11 @@ axis Both Entrambi @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) errore interno in `{cmd}`: {msg} (il documento è rimasto com'era; segnala questo bug) @msg internal error: {msg} (please report this bug) errore interno: {msg} (segnala questo bug) @msg invalid PDF setting: {0} impostazione PDF non valida: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): viene scritta con una UserUnit di {unit}, quindi le app che ignorano UserUnit la mostrano altrettante volte più piccola +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): le pagine {label} vengono scritte senza UserUnit, in punti, e le app che rispettano il limite tagliano la pagina a 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 non ha UserUnit, che serve a una pagina più lunga di 14.400 pt (200 in) per mantenere le sue dimensioni: il file viene scritto come PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la pagina della tavola da disegno {n} misura {size}, troppo lunga e stretta per i lati di pagina PDF da 3 a 14.400 unità: viene scritta con una UserUnit di {unit}, che tiene il lato corto a 3 unità o più, e le app che rispettano il limite tagliano il lato lungo a 14.400 unità +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard i file {label} accettano numeri reali fino a {max}, e la pagina della tavola da disegno {n}, lunga {side} pt, ne richiede di più grandi: scegli PDF 1.6 o successivo senza standard PDF/X, oppure una tavola da disegno più piccola @msg invalid image data for `{0}` dati immagine non validi per `{0}` @msg invalid parameters for `{cmd}`: {msg} parametri non validi per `{cmd}`: {msg} @msg is not an image VectorCraft reads non è un'immagine che VectorCraft sa leggere @@ -4711,6 +4751,20 @@ axis Both Entrambi @msg unknown overprints `{o}` (preserve, simulate or discard) valore di sovrastampa sconosciuto `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` pannello sconosciuto `{raw}` @msg unknown preference `{key}` preferenza sconosciuta `{key}` +@msg Preferences imported. Preferenze importate. +@msg not a preferences file: {e} non è un file di preferenze: {e} +@msg not a preferences file: no JSON object non è un file di preferenze: nessun oggetto JSON +@msg not a preferences file (format `{format}`) non è un file di preferenze (formato `{format}`) +@msg not a preferences file: no `format` non è un file di preferenze: manca `format` +@msg not a preferences file: no `version` non è un file di preferenze: manca `version` +@msg not a preferences file: no `prefs` non è un file di preferenze: manca `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) il file è stato salvato da una versione più recente di VectorCraft (versione {v}) +@msg the file holds no preferences il file non contiene preferenze +@msg `{key}` names a folder, which preferences files leave out `{key}` indica una cartella, e i file di preferenze non includono cartelle +@msg the file is larger than 1 MB il file supera 1 MB +@msg give `path` or `data` specifica `path` o `data` +@msg `{key}` must be an object `{key}` deve essere un oggetto +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` viene eseguito da solo, non come passo di un batch @msg unknown preset (one of {_1}) preset sconosciuto (uno dei seguenti: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preset sconosciuto `{name}` (high, medium, low o uno salvato: vedi flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) preset sconosciuto `{name}` (vedi flattener.presets.list) @@ -4917,6 +4971,8 @@ axis Both Entrambi @msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la voce {index} di `{key}` è {value}, non un ID oggetto (intero non negativo) @msg no such object id {value} nessun oggetto con ID {value} +@msg id {value} is a ruler guide, not an object l’ID {value} è di una guida, non di un oggetto +@msg a ruler guide goes only directly in a layer or sublayer una guida può stare solo direttamente in un livello o sottolivello @msg id {value} is a layer, not an object l’ID {value} è un livello, non un oggetto @msg id {value} is a layer, not an object; move layers with layer.move l’ID {value} è un livello, non un oggetto; sposta i livelli con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path} @@ -5218,7 +5274,8 @@ lighting Light Luce Bevel both sides Smusso su entrambi i lati Presets Predefiniti @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un oggetto può avere un solo effetto 3D. Modificate le opzioni dell'effetto esistente oppure rimuovetelo prima. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} richiede per ora un tracciato, un tracciato composto, testo o un gruppo. Convertite prima immagini e altri oggetti in tracciati. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} richiede per ora un tracciato, un tracciato composto, testo, un gruppo, una fusione o un'istanza di simbolo. Espandete prima gli altri oggetti. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} non può creare un solido da un'immagine: ricalcatela prima con Oggetto › Ricalco immagine › Crea. @msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma @@ -5277,6 +5334,7 @@ lighting Light Luce Preview (1 inch on the page) Anteprima (1 pollice sulla pagina) Set as Default Imposta come predefinito out from a highlighted edge to place its dimension | verso l'esterno da un bordo evidenziato per posizionarne la quota | + two points, then move and click to place an aligned, horizontal or vertical dimension | su due punti, poi sposta e fai clic per posizionare una quota allineata, orizzontale o verticale | cancels | Double-click the tool for options annulla | Doppio clic sullo strumento per le opzioni Linear Lineare Angular Angolare @@ -5309,14 +5367,17 @@ lighting Light Luce @msg Invalid drawing scale Scala del disegno non valida @msg Invalid real scale Scala reale non valida @msg Measured anchor no longer exists Il punto di ancoraggio misurato non esiste più +@msg Invalid measured point Punto misurato non valido @msg Measured segment no longer exists Il segmento misurato non esiste più @msg Non-finite dimension Quota non finita +@msg Only linear dimensions are horizontal or vertical Solo le quote lineari sono orizzontali o verticali @msg Scale Text Attributes: selected type has no scale to bake Scala attributi di testo: il testo selezionato non ha una scala da incorporare @msg Scale must be drawing:real, for example 1:10 La scala deve essere disegno:reale, ad esempio 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Il rapporto di scala deve essere compreso tra 1:1000000 e 1000000:1 @msg Second measured segment no longer exists Il secondo segmento misurato non esiste più @msg Unknown dimension mode Modalità di quota sconosciuta @msg Unknown dimension option {k} Opzione di quota sconosciuta {k} +@msg Unknown dimension orientation Orientamento della quota sconosciuto @msg Unknown dimension style Stile di quota sconosciuto @msg Unknown unit Unità sconosciuta # Revolve cap description @@ -5580,6 +5641,20 @@ axis Y Axis Asse Y Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro Keep settings of its own Conservare impostazioni proprie Keep a cache of its own, which you can clear Conservare una cache propria, che è possibile svuotare + Run these commands on the document: {ids} Eseguire questi comandi sul documento: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” elenca `{id}` in `commandAllowlist`, che un plug-in non può chiamare +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: il `commandAllowlist` del manifesto non lo elenca +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: l’ID o gli argomenti si trovano fuori dalla memoria del modulo +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: l’ID o gli argomenti non sono UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: gli argomenti non sono un oggetto JSON +@msg only a filter with the usual budget declares `commandAllowlist` solo un filtro con il budget consueto dichiara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) chiamate di comandi ({MAX_CALLS} chiamate e {MAX_CALL_BYTES} byte per esecuzione) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) dimensione di una chiamata di comando (vc_call `{shown}`: {len} byte, limite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` al massimo {MAX_ALLOWED_COMMANDS} comandi in `commandAllowlist` +@msg a plug-in may not call this command un plug-in non può chiamare questo comando +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` richiede l’autorizzazione `commands`, e `commands` richiede un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` elenca `{shown}`, che non è un ID di comando o è elencato due volte Clear Plug-in Cache Svuota cache del plug-in @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `{permission}` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature} @@ -5669,6 +5744,23 @@ axis Y Axis Asse Y Button Pulsante Panel Pannello UI Frames Cornici UI + Map Markers Marcatori mappa + Pin Spillo + Flag Bandiera + Crosshair Mirino + Checkpoint Punto di controllo + Inventory Icons Icone inventario + Skill Icons Icone abilità + Attack Attacco + Defend Difesa + Heal Cura + Buff Potenziamento + Ult Ult + Sword Spada + Shield Scudo + Potion Pozione + Key Chiave + Gem Gemma Open Symbol Library Apri libreria simboli Save Symbol Library Salva libreria simboli Save Symbol Library… Salva libreria simboli… @@ -5723,6 +5815,16 @@ axis Y Axis Asse Y User Brush Library 9 Libreria pennelli utente 9 User Brush Library 10 Libreria pennelli utente 10 Art Ribbons Nastri grafici + Zigzag Zigzag + Burst Ray Raggio d’impatto + Dash Trail Scia tratteggiata + Speed Line Linea di velocità + Game Trails Scia di gioco + Energy Beams Raggi di energia + Laser Bolt Dardo laser + Plasma Arc Arco al plasma + Charge Wave Onda di carica + Spark Trail Scia di scintille Calligraphic Nibs Pennini calligrafici Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione @@ -6315,3 +6417,33 @@ breakText Glyphs Glifi Ignore All Ignora tutto Add to Dictionary Aggiungi al dizionario No Suggestions Nessun suggerimento + Edit Custom Dictionary Modifica dizionario personalizzato + Entry: Voce: +# Script Console + Script Console Console script + Clear Script Console Cancella console script + Copy Script Console Copia console script + Copy All Copia tutto + Copy Selected Copia selezione + Finished Terminato + Ended with an error Terminato con un errore + Out of fuel Limite di istruzioni esaurito + Ended at a host limit Terminato a un limite dell'host + Ended at a limit Terminato a un limite + Stopped Interrotto + Ended by an internal fault Terminato per un errore interno + time unavailable tempo non disponibile + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} più lenti: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} chiamata all'host|{n} chiamate all'host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} riga precedente scartata|{n} righe precedenti scartate +@plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last` + +# Deferred plug-in effects + Refresh Deferred Effects Aggiorna effetti rimandati + Deferred Effects Effetti rimandati +@msg no plug-in effect is deferred nessun effetto plug-in è rimandato +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aperto con {n} effetto plug-in rimandato. Effetto › Aggiorna effetti rimandati lo esegue.|Aperto con {n} effetti plug-in rimandati. Effetto › Aggiorna effetti rimandati li esegue. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e0df1a47..7a12afcad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -434,6 +434,8 @@ digits Malayalam マラヤーラム数字 Smart Punctuation… 欧文用字形の自動置換… Create Outlines アウトラインを作成 Optical Margin Alignment 最適なマージン揃え + Dynamic Text ダイナミックテキスト +@msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames} Fill with Placeholder Text サンプルテキストの割り付け Insert Inline Symbol インラインシンボルを挿入 Show Hidden Characters 制御文字を表示 @@ -628,6 +630,7 @@ digits Malayalam マラヤーラム数字 Hide Guides ガイドを隠す Lock Guides ガイドをロック Lock Guides by Default ガイドを初期設定でロック + Artboard Guides with Cmd/Ctrl + Ruler Drag Cmd/Ctrl + 定規からのドラッグでアートボードガイド Make Guides ガイドを作成 Release Guides ガイドを解除 Clear Guides ガイドを消去 @@ -799,6 +802,20 @@ digits Malayalam マラヤーラム数字 Other Library… その他のライブラリ… Graphic Style Libraries グラフィックスタイルライブラリ Shadows and Glows シャドウと光彩 + Game HUD ゲーム HUD + Health Fill 体力 + Mana Glow マナ + Damage Flash ダメージ + Quest Outline クエスト + Coin Shine コイン + Boss Alert ボス + Status Effects ステータス効果 + Poison Tint 毒 + Freeze Edge 凍結 + Burn Aura 火傷 + Stun Pulse 気絶 + Stealth Fade ステルス + Shield Buff シールド Outlines and Rules アウトラインと罫線 Hand-Drawn 手描き風 Gradient Finishes グラデーション仕上げ @@ -1069,6 +1086,7 @@ antiAlias Sharp シャープ Average 平均 BLANK DOCUMENT PRESETS 空白のドキュメントプリセット Background Color: 背景色: + Guide Label: ガイドのラベル: Show Center Mark 中心マークを表示 Show Cross Hairs 十字線を表示 Show Video Safe Areas ビデオセーフエリアを表示 @@ -1986,6 +2004,7 @@ antiAlias Sharp シャープ Include SVG Code SVG コードを含める Include Swatches for Tints ティント用のスウォッチを含める Include non-printing layers 印刷しないレイヤーを含む + Write pages longer than 200 in at full size in points 200 in より長いページを実寸のポイントで書き出す Ink インキ Insert Character 文字を挿入 Inspect Document ドキュメントを調査 @@ -2836,6 +2855,12 @@ mark-style Japanese 日本式 Reset OpenType Features OpenType 機能をリセット Reset Panel パネルをリセット Reset Preferences 環境設定をリセット + Export Preferences… 環境設定を書き出し… + Import Preferences… 環境設定を読み込み… + Save the applied preferences to a JSON file 適用中の環境設定を JSON ファイルに保存 + Apply the preferences in a file Export Preferences saved 「環境設定を書き出し」で保存したファイルの環境設定を適用 + Export Preferences 環境設定を書き出し + Import Preferences 環境設定を読み込み Search Preferences 環境設定を検索 No settings found 設定が見つかりません Reset Profiles プロファイルをリセット @@ -2852,6 +2877,8 @@ mark-style Japanese 日本式 Restore 復元 Restore Recovered Documents 復帰したドキュメントを復元 Restore every preference to its default (applied on OK) すべての環境設定を初期値に戻す (OK で適用) + Reset all preferences to their defaults? すべての環境設定を初期値に戻しますか? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 初期値は環境設定で OK をクリックすると適用されます。そこでキャンセルすると現在の環境設定が保たれます。 Restored documents open unsaved: save them to keep them. 復元したドキュメントは未保存の状態で開きます。保存して残してください。 Restrict printing, editing and other tasks 印刷、編集などの操作を制限 Reverse Gradient グラデーションを反転 @@ -3632,6 +3659,7 @@ file-filter Swatch libraries スウォッチライブラリ file-filter Flattener presets 分割・統合プリセット file-filter PDF presets PDF プリセット file-filter Print presets プリントプリセット +file-filter Preferences 環境設定 file-filter Export presets 書き出しプリセット file-filter SVG Compressed SVG 圧縮 file-filter PDF-compatible .ai PDF 互換の .ai @@ -3872,6 +3900,8 @@ file-filter Templates テンプレート Text Wrap Options テキストの回り込みオプション Tile Full Pages ページ全体を分割 Tint 濃度 + Fill Tint 濃度 (塗り) + Stroke Tint 濃度 (線) Tip extends past the end point 先端をパスの端点より先に伸ばす Tip on the end point 先端をパスの端点に合わせる Top 上 @@ -4420,6 +4450,20 @@ swatch Bright Violet ビビッドバイオレット @msg `{key}` must be between {min} and {max} (got {x}) `{key}` は {min} から {max} までの値にしてください (指定値: {x}) @msg `{key}` must be one of {_1} `{key}` は {_1} のいずれかにしてください @msg unknown preference `{key}` `{key}` は不明な環境設定の項目です +@msg Preferences imported. 環境設定を読み込みました。 +@msg not a preferences file: {e} 環境設定ファイルではありません: {e} +@msg not a preferences file: no JSON object 環境設定ファイルではありません: JSON オブジェクトがありません +@msg not a preferences file (format `{format}`) 環境設定ファイルではありません (形式 `{format}`) +@msg not a preferences file: no `format` 環境設定ファイルではありません: `format` がありません +@msg not a preferences file: no `version` 環境設定ファイルではありません: `version` がありません +@msg not a preferences file: no `prefs` 環境設定ファイルではありません: `prefs` がありません +@msg the file was saved by a newer version of VectorCraft (version {v}) ファイルは新しいバージョンの VectorCraft で保存されています (バージョン {v}) +@msg the file holds no preferences ファイルに環境設定がありません +@msg `{key}` names a folder, which preferences files leave out `{key}` はフォルダーを指定しますが、環境設定ファイルにはフォルダーを含めません +@msg the file is larger than 1 MB ファイルが 1 MB を超えています +@msg give `path` or `data` `path` または `data` を指定してください +@msg `{key}` must be an object `{key}` はオブジェクトである必要があります +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` は単独で実行します。バッチのステップにはできません @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) データ復帰のコピーを保存する場所がありません: 環境設定 → ファイル管理 → データ復帰 → フォルダー (recoveryFolder) を設定してください @msg Data Recovery can't keep copies in {path}: {e} データ復帰は {path} にコピーを保存できません: {e} @msg Plug-ins can't be loaded from {path}: {e} {path} からプラグインを読み込めません: {e} @@ -4600,6 +4644,11 @@ swatch Bright Violet ビビッドバイオレット @msg artboard {0} does not exist アートボード {0} は存在しません @msg cannot read PDF: {0} PDF を読み込めません: {0} @msg invalid PDF setting: {0} PDF の設定が正しくありません: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: UserUnit {unit} で書き出されるため、UserUnit を無視するアプリではその倍率だけ小さく表示されます +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: {label} のページは UserUnit なしでポイント単位で書き出され、上限を守るアプリではページが 200 in で切り取られます +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 には、14,400 pt (200 in) を超えるページがサイズを保つために必要な UserUnit がありません: ファイルは PDF 1.6 として書き出されます +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units アートボード {n} のページは {size} で、PDF のページ辺 3 から 14,400 単位には長く細すぎます: 短辺を 3 単位以上に保つ UserUnit {unit} で書き出され、上限を守るアプリは長辺を 14,400 単位で切り取ります +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ファイルの実数は {max} までですが、アートボード {n} の {side} pt のページにはそれより大きな値が必要です: PDF/X 規格なしの PDF 1.6 以降を選ぶか、アートボードを小さくしてください @msg page {0} does not exist (the PDF has {1}) ページ {0} は存在しません (この PDF のページ数は {1} です) @msg thumbnail: no page {n} (the file has 1) thumbnail: ページ {n} は存在しません (このファイルのページ数は 1 です) @msg the PDF has no pages PDF にページがありません @@ -4648,6 +4697,7 @@ swatch Bright Violet ビビッドバイオレット @msg {label}… {label}… @msg no blend options ブレンドオプションがありません @msg bad color {v} カラー {v} が正しくありません +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` は null、レイヤーカラーの名前、または #rrggbb です @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio には 0.1 〜 10 の数値を指定してください @msg the Color Picker is not open カラーピッカーが開いていません @msg the Simplify slider is not open 単純化スライダーが開いていません @@ -4824,6 +4874,7 @@ swatch Bright Violet ビビッドバイオレット Stop Script スクリプトを停止 Show External Script Warning 外部スクリプトの警告を表示 A Script Is Running スクリプトの実行中 + Script Alert スクリプトの通知 A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. スクリプトは開いているドキュメントを変更でき、実行するコマンドを通じてファイルを書き込むこともできます。信頼できるスクリプトだけを実行してください。 Do not show this warning again 今後この警告を表示しない Stop the script and undo what it did? Then quit again. スクリプトを停止して変更を取り消しますか?その後、もう一度終了してください。 @@ -4837,6 +4888,9 @@ swatch Bright Violet ビビッドバイオレット @msg the script is too large to run スクリプトが大きすぎて実行できません @msg a drag or an undo group is open; finish it before running a script ドラッグまたは取り消しグループが開いています。スクリプトを実行する前に完了してください @msg the script left a document that cannot be kept スクリプトが保持できないドキュメントを残しました +@msg the script is no longer in the Scripts folder スクリプトは Scripts フォルダーにもうありません + Some scripts were not listed 一部のスクリプトは一覧に表示されていません + The Scripts folder cannot be read Scripts フォルダーを読み込めません @msg the script's run is no longer available スクリプトの実行は利用できなくなりました @msg a failed command changed undo steps that could not be put back, so the script was stopped 失敗したコマンドが元に戻せない取り消し手順を変更したため、スクリプトを停止しました @msg a recovery copy could not be written, so the script was not run 復元用のコピーを書き込めなかったため、スクリプトは実行されませんでした @@ -4958,6 +5012,8 @@ swatch Bright Violet ビビッドバイオレット @msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` の要素 {index} は {value} で、オブジェクト ID (0 以上の整数) ではありません @msg no such object id {value} ID が {value} のオブジェクトは見つかりません +@msg id {value} is a ruler guide, not an object ID {value} はオブジェクトではなくガイドです +@msg a ruler guide goes only directly in a layer or sublayer ガイドはレイヤーまたはサブレイヤーの直下にのみ置けます @msg id {value} is a layer, not an object ID {value} はオブジェクトではなくレイヤーです @msg id {value} is a layer, not an object; move layers with layer.move ID {value} はオブジェクトではなくレイヤーです。レイヤーは layer.move で移動してください @msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path} @@ -5260,7 +5316,8 @@ lighting Light ライト Bevel both sides 両面にベベル Presets プリセット @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 1 つのオブジェクトに適用できる 3D 効果は 1 つです。既存の効果のオプションを編集するか、先に削除してください。 -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} には現在、パス、複合パス、テキスト、またはグループが必要です。画像やその他のオブジェクトは先にパスに変換してください。 +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} には現在、パス、複合パス、テキスト、グループ、ブレンド、またはシンボルインスタンスが必要です。その他のオブジェクトは先に拡張してください。 +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} は画像から立体を作成できません。先に「オブジェクト › 画像トレース › 作成」でトレースしてください。 @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください @@ -5328,6 +5385,7 @@ lighting Light ライト Preview (1 inch on the page) プレビュー(ページ上の 1 インチ) Set as Default デフォルトに設定 out from a highlighted edge to place its dimension | でハイライトされた辺から外側へ寸法を配置 | + two points, then move and click to place an aligned, horizontal or vertical dimension | で 2 点を指定し、移動してクリックすると平行、水平または垂直の寸法を配置 | cancels | Double-click the tool for options でキャンセル | ツールをダブルクリックしてオプション Linear 直線 Angular 角度 @@ -5352,14 +5410,17 @@ lighting Light ライト @msg Invalid drawing scale 図面の縮尺が無効です @msg Invalid real scale 実寸の縮尺が無効です @msg Measured anchor no longer exists 測定したアンカーポイントはもう存在しません +@msg Invalid measured point 測定した点が無効です @msg Measured segment no longer exists 測定したセグメントはもう存在しません @msg Non-finite dimension 寸法が有限ではありません +@msg Only linear dimensions are horizontal or vertical 水平または垂直にできるのは直線寸法のみです @msg Scale Text Attributes: selected type has no scale to bake テキスト属性を拡大・縮小: 選択したテキストに取り込むスケールがありません @msg Scale must be drawing:real, for example 1:10 縮尺は 図面:実寸 の形式にしてください (例: 1:10) @msg Scale ratio must be between 1:1000000 and 1000000:1 縮尺の比率は 1:1000000 から 1000000:1 の間にしてください @msg Second measured segment no longer exists 2 番目の測定セグメントはもう存在しません @msg Unknown dimension mode 寸法のモードが不明です @msg Unknown dimension option {k} 不明な寸法オプション {k} +@msg Unknown dimension orientation 寸法の方向が不明です @msg Unknown dimension style 寸法のスタイルが不明です @msg Unknown unit 単位が不明です # Revolve cap description @@ -5645,6 +5706,20 @@ axis Y Axis Y 軸 Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み Keep settings of its own 独自の設定の保持 Keep a cache of its own, which you can clear 消去できる独自のキャッシュの保持 + Run these commands on the document: {ids} ドキュメントでのこれらのコマンドの実行: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call 「{name}」は `commandAllowlist` にプラグインが呼び出せない `{id}` を挙げています +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: マニフェストの `commandAllowlist` にありません +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID または引数がモジュールのメモリの外にあります +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID または引数が UTF-8 ではありません +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: 引数が JSON オブジェクトではありません +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` を宣言できるのは通常の予算のフィルターだけです +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) コマンド呼び出し (1 回の実行につき {MAX_CALLS} 回、{MAX_CALL_BYTES} バイト) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) コマンド呼び出しのサイズ (vc_call `{shown}`: {len} バイト、上限 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` のコマンドは最大 {MAX_ALLOWED_COMMANDS} 個です +@msg a plug-in may not call this command プラグインはこのコマンドを呼び出せません +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` には権限 `commands` が必要で、`commands` には `commandAllowlist` が必要です +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` の `{shown}` はコマンド ID ではないか、2 回挙げられています Clear Plug-in Cache プラグインのキャッシュを消去 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `{permission}` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください @msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です @@ -5734,6 +5809,23 @@ axis Y Axis Y 軸 Button ボタン Panel パネル UI Frames UIフレーム + Map Markers マップマーカー + Pin ピン + Flag 旗 + Crosshair クロスヘア + Checkpoint チェックポイント + Inventory Icons インベントリアイコン + Skill Icons スキルアイコン + Attack 攻撃 + Defend 防御 + Heal 回復 + Buff バフ + Ult 必殺 + Sword 剣 + Shield 盾 + Potion ポーション + Key 鍵 + Gem 宝石 Open Symbol Library シンボルライブラリを開く Save Symbol Library シンボルライブラリを保存 Save Symbol Library… シンボルライブラリを保存… @@ -5788,6 +5880,16 @@ axis Y Axis Y 軸 User Brush Library 9 ユーザーブラシライブラリ 9 User Brush Library 10 ユーザーブラシライブラリ 10 Art Ribbons アートリボン + Zigzag ジグザグ + Burst Ray バーストレイ + Dash Trail ダッシュトレイル + Speed Line スピードライン + Game Trails ゲームトレイル + Energy Beams エネルギービーム + Laser Bolt レーザーボルト + Plasma Arc プラズマアーク + Charge Wave チャージウェーブ + Spark Trail スパークトレイル Calligraphic Nibs カリグラフィペン先 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ @@ -6380,3 +6482,33 @@ breakText Glyphs 字形 Ignore All すべて無視 Add to Dictionary 辞書に追加 No Suggestions 候補なし + Edit Custom Dictionary カスタム辞書を編集 + Entry: エントリ: +# Script Console + Script Console スクリプトコンソール + Clear Script Console スクリプトコンソールを消去 + Copy Script Console スクリプトコンソールをコピー + Copy All すべてコピー + Copy Selected 選択範囲をコピー + Finished 完了 + Ended with an error エラーで終了 + Out of fuel 命令数の上限に到達 + Ended at a host limit ホストの上限で終了 + Ended at a limit 上限で終了 + Stopped 停止 + Ended by an internal fault 内部エラーで終了 + time unavailable 時間を取得できません + {ms} ms {ms} ms + {time}, {calls} {time}、{calls} + slowest: {commands} 最も遅いコマンド:{commands} + {dropped}, {cut} {dropped}、{cut} +@plural {n} host call|{n} host calls ホスト呼び出し {n} 回 +@plural {n} earlier line dropped|{n} earlier lines dropped 古い行 {n} 行を破棄 +@plural {n} line cut|{n} lines cut {n} 行を切り詰め +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください + +# Deferred plug-in effects + Refresh Deferred Effects 保留中の効果を更新 + Deferred Effects 保留中の効果 +@msg no plug-in effect is deferred 保留中のプラグイン効果はありません +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. プラグイン効果を {n} 件保留して開きました。「効果 › 保留中の効果を更新」で実行します。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0ff9696f7..c58480c42 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -92,6 +92,7 @@ Spelling მართლწერა Auto Spell Check მართლწერის ავტომატური შემოწმება Check Spelling… მართლწერის შემოწმება… + Check Spelling მართლწერის შემოწმება Edit Custom Dictionary… მომხმარებლის ლექსიკონის რედაქტირება… Edit Colors ფერების რედაქტირება Recolor Artwork… ნახატის ხელახლა შეფერადება… @@ -331,6 +332,8 @@ Smart Punctuation… ჭკვიანი პუნქტუაცია… Create Outlines კონტურებად გარდაქმნა Optical Margin Alignment ოპტიკური არშიის სწორება + Dynamic Text დინამიკური ტექსტი +@msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} Fill with Placeholder Text შემავსებელი ტექსტით შევსება Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა Show Hidden Characters დამალული სიმბოლოების ჩვენება @@ -362,6 +365,8 @@ Stroke Pattern კონტურის პატერნი Patterns პატერნები Stroke Weight კონტურის სისქე + Stroke Weight Up კონტურის სისქის გაზრდა + Stroke Weight Down კონტურის სისქის შემცირება Graphic Style გრაფიკული სტილი Symbol Instance სიმბოლოს ეგზემპლარი Link Block Series დაკავშირებული ბლოკების სერია @@ -451,6 +456,13 @@ Feather… კიდეების შერბილება… SVG Filters SVG ფილტრები Apply SVG Filter… SVG ფილტრის გამოყენება… + Apply SVG Filter SVG ფილტრის გამოყენება + SVG Filter SVG ფილტრი + New SVG Filter ახალი SVG ფილტრი + Edit SVG Filter SVG ფილტრის რედაქტირება + Delete SVG Filter SVG ფილტრის წაშლა + Import SVG Filter SVG ფილტრის იმპორტირება + Import SVG Filter… SVG ფილტრის იმპორტირება… Warp გაღუნვა Arc… რკალი… Arc Lower… ქვედა რკალი… @@ -522,6 +534,7 @@ Show Guides დამხმარე ხაზების ჩვენება Lock Guides დამხმარე ხაზების ჩაკეტვა Lock Guides by Default დამხმარე ხაზების ნაგულისხმევად ჩაკეტვა + Artboard Guides with Cmd/Ctrl + Ruler Drag სამუშაო დაფის დამხმარე ხაზები Cmd/Ctrl + სახაზავიდან გადათრევით Unlock Guides დამხმარე ხაზების განბლოკვა Make Guides დამხმარე ხაზების შექმნა Release Guides დამხმარე ხაზების მოხსნა @@ -662,6 +675,20 @@ Other Library… სხვა ბიბლიოთეკა… Graphic Style Libraries გრაფიკული სტილების ბიბლიოთეკები Shadows and Glows ჩრდილები და ნათებები + Game HUD თამაშის HUD + Health Fill სიცოცხლე + Mana Glow მანა + Damage Flash ზიანი + Quest Outline ქვესტი + Coin Shine მონეტა + Boss Alert ბოსი + Status Effects სტატუსის ეფექტები + Poison Tint შხამი + Freeze Edge გაყინვა + Burn Aura დაწვა + Stun Pulse დაბნეულობა + Stealth Fade შენიღბვა + Shield Buff ფარი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული Gradient Finishes გრადიენტული ზედაპირები @@ -1500,6 +1527,13 @@ digits Malayalam მალაიალამური Radial Gradient რადიალური გრადიენტი Reset to White, Black თეთრზე და შავზე დაბრუნება Reverse Gradient გრადიენტის შებრუნება + Gradient Presets გრადიენტის პრესეტები + Apply Gradient Preset გრადიენტის პრესეტის გამოყენება + Save Gradient Preset გრადიენტის პრესეტის შენახვა + Save Gradient Preset… გრადიენტის პრესეტის შენახვა… + Delete Gradient Preset გრადიენტის პრესეტის წაშლა + Show Presets პრესეტების ჩვენება + Hide Presets პრესეტების დამალვა Save to Swatches ფერის ნიმუშებში შენახვა Delete Graphic Style (Alt-click: without asking) გრაფიკული სტილის წაშლა (Alt-დაწკაპუნებით: დაუკითხავად) Delete the graphic style “{name}”? წაიშალოს გრაფიკული სტილი „{name}“? @@ -1538,6 +1572,7 @@ digits Malayalam მალაიალამური Rename Preset პრესეტის სახელის შეცვლა Save Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის შენახვა Delete Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის წაშლა + Cancel Image Trace გამოსახულების ვექტორიზაციის გაუქმება Method: მეთოდი: Mode: რეჟიმი: Noise ხმაური @@ -1987,6 +2022,8 @@ digits Malayalam მალაიალამური Green მწვანე Shifts the tints of global and spot colors; other colors stay. ცვლის გლობალური და სპოტ-ფერების ტონირებებს; სხვა ფერები უცვლელი რჩება. Tint ტონირება + Fill Tint შევსების ტონირება + Stroke Tint კონტურის ტონირება Color Guide Options ფერის გზამკვლევის პარამეტრები Less ნაკლები Steps: ბიჯები: @@ -2335,6 +2372,7 @@ digits Malayalam მალაიალამური File Dimensions ფაილის ზომები Transforms გარდაქმნები Background Color: ფონის ფერი: + Guide Label: დამხმარე ხაზების იარლიყი: Show Center Mark ცენტრის ნიშნის ჩვენება Show Cross Hairs ჯვარედინი ხაზების ჩვენება Show Video Safe Areas ვიდეოს უსაფრთხო არეების ჩვენება @@ -2554,6 +2592,7 @@ antiAlias Sharp მკვეთრი General › Thumbnails ზოგადი › ესკიზები Grayscale images ნაცრისფერი გამოსახულებები Include non-printing layers არაბეჭდვადი შრეების ჩართვა + Write pages longer than 200 in at full size in points 200 in-ზე გრძელი გვერდების სრული ზომით, პუნქტებში ჩაწერა Left: მარცხნივ: Mark as trapped ტრაპინგის მარკირება Marks and Bleeds ნიშნები და ჭრისპირა არეები @@ -2928,6 +2967,7 @@ theme Appearance იერსახე Reshape Segment სეგმენტის ფორმის შეცვლა Split Path ტრაექტორიის გაყოფა Activate Document დოკუმენტის გააქტიურება + Move Document დოკუმენტის გადატანა Delete წაშლა Document JSON დოკუმენტის JSON Duplicate დუბლირება @@ -2943,6 +2983,8 @@ theme Appearance იერსახე Effects ეფექტები Move Effect ეფექტის გადატანა Remove Effect ეფექტის წაშლა + Duplicate Effects ეფექტების დუბლირება + Remove Effects ეფექტების წაშლა Expand Options გაშლის პარამეტრები File Info ფაილის ინფორმაცია Batch პაკეტური დამუშავება @@ -3300,6 +3342,12 @@ theme Appearance იერსახე Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური) Reset Preferences პარამეტრების ჩამოყრა + Export Preferences… პარამეტრების ექსპორტირება… + Import Preferences… პარამეტრების იმპორტირება… + Save the applied preferences to a JSON file გამოყენებული პარამეტრების შენახვა JSON ფაილში + Apply the preferences in a file Export Preferences saved პარამეტრების გამოყენება ფაილიდან, რომელიც „პარამეტრების ექსპორტირებამ“ შეინახა + Export Preferences პარამეტრების ექსპორტირება + Import Preferences პარამეტრების იმპორტირება Search Preferences პარამეტრებში ძიება No settings found პარამეტრები ვერ მოიძებნა Scale Corners კუთხეების მასშტაბირება @@ -3340,6 +3388,7 @@ theme Appearance იერსახე Use Japanese Crop Marks იაპონური ჭრის ნიშნების გამოყენება Use Low Resolution Proxy for Linked EPS დაბალი გარჩევადობის პროქსის გამოყენება დაკავშირებული EPS-ისთვის Use Precise Cursors ზუსტი კურსორების გამოყენება + Caps Lock Cursor State Caps Lock-ის მდგომარეობა (კურსორი) Use Preview Bounds გადახედვის საზღვრების გამოყენება When pasting text ტექსტის ჩასმისას XML ID XML ID @@ -3496,6 +3545,8 @@ axis Both ორივე List Folder საქაღალდის სია Area Type Options არის ტექსტის პარამეტრები Reshape Type Area ტექსტის არის ფორმის შეცვლა + Reshape Type Path ტექსტის ტრაექტორიის ფორმის შეცვლა + Move Type Path Handle ტექსტის ტრაექტორიის სახელურის გადაადგილება Scale მასშტაბი Feet & Inches ფუტები და დუიმები Pixels პიქსელები @@ -3641,6 +3692,8 @@ font-technology Variable ცვლადი Preferences პარამეტრები Previous წინა Restore every preference to its default (applied on OK) ყველა პარამეტრის ნაგულისხმევ მნიშვნელობაზე დაბრუნება (გამოიყენება OK-ზე დაჭერისას) + Reset all preferences to their defaults? დავაბრუნოთ ყველა პარამეტრი ნაგულისხმევ მნიშვნელობებზე? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. ნაგულისხმევი მნიშვნელობები გამოიყენება, როცა პარამეტრებში OK-ს დააჭერთ. იქ გაუქმება ინარჩუნებს თქვენს მიმდინარე პარამეტრებს. Smaller ↔ Larger უფრო პატარა ↔ უფრო დიდი Smart Guides (View → Smart Guides) ჭკვიანი დამხმარე ხაზები (ხედი → ჭკვიანი დამხმარე ხაზები) Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} @@ -3739,6 +3792,7 @@ font-technology Variable ცვლადი Flip არეკვლა Couldn't {} {name}: {e} ვერ მოხერხდა {}: {name}: {e} Delete Character Style სიმბოლოს სტილის წაშლა + Edit Character Style სიმბოლოს სტილის რედაქტირება Delete Layer შრის წაშლა Delete Paragraph Style აბზაცის სტილის წაშლა Duplicate Character Style სიმბოლოს სტილის დუბლირება @@ -4026,6 +4080,7 @@ font-technology Variable ცვლადი @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis უნდა იყოს "vertical", "horizontal" ან კუთხე გრადუსებში და არა `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" ფონი {other}: transparent, white, black ან ფერი, მაგალითად "#ff8800" @msg bad color {v} არასწორი ფერი {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` არის null, ფენის ფერის სახელი ან #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio უნდა იყოს რიცხვი 0,1-დან 10-მდე @msg bad colour `{hex}` (#rrggbb) არასწორი ფერი `{hex}` (#rrggbb) @msg bad page tree გვერდების არასწორი ხე @@ -4080,6 +4135,11 @@ font-technology Variable ცვლადი @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) შიდა შეცდომა `{cmd}`-ში: {msg} (დოკუმენტი წინანდელ მდგომარეობაში დარჩა; გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) @msg internal error: {msg} (please report this bug) შიდა შეცდომა: {msg} (გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) @msg invalid PDF setting: {0} PDF-ის არასწორი პარამეტრი: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): ის იწერება UserUnit {unit}-ით, ამიტომ აპები, რომლებიც UserUnit-ს უგულებელყოფენ, მას ამდენჯერ პატარას აჩვენებენ +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): {label} გვერდები იწერება UserUnit-ის გარეშე, პუნქტებში, და აპები, რომლებიც ლიმიტს იცავენ, გვერდს 200 in-ზე ჭრიან +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს ზომის შესანარჩუნებლად სჭირდება: ფაილი იწერება როგორც PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units სამუშაო დაფა {n}-ის გვერდი არის {size}, ძალიან გრძელი და ვიწრო PDF-ის 3-დან 14 400 ერთეულამდე გვერდის მხარეებისთვის: ის იწერება UserUnit {unit}-ით, რომელიც მოკლე მხარეს 3 ერთეულზე ან მეტზე ინარჩუნებს, ხოლო ზღვრის დამცველი აპები გრძელ მხარეს 14 400 ერთეულამდე ჭრიან +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ფაილები ინახავს ნამდვილ რიცხვებს {max}-მდე, სამუშაო დაფა {n}-ის {side} pt სიგრძის გვერდს კი უფრო დიდი სჭირდება: აირჩიეთ PDF 1.6 ან უფრო ახალი PDF/X სტანდარტის გარეშე, ან უფრო პატარა სამუშაო დაფა @msg invalid image data for `{0}` არასწორი გამოსახულების მონაცემები `{0}`-სთვის @msg invalid parameters for `{cmd}`: {msg} `{cmd}`: არასწორი პარამეტრები — {msg} @msg is not an image VectorCraft reads ეს არ არის გამოსახულება, რომელსაც VectorCraft კითხულობს @@ -4393,6 +4453,20 @@ font-technology Variable ცვლადი @msg unknown overprints `{o}` (preserve, simulate or discard) უცნობი ზედდაბეჭდვა `{o}` (preserve, simulate ან discard) @msg unknown panel `{raw}` უცნობი პანელი `{raw}` @msg unknown preference `{key}` უცნობი პარამეტრი `{key}` +@msg Preferences imported. პარამეტრები იმპორტირებულია. +@msg not a preferences file: {e} ეს არ არის პარამეტრების ფაილი: {e} +@msg not a preferences file: no JSON object ეს არ არის პარამეტრების ფაილი: JSON ობიექტი არ არის +@msg not a preferences file (format `{format}`) ეს არ არის პარამეტრების ფაილი (ფორმატი `{format}`) +@msg not a preferences file: no `format` ეს არ არის პარამეტრების ფაილი: `format` არ არის +@msg not a preferences file: no `version` ეს არ არის პარამეტრების ფაილი: `version` არ არის +@msg not a preferences file: no `prefs` ეს არ არის პარამეტრების ფაილი: `prefs` არ არის +@msg the file was saved by a newer version of VectorCraft (version {v}) ფაილი შენახულია VectorCraft-ის უფრო ახალი ვერსიით (ვერსია {v}) +@msg the file holds no preferences ფაილში პარამეტრები არ არის +@msg `{key}` names a folder, which preferences files leave out `{key}` საქაღალდეს მიუთითებს, პარამეტრების ფაილები კი საქაღალდეებს არ შეიცავს +@msg the file is larger than 1 MB ფაილი 1 MB-ზე დიდია +@msg give `path` or `data` მიუთითეთ `path` ან `data` +@msg `{key}` must be an object `{key}` უნდა იყოს ობიექტი +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` ცალკე სრულდება და არა პაკეტის ნაბიჯად @msg unknown preset (one of {_1}) უცნობი პრესეტი (ერთ-ერთი: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) უცნობი პრესეტი `{name}` (high, medium, low ან შენახული: იხ. flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) უცნობი პრესეტი `{name}` (იხ. flattener.presets.list) @@ -4553,6 +4627,8 @@ font-technology Variable ცვლადი @msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-ის ელემენტი {index} არის {value} და არა ობიექტის id (არაუარყოფითი მთელი რიცხვი) @msg no such object id {value} ობიექტი id-ით {value} არ არსებობს +@msg id {value} is a ruler guide, not an object id {value} დამხმარე ხაზისაა და არა ობიექტის +@msg a ruler guide goes only directly in a layer or sublayer დამხმარე ხაზი შეიძლება იყოს მხოლოდ უშუალოდ შრეში ან ქვეშრეში @msg id {value} is a layer, not an object id {value} შრეა და არა ობიექტი @msg id {value} is a layer, not an object; move layers with layer.move id {value} შრეა და არა ობიექტი; შრეების გადასატანად გამოიყენეთ layer.move @msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path} @@ -4959,7 +5035,8 @@ font-technology Variable ცვლადი Bevel both sides წაკვეთა ორივე მხარეს Presets პრესეტები @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. ობიექტს ერთი 3D ეფექტი შეიძლება ჰქონდეს. შეცვალეთ არსებული ეფექტის პარამეტრები ან ჯერ წაშალეთ ის. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი, ჯგუფი, გადასვლა ან სიმბოლოს ეგზემპლარი. ჯერ გაშალეთ სხვა ობიექტები. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: გამოსახულებიდან მყარი სხეულის აგება შეუძლებელია. ჯერ მოახდინეთ მისი ვექტორიზაცია ბრძანებით ობიექტი › გამოსახულების ვექტორიზაცია › შექმნა. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა @@ -4978,6 +5055,7 @@ file-filter Swatch libraries ფერის ნიმუშების ბი file-filter Flattener presets გაბრტყელების პრესეტები file-filter PDF presets PDF-ის პრესეტები file-filter Print presets ბეჭდვის პრესეტები +file-filter Preferences პარამეტრები file-filter Export presets ექსპორტის პრესეტები file-filter SVG Compressed შეკუმშული SVG file-filter PDF-compatible .ai PDF-თან თავსებადი .ai @@ -5135,9 +5213,11 @@ mark-style Japanese იაპონური imageTrace Outline კონტური Run გაშვება Run Script სკრიპტის გაშვება + List Installed Scripts დაინსტალირებული სკრიპტების სია Stop Script სკრიპტის შეჩერება Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება A Script Is Running სკრიპტი მუშაობს + Script Alert სკრიპტის შეტყობინება A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. სკრიპტს შეუძლია ღია დოკუმენტების შეცვლა და მის მიერ გაშვებული ბრძანებებით ფაილების ჩაწერა. გაუშვით მხოლოდ სანდო სკრიპტები. Do not show this warning again აღარ აჩვენო ეს გაფრთხილება Stop the script and undo what it did? Then quit again. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით. @@ -5151,8 +5231,11 @@ imageTrace Outline კონტური @msg the script is too large to run სკრიპტი გასაშვებად ძალიან დიდია @msg a drag or an undo group is open; finish it before running a script ღიაა გადათრევა ან გაუქმების ჯგუფი; დაასრულეთ სკრიპტის გაშვებამდე @msg the script left a document that cannot be kept სკრიპტმა დატოვა დოკუმენტი, რომლის შენახვა შეუძლებელია +@msg the script is no longer in the Scripts folder სკრიპტი აღარ არის Scripts საქაღალდეში + Some scripts were not listed ზოგიერთი სკრიპტი არ არის ჩამოთვლილი + The Scripts folder cannot be read Scripts საქაღალდის წაკითხვა შეუძლებელია @msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი -@msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ შეცვალა გაუქმების ნაბიჯები, რომელთა აღდგენა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა +@msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ დაბრუნების ნაბიჯები შეცვალა და მათი პირვანდელ მდგომარეობაში მოყვანა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა @msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა @msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში @@ -5274,6 +5357,7 @@ imageTrace Outline კონტური Preview (1 inch on the page) წინასწარი ნახვა (1 დუიმი გვერდზე) Set as Default ნაგულისხმევად დაყენება out from a highlighted edge to place its dimension | — გამოკვეთილი კიდიდან გარეთ, მისი ზომის დასასმელად | + two points, then move and click to place an aligned, horizontal or vertical dimension | — ორ წერტილზე, შემდეგ გადაადგილეთ და დააწკაპუნეთ გასწორებული, ჰორიზონტალური ან ვერტიკალური ზომის დასასმელად | cancels | Double-click the tool for options — გაუქმება | პარამეტრებისთვის ორჯერ დაწკაპეთ ხელსაწყოზე Angular კუთხური Away from the Line ხაზისგან მოშორებით @@ -5303,14 +5387,17 @@ imageTrace Outline კონტური @msg Invalid drawing scale ნახაზის არასწორი მასშტაბი @msg Invalid real scale რეალური ზომის არასწორი მასშტაბი @msg Measured anchor no longer exists გაზომილი საყრდენი წერტილი აღარ არსებობს +@msg Invalid measured point გაზომილი წერტილი არასწორია @msg Measured segment no longer exists გაზომილი სეგმენტი აღარ არსებობს @msg Non-finite dimension ზომა სასრული რიცხვი არ არის +@msg Only linear dimensions are horizontal or vertical მხოლოდ ხაზოვანი ზომებია ჰორიზონტალური ან ვერტიკალური @msg Scale Text Attributes: selected type has no scale to bake ტექსტის ატრიბუტების მასშტაბირება: არჩეულ ტექსტს არ აქვს გადასაყვანი მასშტაბი @msg Scale must be drawing:real, for example 1:10 მასშტაბი უნდა იყოს ნახაზი:რეალური, მაგალითად 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 მასშტაბის შეფარდება 1:1000000-სა და 1000000:1-ს შორის უნდა იყოს @msg Second measured segment no longer exists მეორე გაზომილი სეგმენტი აღარ არსებობს @msg Unknown dimension mode ზომის უცნობი რეჟიმი @msg Unknown dimension option {k} ზომის უცნობი პარამეტრი {k} +@msg Unknown dimension orientation ზომის უცნობი ორიენტაცია @msg Unknown dimension style ზომის უცნობი სტილი @msg Unknown unit უცნობი ერთეული Close the circular ends of open profiles. Partial sweeps keep their cut sides open. ღია პროფილების წრიული ბოლოების დახურვა. ნაწილობრივი ბრუნვისას ჭრის მხარეები ღია რჩება. @@ -5666,7 +5753,7 @@ imageTrace Outline კონტური @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. ბრტყელი ლოგოს რეჟიმი keepColors-ს არ იყენებს: ის ვექტორიზებს logoColors-ში მითითებულ ფერებს ან მის მიერ ნაპოვნ ფერებს. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). არაფერი ვექტორიზდა: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება keepColors-ს ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. არაფერი ვექტორიზდა: ფერის იგნორირება გამოტოვებს keepColors-ში ჩამოთვლილი ფერების ყველა არეს. -@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი კონტური არ მიიღება. +@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი ტრაექტორია არ მიიღება. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება {c}-ს, ამიტომ მისგან არაფერი ვექტორიზდება. @msg `colorGroup` needs `library: "document"` `colorGroup`-ს სჭირდება `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup`-ს სჭირდება `palette: "documentLibrary"` @@ -5720,6 +5807,8 @@ imageTrace Outline კონტური Clear History ისტორიის გასუფთავება New Document from State ახალი დოკუმენტი მდგომარეობიდან Continue in Background ფონურ რეჟიმში გაგრძელება + Cancel Opening გახსნის გაუქმება + Reading the file… ფაილი იკითხება… Artboard {n} of {total} სამუშაო დაფა {n} / {total} File {n} of {total} ფაილი {n} / {total} Auto Leading: ავტომატური სტრიქონთა დაშორება: @@ -5886,6 +5975,20 @@ axis Y Axis Y ღერძი Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა Keep settings of its own საკუთარი პარამეტრების შენახვა Keep a cache of its own, which you can clear საკუთარი ქეშის შენახვა, რომლის გასუფთავებაც შეგიძლიათ + Run these commands on the document: {ids} ამ ბრძანებების გაშვება დოკუმენტზე: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ `commandAllowlist`-ში ასახელებს `{id}`-ს, რომლის გამოძახებაც პლაგინს არ შეუძლია +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: მანიფესტის `commandAllowlist` მას არ ასახელებს +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID ან არგუმენტები მოდულის მეხსიერების გარეთაა +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID ან არგუმენტები UTF-8 არ არის +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: არგუმენტები JSON ობიექტი არ არის +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist`-ის გამოცხადება მხოლოდ ჩვეულებრივი ბიუჯეტის ფილტრს შეუძლია +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) ბრძანებების გამოძახებები ({MAX_CALLS} გამოძახება და {MAX_CALL_BYTES} ბაიტი ერთ გაშვებაზე) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) ბრძანების გამოძახების ზომა (vc_call `{shown}`: {len} ბაიტი, ლიმიტი {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` მაქსიმუმ {MAX_ALLOWED_COMMANDS} ბრძანება `commandAllowlist`-ში +@msg a plug-in may not call this command პლაგინს ამ ბრძანების გამოძახება არ შეუძლია +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist`-ს სჭირდება ნებართვა `commands`, ხოლო `commands`-ს სჭირდება `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` ასახელებს `{shown}`-ს, რომელიც ბრძანების ID არ არის ან ორჯერაა ჩამოთვლილი Clear Plug-in Cache პლაგინის ქეშის გასუფთავება @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `{permission}` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} @@ -5975,6 +6078,23 @@ axis Y Axis Y ღერძი Button ღილაკი Panel პანელი UI Frames UI ჩარჩოები + Map Markers რუკის ნიშნულები + Pin ქინძისთავი + Flag დროშა + Crosshair სამიზნე + Checkpoint საკონტროლო წერტილი + Inventory Icons ინვენტარის ხატულები + Skill Icons უნარების ხატულები + Attack შეტევა + Defend თავდაცვა + Heal განკურნება + Buff გაძლიერება + Ult ულტი + Sword მახვილი + Shield ფარი + Potion წამალი + Key გასაღები + Gem ძვირფასი ქვა Open Symbol Library სიმბოლოების ბიბლიოთეკის გახსნა Save Symbol Library სიმბოლოების ბიბლიოთეკის შენახვა Save Symbol Library… სიმბოლოების ბიბლიოთეკის შენახვა… @@ -6029,6 +6149,16 @@ axis Y Axis Y ღერძი User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9 User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10 Art Ribbons ნახატის ლენტები + Zigzag ზიგზაგი + Burst Ray დარტყმის სხივი + Dash Trail წყვეტილი კვალი + Speed Line სიჩქარის ხაზი + Game Trails თამაშის კვალი + Energy Beams ენერგიის სხივები + Laser Bolt ლაზერული ჭავლი + Plasma Arc პლაზმის რკალი + Charge Wave მუხტის ტალღა + Spark Trail ნაპერწკლის კვალი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები @@ -6239,6 +6369,7 @@ anchor Points: წერტილები: Parametric Shape Tool პარამეტრული ფიგურის ხელსაწყო Parametric Shape პარამეტრული ფიგურა Edit Parametric Shape პარამეტრული ფიგურის რედაქტირება + Configure Shape ფიგურის პარამეტრების შეცვლა Convert to Parametric Shape პარამეტრულ ფიგურად გარდაქმნა Ring რგოლი Heart გული @@ -6519,10 +6650,28 @@ breakText Glyphs გლიფები Malayalam მალაიალამური Odia ორია Urdu ურდუ + Assamese ასამური + Sanskrit სანსკრიტი + German: Swiss გერმანული: შვეიცარია + Catalan კატალანური + Danish დანიური + Swedish შვედური + Finnish ფინური + Norwegian ნორვეგიული + Polish პოლონური + Czech ჩეხური + Russian რუსული + Bulgarian ბულგარული + Serbian: Cyrillic სერბული: კირილიცა + Serbian: Latin სერბული: ლათინიცა + Croatian ხორვატული + Turkish თურქული + Chinese ჩინური VectorCraft {release} and later (version {n}) VectorCraft {release} და უფრო ახალი (ვერსია {n}) # Document Tools add-on: Export All Open Documents (#4920). Export All Open Documents as PDF ყველა ღია დოკუმენტის ექსპორტი PDF-ად Export All Open Documents as SVG ყველა ღია დოკუმენტის ექსპორტი SVG-ად + Export All Open Documents ყველა ღია დოკუმენტის ექსპორტირება @msgplural Exported {n} document|Exported {n} documents ექსპორტირებულია დოკუმენტი ({n}) @msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} ექსპორტირებულია დოკუმენტი ({n}); {title} არ ჩაიწერა: {error} # Document Tools add-on: Batch Trace Folder (#4921). @@ -6536,6 +6685,7 @@ breakText Glyphs გლიფები Static Symbol სტატიკური სიმბოლო Symbol Instance Appearance სიმბოლოს ეგზემპლარის იერსახე Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა + Select Symbol Instance Part სიმბოლოს ეგზემპლარის ნაწილის მონიშვნა @msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ @msg Only the fill, stroke, opacity and blend of a path, compound path or type selected in a symbol instance can change სიმბოლოს ეგზემპლარში მონიშნული მრუდის, შედგენილი მრუდის ან ტექსტის მხოლოდ შევსება, კონტური, გამჭვირვალობა და შერევა შეიძლება შეიცვალოს @@ -6755,3 +6905,38 @@ font-script Japanese იაპონური Ignore All ყველას იგნორირება Add to Dictionary ლექსიკონში დამატება No Suggestions შემოთავაზებები არ არის + Copies per Sheet (as many as fit, in a grid) ასლები ფურცელზე (რამდენიც დაეტევა, ბადედ) + Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება + Add to Custom Dictionary მომხმარებლის ლექსიკონში დამატება + Change in Custom Dictionary მომხმარებლის ლექსიკონში შეცვლა + Delete from Custom Dictionary მომხმარებლის ლექსიკონიდან წაშლა + Spelling Suggestions მართლწერის შეთავაზებები + Entry: ჩანაწერი: +# Script Console + Script Console სკრიპტების კონსოლი + Clear Script Console სკრიპტების კონსოლის გასუფთავება + Copy Script Console სკრიპტების კონსოლის კოპირება + Copy All ყველაფრის კოპირება + Copy Selected მონიშნულის კოპირება + Finished დასრულდა + Ended with an error დასრულდა შეცდომით + Out of fuel ინსტრუქციების ლიმიტი ამოიწურა + Ended at a host limit დასრულდა ჰოსტის ლიმიტზე + Ended at a limit დასრულდა ლიმიტზე + Stopped შეჩერდა + Ended by an internal fault დასრულდა შიდა შეცდომის გამო + time unavailable დრო მიუწვდომელია + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} ყველაზე ნელი: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls ჰოსტის {n} გამოძახება +@plural {n} earlier line dropped|{n} earlier lines dropped {n} ძველი სტრიქონი წაიშალა +@plural {n} line cut|{n} lines cut {n} სტრიქონი შემოკლდა +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ + +# Deferred plug-in effects + Refresh Deferred Effects გადადებული ეფექტების განახლება + Deferred Effects გადადებული ეფექტები +@msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b68b4b308..39efdecf2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -427,6 +427,8 @@ digits Malayalam 말라얄람어 Smart Punctuation… 스마트 문장 부호… Create Outlines 윤곽선 만들기 Optical Margin Alignment 시각적 여백 정렬 + Dynamic Text 동적 텍스트 +@msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames} Fill with Placeholder Text 자리 표시 문자로 채우기 Insert Inline Symbol 인라인 심볼 삽입 Show Hidden Characters 숨겨진 문자 표시 @@ -623,6 +625,7 @@ digits Malayalam 말라얄람어 Hide Guides 안내선 숨기기 Lock Guides 안내선 잠그기 Lock Guides by Default 기본적으로 안내선 잠그기 + Artboard Guides with Cmd/Ctrl + Ruler Drag Cmd/Ctrl + 눈금자 드래그로 아트보드 안내선 Make Guides 안내선 만들기 Release Guides 안내선 해제 Clear Guides 안내선 지우기 @@ -792,6 +795,20 @@ digits Malayalam 말라얄람어 Other Library… 다른 라이브러리… Graphic Style Libraries 그래픽 스타일 라이브러리 Shadows and Glows 그림자와 광선 + Game HUD 게임 HUD + Health Fill 체력 + Mana Glow 마나 + Damage Flash 피해 + Quest Outline 퀘스트 + Coin Shine 코인 + Boss Alert 보스 + Status Effects 상태 효과 + Poison Tint 독 + Freeze Edge 빙결 + Burn Aura 화상 + Stun Pulse 기절 + Stealth Fade 은신 + Shield Buff 방패 Outlines and Rules 윤곽선과 괘선 Hand-Drawn 손그림 Gradient Finishes 그레이디언트 마감 @@ -1059,6 +1076,7 @@ antiAlias Sharp 날카롭게 Average 평균 BLANK DOCUMENT PRESETS 빈 문서 프리셋 Background Color: 배경색: + Guide Label: 안내선 레이블: Show Center Mark 중심 표시 보기 Show Cross Hairs 십자선 보기 Show Video Safe Areas 비디오 안전 영역 보기 @@ -2015,6 +2033,7 @@ antiAlias Sharp 날카롭게 Include SVG Code SVG 코드 포함 Include Swatches for Tints 농도별 견본 포함 Include non-printing layers 인쇄되지 않는 레이어 포함 + Write pages longer than 200 in at full size in points 200 in보다 긴 페이지를 실제 크기의 포인트로 기록 Ink 잉크 Insert Character 문자 삽입 Inspect Document 문서 검사 @@ -2825,6 +2844,12 @@ mark-style Japanese 일본식 Reset OpenType Features OpenType 기능 재설정 Reset Panel 패널 재설정 Reset Preferences 환경 설정 재설정 + Export Preferences… 환경 설정 내보내기… + Import Preferences… 환경 설정 가져오기… + Save the applied preferences to a JSON file 적용된 환경 설정을 JSON 파일로 저장 + Apply the preferences in a file Export Preferences saved 환경 설정 내보내기로 저장한 파일의 환경 설정 적용 + Export Preferences 환경 설정 내보내기 + Import Preferences 환경 설정 가져오기 Search Preferences 환경 설정 검색 No settings found 설정을 찾을 수 없음 Reset Profiles 프로필 재설정 @@ -2841,6 +2866,8 @@ mark-style Japanese 일본식 Restore 복원 Restore Recovered Documents 복구된 문서 복원 Restore every preference to its default (applied on OK) 모든 환경 설정을 기본값으로 복원(확인 시 적용) + Reset all preferences to their defaults? 모든 환경 설정을 기본값으로 재설정하시겠습니까? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 기본값은 환경 설정에서 확인을 클릭하면 적용됩니다. 그곳에서 취소를 누르면 현재 환경 설정이 유지됩니다. Restored documents open unsaved: save them to keep them. 복원된 문서는 저장되지 않은 상태로 열립니다. 보존하려면 저장하세요. Restrict printing, editing and other tasks 인쇄, 편집 등의 작업 제한 Reverse Gradient 그레이디언트 반전 @@ -3620,6 +3647,7 @@ file-filter Swatch libraries 견본 라이브러리 file-filter Flattener presets 병합 프리셋 file-filter PDF presets PDF 프리셋 file-filter Print presets 인쇄 프리셋 +file-filter Preferences 환경 설정 file-filter Export presets 내보내기 사전 설정 file-filter SVG Compressed 압축된 SVG file-filter PDF-compatible .ai PDF 호환 .ai @@ -3860,6 +3888,8 @@ file-filter Templates 템플릿 Text Wrap Options 텍스트 감싸기 옵션 Tile Full Pages 전체 페이지 타일링 Tint 색조 + Fill Tint 칠 색조 + Stroke Tint 선 색조 Tip extends past the end point 끝이 끝점을 지나 연장됨 Tip on the end point 끝이 끝점에 위치 Top 위쪽 @@ -4347,6 +4377,20 @@ theme Appearance 외관 @msg `{key}` must be between {min} and {max} (got {x}) `{key}`은(는) {min}에서 {max} 사이여야 합니다({x} 입력됨) @msg `{key}` must be one of {_1} `{key}`은(는) {_1} 중 하나여야 합니다 @msg unknown preference `{key}` 알 수 없는 환경 설정 `{key}` +@msg Preferences imported. 환경 설정을 가져왔습니다. +@msg not a preferences file: {e} 환경 설정 파일이 아닙니다: {e} +@msg not a preferences file: no JSON object 환경 설정 파일이 아닙니다: JSON 개체가 없습니다 +@msg not a preferences file (format `{format}`) 환경 설정 파일이 아닙니다(형식 `{format}`) +@msg not a preferences file: no `format` 환경 설정 파일이 아닙니다: `format`이 없습니다 +@msg not a preferences file: no `version` 환경 설정 파일이 아닙니다: `version`이 없습니다 +@msg not a preferences file: no `prefs` 환경 설정 파일이 아닙니다: `prefs`가 없습니다 +@msg the file was saved by a newer version of VectorCraft (version {v}) 더 새로운 버전의 VectorCraft에서 저장한 파일입니다(버전 {v}) +@msg the file holds no preferences 파일에 환경 설정이 없습니다 +@msg `{key}` names a folder, which preferences files leave out `{key}`은(는) 폴더를 지정하며, 환경 설정 파일에는 폴더가 포함되지 않습니다 +@msg the file is larger than 1 MB 파일이 1MB보다 큽니다 +@msg give `path` or `data` `path` 또는 `data`를 지정하세요 +@msg `{key}` must be an object `{key}`은(는) 개체여야 합니다 +@msg `prefs.import` runs on its own, not as a batch step `prefs.import`은(는) 일괄 처리 단계가 아니라 단독으로 실행됩니다 @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 데이터 복구에 복사본을 보관할 곳이 없습니다. 환경 설정 → 파일 처리 → 데이터 복구 → 폴더(recoveryFolder)를 지정하세요 @msg Data Recovery can't keep copies in {path}: {e} 데이터 복구가 {path}에 복사본을 보관할 수 없습니다: {e} @msg Plug-ins can't be loaded from {path}: {e} {path}에서 플러그인을 불러올 수 없습니다: {e} @@ -4527,6 +4571,11 @@ theme Appearance 외관 @msg artboard {0} does not exist 아트보드 {0}이(가) 없습니다 @msg cannot read PDF: {0} PDF를 읽을 수 없습니다: {0} @msg invalid PDF setting: {0} 잘못된 PDF 설정: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: UserUnit {unit}(으)로 기록되므로 UserUnit을 무시하는 앱에서는 그 배수만큼 작게 표시됩니다 +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: {label} 페이지는 UserUnit 없이 포인트 단위로 기록되며, 한도를 지키는 앱은 페이지를 200 in으로 자릅니다 +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5에는 14,400 pt (200 in)보다 긴 페이지가 크기를 유지하는 데 필요한 UserUnit이 없습니다: 파일이 PDF 1.6으로 기록됩니다 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 아트보드 {n}의 페이지는 {size}로, PDF 페이지 변 3에서 14,400 단위에 비해 너무 길고 좁습니다: 짧은 변을 3 단위 이상으로 유지하는 UserUnit {unit}로 기록되며, 한도를 지키는 앱은 긴 변을 14,400 단위로 자릅니다 +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 파일은 실수를 최대 {max}까지 담는데 아트보드 {n}의 {side} pt 페이지에는 더 큰 값이 필요합니다: PDF/X 표준 없이 PDF 1.6 이상을 선택하거나 더 작은 아트보드를 사용하세요 @msg page {0} does not exist (the PDF has {1}) 페이지 {0}이(가) 없습니다(PDF의 페이지 수: {1}) @msg thumbnail: no page {n} (the file has 1) thumbnail: 페이지 {n}이(가) 없습니다(파일의 페이지 수: 1) @msg the PDF has no pages PDF에 페이지가 없습니다 @@ -4575,6 +4624,7 @@ theme Appearance 외관 @msg {label}… {label}… @msg no blend options 혼합 옵션이 없습니다 @msg bad color {v} 잘못된 색상 {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel`은 null, 레이어 색상 이름 또는 #rrggbb입니다 @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio은(는) 0.1에서 10 사이의 숫자여야 합니다 @msg the Color Picker is not open 색상 피커가 열려 있지 않습니다 @msg the Simplify slider is not open 단순화 슬라이더가 열려 있지 않습니다 @@ -4751,6 +4801,7 @@ theme Appearance 외관 Stop Script 스크립트 중지 Show External Script Warning 외부 스크립트 경고 표시 A Script Is Running 스크립트 실행 중 + Script Alert 스크립트 알림 A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 스크립트는 열려 있는 문서를 변경하고 실행하는 명령을 통해 파일을 쓸 수 있습니다. 신뢰할 수 있는 스크립트만 실행하세요. Do not show this warning again 이 경고를 다시 표시하지 않음 Stop the script and undo what it did? Then quit again. 스크립트를 중지하고 변경 내용을 취소할까요? 그런 다음 다시 종료하세요. @@ -4764,6 +4815,9 @@ theme Appearance 외관 @msg the script is too large to run 스크립트가 너무 커서 실행할 수 없습니다 @msg a drag or an undo group is open; finish it before running a script 드래그 또는 실행 취소 그룹이 열려 있습니다. 스크립트를 실행하기 전에 완료하세요 @msg the script left a document that cannot be kept 스크립트가 유지할 수 없는 문서를 남겼습니다 +@msg the script is no longer in the Scripts folder 스크립트가 더 이상 Scripts 폴더에 없습니다 + Some scripts were not listed 일부 스크립트가 나열되지 않았습니다 + The Scripts folder cannot be read Scripts 폴더를 읽을 수 없습니다 @msg the script's run is no longer available 스크립트 실행을 더 이상 사용할 수 없습니다 @msg a failed command changed undo steps that could not be put back, so the script was stopped 실패한 명령이 되돌릴 수 없는 실행 취소 단계를 변경하여 스크립트를 중지했습니다 @msg a recovery copy could not be written, so the script was not run 복구 사본을 쓸 수 없어 스크립트를 실행하지 않았습니다 @@ -4885,6 +4939,8 @@ theme Appearance 외관 @msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`의 항목 {index}은(는) {value}이며, 음이 아닌 정수인 개체 id가 아닙니다 @msg no such object id {value} id가 {value}인 개체가 없습니다 +@msg id {value} is a ruler guide, not an object id {value}은(는) 개체가 아니라 안내선입니다 +@msg a ruler guide goes only directly in a layer or sublayer 안내선은 레이어나 하위 레이어 바로 아래에만 둘 수 있습니다 @msg id {value} is a layer, not an object id {value}은(는) 개체가 아닌 레이어입니다 @msg id {value} is a layer, not an object; move layers with layer.move id {value}은(는) 개체가 아닌 레이어입니다. 레이어는 layer.move로 이동하세요 @msg automation filesystem access is not granted: read authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 읽기 권한이 없습니다: {path} @@ -5179,7 +5235,8 @@ lighting Light 광원 Bevel both sides 양면 베벨 Presets 사전 설정 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 오브젝트에는 3D 효과를 하나만 적용할 수 있습니다. 기존 효과의 옵션을 편집하거나 먼저 제거하십시오. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}에는 현재 패스, 컴파운드 패스, 문자 또는 그룹이 필요합니다. 이미지 및 기타 개체를 먼저 패스로 변환하십시오. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}에는 현재 패스, 컴파운드 패스, 문자, 그룹, 블렌드 또는 심볼 인스턴스가 필요합니다. 기타 개체를 먼저 확장하십시오. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}은(는) 이미지로 입체를 만들 수 없습니다. 먼저 개체 › 이미지 추적 › 만들기로 추적하십시오. @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 @@ -5238,6 +5295,7 @@ lighting Light 광원 Preview (1 inch on the page) 미리 보기 (페이지의 1인치) Set as Default 기본값으로 설정 out from a highlighted edge to place its dimension | 강조된 가장자리에서 바깥쪽으로 끌어 치수 배치 | + two points, then move and click to place an aligned, horizontal or vertical dimension | 으로 두 점을 지정한 다음 이동하고 클릭하여 정렬, 가로 또는 세로 치수 배치 | cancels | Double-click the tool for options 취소 | 도구를 두 번 클릭하면 옵션 Linear 선형 Angular 각도 @@ -5270,14 +5328,17 @@ lighting Light 광원 @msg Invalid drawing scale 잘못된 도면 축척 @msg Invalid real scale 잘못된 실제 축척 @msg Measured anchor no longer exists 측정한 고정점이 더 이상 없습니다 +@msg Invalid measured point 측정한 점이 잘못되었습니다 @msg Measured segment no longer exists 측정한 세그먼트가 더 이상 없습니다 @msg Non-finite dimension 유한하지 않은 치수 +@msg Only linear dimensions are horizontal or vertical 선형 치수만 가로 또는 세로로 지정할 수 있습니다 @msg Scale Text Attributes: selected type has no scale to bake 텍스트 속성 크기 조절: 선택한 텍스트에 적용할 크기 조절이 없습니다 @msg Scale must be drawing:real, for example 1:10 축척은 도면:실제 형식이어야 합니다(예: 1:10) @msg Scale ratio must be between 1:1000000 and 1000000:1 축척 비율은 1:1000000에서 1000000:1 사이여야 합니다 @msg Second measured segment no longer exists 두 번째 측정 세그먼트가 더 이상 없습니다 @msg Unknown dimension mode 알 수 없는 치수 모드 @msg Unknown dimension option {k} 알 수 없는 치수 옵션 {k} +@msg Unknown dimension orientation 알 수 없는 치수 방향 @msg Unknown dimension style 알 수 없는 치수 스타일 @msg Unknown unit 알 수 없는 단위 # Revolve cap description @@ -5541,6 +5602,20 @@ axis Y Axis Y축 Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기 Keep settings of its own 자체 설정 보관 Keep a cache of its own, which you can clear 지울 수 있는 자체 캐시 보관 + Run these commands on the document: {ids} 문서에서 다음 명령 실행: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}”의 `commandAllowlist`에 플러그인이 호출할 수 없는 `{id}`가 있습니다 +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: 매니페스트의 `commandAllowlist`에 없습니다 +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID 또는 인수가 모듈의 메모리 밖에 있습니다 +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID 또는 인수가 UTF-8이 아닙니다 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: 인수가 JSON 객체가 아닙니다 +@msg only a filter with the usual budget declares `commandAllowlist` 보통 예산의 필터만 `commandAllowlist`를 선언할 수 있습니다 +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) 명령 호출(실행당 {MAX_CALLS}회, {MAX_CALL_BYTES}바이트) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) 명령 호출 크기(vc_call `{shown}`: {len}바이트, 제한 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist`의 명령은 최대 {MAX_ALLOWED_COMMANDS}개입니다 +@msg a plug-in may not call this command 플러그인은 이 명령을 호출할 수 없습니다 +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist`에는 `commands` 권한이 필요하고, `commands`에는 `commandAllowlist`가 필요합니다 +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist`의 `{shown}`은(는) 명령 ID가 아니거나 두 번 나열되었습니다 Clear Plug-in Cache 플러그인 캐시 지우기 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 @msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다 @@ -5630,6 +5705,23 @@ axis Y Axis Y축 Button 버튼 Panel 패널 UI Frames UI 프레임 + Map Markers 지도 마커 + Pin 핀 + Flag 깃발 + Crosshair 십자선 + Checkpoint 체크포인트 + Inventory Icons 인벤토리 아이콘 + Skill Icons 스킬 아이콘 + Attack 공격 + Defend 방어 + Heal 치유 + Buff 버프 + Ult 궁극기 + Sword 검 + Shield 방패 + Potion 포션 + Key 열쇠 + Gem 보석 Open Symbol Library 심볼 라이브러리 열기 Save Symbol Library 심볼 라이브러리 저장 Save Symbol Library… 심볼 라이브러리 저장… @@ -5684,6 +5776,16 @@ axis Y Axis Y축 User Brush Library 9 사용자 브러시 라이브러리 9 User Brush Library 10 사용자 브러시 라이브러리 10 Art Ribbons 아트 리본 + Zigzag 지그재그 + Burst Ray 버스트 레이 + Dash Trail 대시 트레일 + Speed Line 스피드 라인 + Game Trails 게임 트레일 + Energy Beams 에너지 빔 + Laser Bolt 레이저 볼트 + Plasma Arc 플라즈마 아크 + Charge Wave 차지 웨이브 + Spark Trail 스파크 트레일 Calligraphic Nibs 서예 펜촉 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 @@ -6276,3 +6378,33 @@ breakText Glyphs 글리프 Ignore All 모두 무시 Add to Dictionary 사전에 추가 No Suggestions 제안 없음 + Edit Custom Dictionary 사용자 사전 편집 + Entry: 항목: +# Script Console + Script Console 스크립트 콘솔 + Clear Script Console 스크립트 콘솔 지우기 + Copy Script Console 스크립트 콘솔 복사 + Copy All 모두 복사 + Copy Selected 선택 항목 복사 + Finished 완료됨 + Ended with an error 오류로 종료됨 + Out of fuel 명령 한도에 도달함 + Ended at a host limit 호스트 한도에서 종료됨 + Ended at a limit 한도에서 종료됨 + Stopped 중지됨 + Ended by an internal fault 내부 오류로 종료됨 + time unavailable 시간 정보 없음 + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} 가장 느린 명령: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls 호스트 호출 {n}회 +@plural {n} earlier line dropped|{n} earlier lines dropped 이전 줄 {n}개 삭제됨 +@plural {n} line cut|{n} lines cut 줄 {n}개 잘림 +@msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다 + +# Deferred plug-in effects + Refresh Deferred Effects 보류된 효과 새로 고침 + Deferred Effects 보류된 효과 +@msg no plug-in effect is deferred 보류된 플러그인 효과가 없습니다 +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 플러그인 효과 {n}개를 보류하고 열었습니다. 효과 › 보류된 효과 새로 고침으로 실행합니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 005972780..dd1d22edc 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -55,6 +55,8 @@ Scripts Scripts Check Script… Script controleren… Check Script Script controleren + Some scripts were not listed Sommige scripts zijn niet weergegeven + The Scripts folder cannot be read De map Scripts kan niet worden gelezen Other Script… Ander script… Document Setup… Documentinstellingen… Switch Units Eenheden wisselen @@ -379,6 +381,7 @@ Smart Punctuation… Slimme interpunctie… Create Outlines Omzetten in curven Optical Margin Alignment Optische marge-uitlijning + Dynamic Text Dynamische tekst Fill with Placeholder Text Vullen met plaatshoudertekst Insert Inline Symbol Inline-symbool invoegen Show Hidden Characters Verborgen tekens tonen @@ -574,6 +577,7 @@ Show Guides Hulplijnen tonen Lock Guides Hulplijnen vergrendelen Lock Guides by Default Hulplijnen standaard vergrendelen + Artboard Guides with Cmd/Ctrl + Ruler Drag Tekenbordhulplijnen met Cmd/Ctrl + slepen vanuit liniaal Unlock Guides Hulplijnen ontgrendelen Make Guides Hulplijnen maken Release Guides Hulplijnen vrijgeven @@ -726,6 +730,20 @@ Other Library… Andere bibliotheek… Graphic Style Libraries Bibliotheken met grafische stijlen Shadows and Glows Schaduwen en gloed + Game HUD Game-HUD + Health Fill Leven + Mana Glow Mana + Damage Flash Schade + Quest Outline Quest + Coin Shine Munt + Boss Alert Baas + Status Effects Statuseffecten + Poison Tint Gif + Freeze Edge Bevriezing + Burn Aura Brand + Stun Pulse Verdoving + Stealth Fade Sluipen + Shield Buff Schild Outlines and Rules Omtrekken en lijnen Hand-Drawn Handgetekend Gradient Finishes Verloopafwerkingen @@ -2095,6 +2113,8 @@ digits Malayalam Malayalam Green Groen Shifts the tints of global and spot colors; other colors stay. Verschuift de tinten van globale kleuren en spotkleuren; andere kleuren blijven ongewijzigd. Tint Tint + Fill Tint Vultint + Stroke Tint Contourtint Color Guide Options Opties voor kleurengids Less Minder Steps: Stappen: @@ -2448,6 +2468,7 @@ digits Malayalam Malayalam File Dimensions Bestandsafmetingen Transforms Transformaties Background Color: Achtergrondkleur: + Guide Label: Hulplijnlabel: Show Center Mark Middenmarkering tonen Show Cross Hairs Dradenkruis tonen Show Video Safe Areas Veilige videogebieden tonen @@ -2692,6 +2713,7 @@ antiAlias Sharp Scherp General › Thumbnails Algemeen › Miniaturen Grayscale images Grijswaardenafbeeldingen Include non-printing layers Niet-afdrukbare lagen opnemen + Write pages longer than 200 in at full size in points Pagina's langer dan 200 in op ware grootte in punten schrijven Left: Links: Mark as trapped Markeren als getrapt Marks and Bleeds Steker en afloop @@ -3494,6 +3516,12 @@ theme Appearance Vormgeving Real-time Drawing and Editing Tekenen en bewerken in realtime Render Threads (-1 = Automatic) Renderthreads (-1 = automatisch) Reset Preferences Voorkeuren herstellen + Export Preferences… Voorkeuren exporteren… + Import Preferences… Voorkeuren importeren… + Save the applied preferences to a JSON file De toegepaste voorkeuren in een JSON-bestand opslaan + Apply the preferences in a file Export Preferences saved De voorkeuren toepassen uit een bestand dat Voorkeuren exporteren heeft opgeslagen + Export Preferences Voorkeuren exporteren + Import Preferences Voorkeuren importeren Search Preferences Voorkeuren doorzoeken No settings found Geen instellingen gevonden Scale Corners Hoeken schalen @@ -3864,6 +3892,8 @@ font-technology Variable Variabel Preferences Voorkeuren Previous Vorige Restore every preference to its default (applied on OK) Elke voorkeur terugzetten naar de standaard (toegepast bij OK) + Reset all preferences to their defaults? Alle voorkeuren terugzetten naar de standaardwaarden? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. De standaardwaarden worden toegepast wanneer u in Voorkeuren op OK klikt. Annuleren daar behoudt uw huidige voorkeuren. Smaller ↔ Larger Kleiner ↔ Groter Smart Guides (View → Smart Guides) Slimme hulplijnen (Beeld → Slimme hulplijnen) Couldn't save recovery data: {e} Kon herstelgegevens niet opslaan: {e} @@ -4092,6 +4122,7 @@ imageTrace Outline Contour Stop Script Script stoppen Show External Script Warning Waarschuwing voor externe scripts tonen A Script Is Running Er wordt een script uitgevoerd + Script Alert Scriptmelding A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Een script kan de geopende documenten wijzigen en via de uitgevoerde opdrachten bestanden schrijven. Voer alleen scripts uit die u vertrouwt. Do not show this warning again Deze waarschuwing niet meer tonen Stop the script and undo what it did? Then quit again. Het script stoppen en de wijzigingen ervan ongedaan maken? Sluit daarna opnieuw af. @@ -4607,6 +4638,20 @@ imageTrace Outline Contour @msg unknown overprints `{o}` (preserve, simulate or discard) onbekende overdruk `{o}` (behouden, simuleren of verwerpen) @msg unknown panel `{raw}` onbekend paneel `{raw}` @msg unknown preference `{key}` onbekende voorkeur `{key}` +@msg Preferences imported. Voorkeuren geïmporteerd. +@msg not a preferences file: {e} geen voorkeurenbestand: {e} +@msg not a preferences file: no JSON object geen voorkeurenbestand: geen JSON-object +@msg not a preferences file (format `{format}`) geen voorkeurenbestand (indeling `{format}`) +@msg not a preferences file: no `format` geen voorkeurenbestand: geen `format` +@msg not a preferences file: no `version` geen voorkeurenbestand: geen `version` +@msg not a preferences file: no `prefs` geen voorkeurenbestand: geen `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) het bestand is opgeslagen met een nieuwere versie van VectorCraft (versie {v}) +@msg the file holds no preferences het bestand bevat geen voorkeuren +@msg `{key}` names a folder, which preferences files leave out `{key}` geeft een map aan, en voorkeurenbestanden bevatten geen mappen +@msg the file is larger than 1 MB het bestand is groter dan 1 MB +@msg give `path` or `data` geef `path` of `data` op +@msg `{key}` must be an object `{key}` moet een object zijn +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` wordt los uitgevoerd, niet als stap van een batch @msg unknown preset (one of {_1}) onbekende voorinstelling (één van {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) onbekende voorinstelling `{name}` (hoog, middel, laag of een opgeslagen voorinstelling: zie flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) onbekende voorinstelling `{name}` (zie flattener.presets.list) @@ -4771,6 +4816,8 @@ imageTrace Outline Contour @msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht @msg `{key}` entry {index} is {value}, not a non-negative integer object id item {index} van `{key}` is {value}, geen object-id (een niet-negatief geheel getal) @msg no such object id {value} er is geen object met id {value} +@msg id {value} is a ruler guide, not an object id {value} is van een hulplijn, niet van een object +@msg a ruler guide goes only directly in a layer or sublayer een hulplijn kan alleen direct in een laag of sublaag staan @msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path} @@ -5103,6 +5150,7 @@ file-filter Swatch libraries Stalenbibliotheken file-filter Flattener presets Afvlakvoorinstellingen file-filter PDF presets PDF-voorinstellingen file-filter Print presets Afdrukvoorinstellingen +file-filter Preferences Voorkeuren file-filter Export presets Exportvoorinstellingen file-filter SVG Compressed Gecomprimeerde SVG file-filter PDF-compatible .ai PDF-compatibel .ai @@ -5162,7 +5210,8 @@ mark-style Japanese Japans Bevel both sides Afschuining aan beide zijden Presets Voorinstellingen @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Een object kan maar één 3D-effect hebben. Pas de opties van het bestaande effect aan of verwijder het eerst. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} vereist momenteel een pad of samengesteld pad. Zet tekst, afbeeldingen en groepen eerst om naar paden. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} vereist momenteel een pad, samengesteld pad, tekst, groep, vermenging of symboolinstantie. Breid andere objecten eerst uit. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan geen massief object maken van een afbeelding: traceer deze eerst met Object › Afbeelding traceren › Maken. @msg {label} requires a shape with at least two anchors {label} vereist een vorm met minstens twee ankerpunten @msg The shape is too complex for a 3D effect De vorm is te complex voor een 3D-effect @msg The 3D surface is too complex; simplify the shape Het 3D-oppervlak is te complex; vereenvoudig de vorm @@ -5276,6 +5325,7 @@ mark-style Japanese Japans Preview (1 inch on the page) Voorbeeld (1 inch op de pagina) Set as Default Als standaard instellen out from a highlighted edge to place its dimension | uit een gemarkeerde rand om de bijbehorende maatlijn te plaatsen | + two points, then move and click to place an aligned, horizontal or vertical dimension | op twee punten, verplaats daarna en klik om een uitgelijnde, horizontale of verticale maatlijn te plaatsen | cancels | Double-click the tool for options annuleert | Dubbelklik op het gereedschap voor opties Angular Hoek Away from the Line Van de lijn af @@ -5305,14 +5355,17 @@ mark-style Japanese Japans @msg Invalid drawing scale Ongeldige tekeningsschaal @msg Invalid real scale Ongeldige werkelijke schaal @msg Measured anchor no longer exists Gemeten ankerpunt bestaat niet meer +@msg Invalid measured point Ongeldig gemeten punt @msg Measured segment no longer exists Gemeten segment bestaat niet meer @msg Non-finite dimension Niet-eindige maatlijn +@msg Only linear dimensions are horizontal or vertical Alleen lineaire maatlijnen zijn horizontaal of verticaal @msg Scale Text Attributes: selected type has no scale to bake Tekstkenmerken schalen: de geselecteerde tekst heeft geen schaling om toe te passen @msg Scale must be drawing:real, for example 1:10 Schaal moet tekening:werkelijk zijn, bijvoorbeeld 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Schaalverhouding moet tussen 1:1000000 en 1000000:1 liggen @msg Second measured segment no longer exists Tweede gemeten segment bestaat niet meer @msg Unknown dimension mode Onbekende maatlijnmodus @msg Unknown dimension option {k} Onbekende maatlijnoptie {k} +@msg Unknown dimension orientation Onbekende maatlijnrichting @msg Unknown dimension style Onbekende maatlijnstijl @msg Unknown unit Onbekende eenheid Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Sluit de ronde uiteinden van open profielen. Gedeeltelijke omwentelingen houden hun afgesneden zijden open. @@ -5861,6 +5914,7 @@ axis Y Axis Y-as Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven Keep settings of its own Eigen instellingen bewaren Keep a cache of its own, which you can clear Een eigen cache bewaren, die u kunt legen + Run these commands on the document: {ids} Deze opdrachten op het document uitvoeren: {ids} Clear Plug-in Cache Plug-incache legen # Actions panel: action sets (#3712). New Set… Nieuwe set… @@ -5910,6 +5964,23 @@ axis Y Axis Y-as Button Knop Panel Paneel UI Frames UI-kaders + Map Markers Kaartmarkeringen + Pin Speld + Flag Vlag + Crosshair Vizier + Checkpoint Controlepunt + Inventory Icons Inventarisiconen + Skill Icons Vaardigheidsiconen + Attack Aanval + Defend Verdediging + Heal Genezen + Buff Buff + Ult Ult + Sword Zwaard + Shield Schild + Potion Drank + Key Sleutel + Gem Edelsteen Open Symbol Library Symboolbibliotheek openen Save Symbol Library Symboolbibliotheek opslaan Save Symbol Library… Symboolbibliotheek opslaan… @@ -5958,6 +6029,16 @@ axis Y Axis Y-as User Brush Library 9 Penseelbibliotheek van gebruiker 9 User Brush Library 10 Penseelbibliotheek van gebruiker 10 Art Ribbons Illustratielinten + Zigzag Zigzag + Burst Ray Inslagstraal + Dash Trail Streepspoor + Speed Line Snelheidslijn + Game Trails Spelsporen + Energy Beams Energiestralen + Laser Bolt Laserbout + Plasma Arc Plasmaboog + Charge Wave Laadgolf + Spark Trail Vonkenspoor Calligraphic Nibs Kalligrafische penpunten Pattern Borders Patroonranden Scatter Shapes Strooivormen @@ -6505,3 +6586,30 @@ breakText Glyphs Glyphs # Align to Pixel Grid Align to Pixel Grid Uitlijnen op pixelraster @msg side is left or right, not `{s}` side is left of right, niet `{s}` + Edit Custom Dictionary Aangepast woordenboek bewerken + Entry: Item: +# Script Console + Script Console Scriptconsole + Clear Script Console Scriptconsole wissen + Copy Script Console Scriptconsole kopiëren + Copy All Alles kopiëren + Copy Selected Selectie kopiëren + Finished Voltooid + Ended with an error Beëindigd met een fout + Out of fuel Instructielimiet bereikt + Ended at a host limit Beëindigd bij een hostlimiet + Ended at a limit Beëindigd bij een limiet + Stopped Gestopt + Ended by an internal fault Beëindigd door een interne fout + time unavailable tijd niet beschikbaar + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} traagste: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} host-aanroep|{n} host-aanroepen +@plural {n} earlier line dropped|{n} earlier lines dropped {n} eerdere regel verwijderd|{n} eerdere regels verwijderd +@plural {n} line cut|{n} lines cut {n} regel ingekort|{n} regels ingekort + +# Deferred plug-in effects + Refresh Deferred Effects Uitgestelde effecten vernieuwen + Deferred Effects Uitgestelde effecten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index feb3d8308..dd850a3a4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -365,6 +365,8 @@ Smart Punctuation… Inteligentna interpunkcja… Create Outlines Utwórz kontury Optical Margin Alignment Optyczne wyrównanie marginesów + Dynamic Text Tekst dynamiczny +@msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames} Fill with Placeholder Text Wypełnij tekstem zastępczym Insert Inline Symbol Wstaw symbol w tekście Show Hidden Characters Pokaż ukryte znaki @@ -567,6 +569,7 @@ Show Guides Pokaż linie pomocnicze Lock Guides Zablokuj linie pomocnicze Lock Guides by Default Domyślnie blokuj linie pomocnicze + Artboard Guides with Cmd/Ctrl + Ruler Drag Linie pomocnicze obszaru kompozycji z Cmd/Ctrl + przeciąganiem z linijki Unlock Guides Odblokuj linie pomocnicze Make Guides Utwórz linie pomocnicze Release Guides Uwolnij linie pomocnicze @@ -722,6 +725,20 @@ Other Library… Inna biblioteka… Graphic Style Libraries Biblioteki stylów graficznych Shadows and Glows Cienie i blaski + Game HUD HUD gry + Health Fill Zdrowie + Mana Glow Mana + Damage Flash Obrażenia + Quest Outline Zadanie + Coin Shine Moneta + Boss Alert Boss + Status Effects Efekty statusu + Poison Tint Trucizna + Freeze Edge Mróz + Burn Aura Oparzenie + Stun Pulse Ogłuszenie + Stealth Fade Ukrycie + Shield Buff Tarcza Outlines and Rules Kontury i linie Hand-Drawn Rysowane odręcznie Gradient Finishes Wykończenia gradientowe @@ -2193,6 +2210,8 @@ digits Malayalam Malajalam Green Zielony Shifts the tints of global and spot colors; other colors stay. Przesuwa tinty kolorów globalnych i dodatkowych; pozostałe kolory bez zmian. Tint Tinta + Fill Tint Tinta wypełnienia + Stroke Tint Tinta obrysu Color Guide Options Opcje przewodnika kolorów Less Mniej Steps: Kroki: @@ -2559,6 +2578,7 @@ digits Malayalam Malajalam File Dimensions Wymiary pliku Transforms Przekształcenia Background Color: Kolor tła: + Guide Label: Etykieta linii pomocniczych: Show Center Mark Pokaż znacznik środka Show Cross Hairs Pokaż krzyż nitkowy Show Video Safe Areas Pokaż bezpieczne obszary wideo @@ -2780,6 +2800,7 @@ antiAlias Sharp Ostre General › Thumbnails Ogólne › Miniatury Grayscale images Obrazy w skali szarości Include non-printing layers Uwzględnij warstwy niedrukowane + Write pages longer than 200 in at full size in points Zapisuj strony dłuższe niż 200 in w pełnym rozmiarze, w punktach Left: Lewy: Mark as trapped Oznacz jako z zalewkami Marks and Bleeds Znaczniki i spady @@ -3620,6 +3641,12 @@ theme Appearance Wygląd Real-time Drawing and Editing Rysowanie i edycja w czasie rzeczywistym Render Threads (-1 = Automatic) Wątki renderowania (-1 = automatycznie) Reset Preferences Resetuj preferencje + Export Preferences… Eksportuj preferencje… + Import Preferences… Importuj preferencje… + Save the applied preferences to a JSON file Zapisz zastosowane preferencje w pliku JSON + Apply the preferences in a file Export Preferences saved Zastosuj preferencje z pliku zapisanego poleceniem Eksportuj preferencje + Export Preferences Eksportuj preferencje + Import Preferences Importuj preferencje Search Preferences Szukaj w preferencjach No settings found Nie znaleziono ustawień Scale Corners Skaluj narożniki @@ -3976,6 +4003,8 @@ font-technology Variable Zmienne Preferences Preferencje Previous Wstecz Restore every preference to its default (applied on OK) Przywróć domyślne wartości wszystkich preferencji (zastosowane po OK) + Reset all preferences to their defaults? Przywrócić domyślne wartości wszystkich preferencji? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Wartości domyślne zostaną zastosowane po kliknięciu OK w Preferencjach. Anuluj w tym oknie zachowuje bieżące preferencje. Smaller ↔ Larger Mniejszy ↔ większy Smart Guides (View → Smart Guides) Inteligentne linie pomocnicze (Widok → Inteligentne linie pomocnicze) Couldn't save recovery data: {e} Nie udało się zapisać danych odzyskiwania: {e} @@ -4205,6 +4234,7 @@ imageTrace Outline Kontur Stop Script Zatrzymaj skrypt Show External Script Warning Pokazuj ostrzeżenie o skryptach zewnętrznych A Script Is Running Skrypt jest uruchomiony + Script Alert Komunikat skryptu A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrypt może zmieniać otwarte dokumenty i zapisywać pliki za pomocą wykonywanych poleceń. Uruchamiaj tylko skrypty, którym ufasz. Do not show this warning again Nie pokazuj więcej tego ostrzeżenia Stop the script and undo what it did? Then quit again. Zatrzymać skrypt i cofnąć jego zmiany? Następnie zakończ program ponownie. @@ -4218,6 +4248,9 @@ imageTrace Outline Kontur @msg the script is too large to run skrypt jest zbyt duży, aby go uruchomić @msg a drag or an undo group is open; finish it before running a script otwarte jest przeciąganie lub grupa cofania; zakończ je przed uruchomieniem skryptu @msg the script left a document that cannot be kept skrypt pozostawił dokument, którego nie można zachować +@msg the script is no longer in the Scripts folder skryptu nie ma już w folderze Scripts + Some scripts were not listed Niektóre skrypty nie zostały wyświetlone + The Scripts folder cannot be read Nie można odczytać folderu Scripts @msg the script's run is no longer available uruchomienie skryptu nie jest już dostępne @msg a failed command changed undo steps that could not be put back, so the script was stopped nieudane polecenie zmieniło kroki cofania, których nie udało się przywrócić, więc skrypt został zatrzymany @msg a recovery copy could not be written, so the script was not run nie udało się zapisać kopii odzyskiwania, więc skrypt nie został uruchomiony @@ -4401,6 +4434,7 @@ imageTrace Outline Kontur @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis musi mieć wartość "vertical", "horizontal" lub kąt w stopniach, nie `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tło {other}: transparent, white, black lub kolor, np. "#ff8800" @msg bad color {v} nieprawidłowy kolor {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` to null, nazwa koloru warstwy lub #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musi być liczbą od 0,1 do 10 @msg the Color Picker is not open Próbnik kolorów nie jest otwarty @msg the Simplify slider is not open Suwak Uprość nie jest otwarty @@ -4464,6 +4498,11 @@ imageTrace Outline Kontur @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) błąd wewnętrzny w `{cmd}`: {msg} (dokument pozostał bez zmian; zgłoś ten błąd) @msg internal error: {msg} (please report this bug) błąd wewnętrzny: {msg} (zgłoś ten błąd) @msg invalid PDF setting: {0} nieprawidłowe ustawienie PDF: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): jest zapisywana z UserUnit {unit}, więc aplikacje ignorujące UserUnit pokazują ją tyle razy mniejszą +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): strony {label} są zapisywane bez UserUnit, w punktach, a aplikacje przestrzegające limitu przycinają stronę do 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nie ma UserUnit, którego strona dłuższa niż 14 400 pt (200 in) potrzebuje, aby zachować rozmiar: plik jest zapisywany jako PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units strona obszaru kompozycji {n} ma {size} i jest za długa i za wąska dla boków strony PDF od 3 do 14 400 jednostek: jest zapisywana z UserUnit {unit}, która utrzymuje krótszy bok na co najmniej 3 jednostkach, a aplikacje przestrzegające limitu przycinają dłuższy bok do 14 400 jednostek +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard pliki {label} mieszczą liczby rzeczywiste do {max}, a strona obszaru kompozycji {n} o długości {side} pt wymaga większych: wybierz PDF 1.6 lub nowszy bez standardu PDF/X albo mniejszy obszar kompozycji @msg invalid image data for `{0}` nieprawidłowe dane obrazu dla `{0}` @msg invalid parameters for `{cmd}`: {msg} nieprawidłowe parametry dla `{cmd}`: {msg} @msg is not an image VectorCraft reads nie jest obrazem obsługiwanym przez VectorCraft @@ -4803,6 +4842,20 @@ imageTrace Outline Kontur @msg unknown overprints `{o}` (preserve, simulate or discard) nieznana wartość nadruków `{o}` (preserve, simulate lub discard) @msg unknown panel `{raw}` nieznany panel `{raw}` @msg unknown preference `{key}` nieznana preferencja `{key}` +@msg Preferences imported. Zaimportowano preferencje. +@msg not a preferences file: {e} to nie jest plik preferencji: {e} +@msg not a preferences file: no JSON object to nie jest plik preferencji: brak obiektu JSON +@msg not a preferences file (format `{format}`) to nie jest plik preferencji (format `{format}`) +@msg not a preferences file: no `format` to nie jest plik preferencji: brak `format` +@msg not a preferences file: no `version` to nie jest plik preferencji: brak `version` +@msg not a preferences file: no `prefs` to nie jest plik preferencji: brak `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) plik zapisano w nowszej wersji programu VectorCraft (wersja {v}) +@msg the file holds no preferences plik nie zawiera preferencji +@msg `{key}` names a folder, which preferences files leave out `{key}` wskazuje folder, a pliki preferencji nie zawierają folderów +@msg the file is larger than 1 MB plik jest większy niż 1 MB +@msg give `path` or `data` podaj `path` lub `data` +@msg `{key}` must be an object `{key}` musi być obiektem +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` działa samodzielnie, nie jako krok wsadu @msg unknown preset (one of {_1}) nieznane ustawienie (jedno z: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) nieznane ustawienie `{name}` (high, medium, low lub zapisane: patrz flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) nieznane ustawienie `{name}` (patrz flattener.presets.list) @@ -4983,6 +5036,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} w `{key}` to {value}, a nie identyfikator obiektu (nieujemna liczba całkowita) @msg no such object id {value} brak obiektu o identyfikatorze {value} +@msg id {value} is a ruler guide, not an object identyfikator {value} należy do linii pomocniczej, a nie do obiektu +@msg a ruler guide goes only directly in a layer or sublayer linia pomocnicza może się znajdować tylko bezpośrednio w warstwie lub podwarstwie @msg id {value} is a layer, not an object identyfikator {value} oznacza warstwę, a nie obiekt @msg id {value} is a layer, not an object; move layers with layer.move identyfikator {value} oznacza warstwę, a nie obiekt; warstwy przenosi się za pomocą layer.move @msg automation filesystem access is not granted: read authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do odczytu: {path} @@ -5381,6 +5436,7 @@ file-filter Swatch libraries Biblioteki próbek file-filter Flattener presets Ustawienia spłaszczania file-filter PDF presets Ustawienia PDF file-filter Print presets Ustawienia drukowania +file-filter Preferences Preferencje file-filter Export presets Ustawienia eksportu file-filter SVG Compressed SVG skompresowany file-filter PDF-compatible .ai .ai zgodny z PDF @@ -5439,7 +5495,8 @@ mark-style Japanese Japońskie Bevel both sides Faza po obu stronach Presets Ustawienia @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Obiekt może mieć jeden efekt 3D. Edytuj opcje istniejącego efektu lub najpierw go usuń. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} wymaga obecnie ścieżki, ścieżki złożonej, tekstu lub grupy. Najpierw przekonwertuj obrazy i inne obiekty na ścieżki. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} wymaga obecnie ścieżki, ścieżki złożonej, tekstu, grupy, przejścia lub wystąpienia symbolu. Najpierw rozszerz inne obiekty. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nie może utworzyć bryły z obrazu: najpierw obrysuj go poleceniem Obiekt › Obrys obrazu › Utwórz. @msg {label} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami @msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D @msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt @@ -5520,6 +5577,7 @@ mark-style Japanese Japońskie Preview (1 inch on the page) Podgląd (1 cal na stronie) Set as Default Ustaw jako domyślne out from a highlighted edge to place its dimension | od podświetlonej krawędzi na zewnątrz, aby umieścić jej wymiar | + two points, then move and click to place an aligned, horizontal or vertical dimension | dwa punkty, potem przesuń i kliknij, aby umieścić wymiar wyrównany, poziomy lub pionowy | cancels | Double-click the tool for options anuluje | Kliknij dwukrotnie narzędzie, aby wyświetlić opcje Angular Kątowy Away from the Line Z dala od linii @@ -5549,14 +5607,17 @@ mark-style Japanese Japońskie @msg Invalid drawing scale Nieprawidłowa skala rysunku @msg Invalid real scale Nieprawidłowa skala rzeczywista @msg Measured anchor no longer exists Mierzony punkt kontrolny już nie istnieje +@msg Invalid measured point Nieprawidłowy mierzony punkt @msg Measured segment no longer exists Mierzony odcinek już nie istnieje @msg Non-finite dimension Wymiar nie jest liczbą skończoną +@msg Only linear dimensions are horizontal or vertical Tylko wymiary liniowe są poziome lub pionowe @msg Scale Text Attributes: selected type has no scale to bake Skaluj atrybuty tekstu: wybrany tekst nie ma skali do zastosowania @msg Scale must be drawing:real, for example 1:10 Skala musi mieć postać rysunek:rzeczywistość, na przykład 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Współczynnik skali musi mieścić się między 1:1000000 a 1000000:1 @msg Second measured segment no longer exists Drugi mierzony odcinek już nie istnieje @msg Unknown dimension mode Nieznany tryb wymiaru @msg Unknown dimension option {k} Nieznana opcja wymiaru {k} +@msg Unknown dimension orientation Nieznana orientacja wymiaru @msg Unknown dimension style Nieznany styl wymiaru @msg Unknown unit Nieznana jednostka # Revolve cap description @@ -5817,6 +5878,20 @@ axis Y Axis Oś Y Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika Keep settings of its own Przechowywać własne ustawienia Keep a cache of its own, which you can clear Przechowywać własną pamięć podręczną, którą możesz wyczyścić + Run these commands on the document: {ids} Uruchamiać w dokumencie te polecenia: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}” wymienia w `commandAllowlist` polecenie `{id}`, którego wtyczka nie może wywołać +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: `commandAllowlist` manifestu go nie wymienia +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID lub argumenty leżą poza pamięcią modułu +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID lub argumenty nie są w UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumenty nie są obiektem JSON +@msg only a filter with the usual budget declares `commandAllowlist` tylko filtr ze zwykłym budżetem deklaruje `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) wywołania poleceń ({MAX_CALLS} wywołań i {MAX_CALL_BYTES} bajtów na uruchomienie) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) rozmiar wywołania polecenia (vc_call `{shown}`: {len} bajtów, limit {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` najwyżej {MAX_ALLOWED_COMMANDS} poleceń w `commandAllowlist` +@msg a plug-in may not call this command wtyczka nie może wywołać tego polecenia +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` potrzebuje uprawnienia `commands`, a `commands` potrzebuje `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` wymienia `{shown}`, które nie jest ID polecenia lub występuje dwa razy Clear Plug-in Cache Wyczyść pamięć podręczną wtyczki @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `{permission}` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature} @@ -5906,6 +5981,23 @@ axis Y Axis Oś Y Button Przycisk Panel Panel UI Frames Ramki UI + Map Markers Znaczniki mapy + Pin Pinezka + Flag Flaga + Crosshair Celownik + Checkpoint Punkt kontrolny + Inventory Icons Ikony ekwipunku + Skill Icons Ikony umiejętności + Attack Atak + Defend Obrona + Heal Leczenie + Buff Wzmocnienie + Ult Ult + Sword Miecz + Shield Tarcza + Potion Mikstura + Key Klucz + Gem Klejnot Open Symbol Library Otwórz bibliotekę symboli Save Symbol Library Zapisz bibliotekę symboli Save Symbol Library… Zapisz bibliotekę symboli… @@ -5960,6 +6052,16 @@ axis Y Axis Oś Y User Brush Library 9 Biblioteka pędzli użytkownika 9 User Brush Library 10 Biblioteka pędzli użytkownika 10 Art Ribbons Wstęgi artystyczne + Zigzag Zygzak + Burst Ray Promień uderzenia + Dash Trail Ślad kreskowany + Speed Line Linia prędkości + Game Trails Ślady gry + Energy Beams Wiązki energii + Laser Bolt Pocisk laserowy + Plasma Arc Łuk plazmowy + Charge Wave Fala ładunku + Spark Trail Ślad iskier Calligraphic Nibs Stalówki kaligraficzne Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone @@ -6550,3 +6652,33 @@ breakText Glyphs Glify Ignore All Ignoruj wszystko Add to Dictionary Dodaj do słownika No Suggestions Brak sugestii + Edit Custom Dictionary Edytuj słownik użytkownika + Entry: Wpis: +# Script Console + Script Console Konsola skryptów + Clear Script Console Wyczyść konsolę skryptów + Copy Script Console Kopiuj konsolę skryptów + Copy All Kopiuj wszystko + Copy Selected Kopiuj zaznaczenie + Finished Zakończono + Ended with an error Zakończono z błędem + Out of fuel Wyczerpano limit instrukcji + Ended at a host limit Zakończono na limicie hosta + Ended at a limit Zakończono na limicie + Stopped Zatrzymano + Ended by an internal fault Zakończono z powodu błędu wewnętrznego + time unavailable czas niedostępny + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} najwolniejsze: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} wywołanie hosta|{n} wywołania hosta|{n} wywołań hosta +@plural {n} earlier line dropped|{n} earlier lines dropped {n} odrzucony wcześniejszy wiersz|{n} odrzucone wcześniejsze wiersze|{n} odrzuconych wcześniejszych wierszy +@plural {n} line cut|{n} lines cut {n} skrócony wiersz|{n} skrócone wiersze|{n} skróconych wierszy +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last` + +# Deferred plug-in effects + Refresh Deferred Effects Odśwież odroczone efekty + Deferred Effects Odroczone efekty +@msg no plug-in effect is deferred żaden efekt wtyczki nie jest odroczony +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otwarto z {n} odroczonym efektem wtyczki. Efekt › Odśwież odroczone efekty go uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e0e83aaec..f67ae6cda 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -275,6 +275,7 @@ antiAlias Sharp Nítido Average… Média… BLANK DOCUMENT PRESETS PREDEFINIDOS DE DOCUMENTO EM BRANCO Background Color: Cor do Plano de Fundo: + Guide Label: Rótulo das Guias: Show Center Mark Mostrar Marca Central Show Cross Hairs Mostrar Linhas Cruzadas Show Video Safe Areas Mostrar Áreas Seguras de Vídeo @@ -1455,6 +1456,7 @@ antiAlias Sharp Nítido Include SVG Code Incluir Código SVG Include Swatches for Tints Incluir Amostras para Tons Include non-printing layers Incluir camadas de não impressão + Write pages longer than 200 in at full size in points Gravar páginas com mais de 200 in em tamanho real, em pontos Inflate… Inflar… Info Informações Ink Tinta @@ -1617,6 +1619,7 @@ antiAlias Sharp Nítido Lock Grid Bloquear Grade Lock Guides Bloquear Guias Lock Guides by Default Bloquear Guias por Padrão + Artboard Guides with Cmd/Ctrl + Ruler Drag Guias de Prancheta com Cmd/Ctrl + Arrastar da Régua Lock Slices Bloquear Fatias Lock Station Point Bloquear Ponto de Estação Lossless (lossy WebP isn't available yet) Sem Perdas (WebP com perdas ainda não está disponível) @@ -1928,6 +1931,8 @@ antiAlias Sharp Nítido Open… Abrir… Optical Óptico Optical Margin Alignment Alinhamento de Margem Óptica + Dynamic Text Texto Dinâmico +@msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames} Optimize for fast web view Otimizar para visualização rápida na web Optimized Otimizado Options Opções @@ -2356,6 +2361,12 @@ digits Malayalam Malaiala Reset OpenType Features Redefinir Recursos OpenType Reset Panel Redefinir Painel Reset Preferences Redefinir Preferências + Export Preferences… Exportar Preferências… + Import Preferences… Importar Preferências… + Save the applied preferences to a JSON file Salvar as preferências aplicadas em um arquivo JSON + Apply the preferences in a file Export Preferences saved Aplicar as preferências de um arquivo salvo com Exportar Preferências + Export Preferences Exportar Preferências + Import Preferences Importar Preferências Search Preferences Pesquisar nas Preferências No settings found Nenhuma configuração encontrada Reset Profiles Redefinir Perfis @@ -2377,6 +2388,8 @@ digits Malayalam Malaiala Restore Restaurar Restore Recovered Documents Restaurar Documentos Recuperados Restore every preference to its default (applied on OK) Restaurar toda preferência ao padrão (aplicado em OK) + Reset all preferences to their defaults? Redefinir todas as preferências para o padrão? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Os padrões são aplicados quando você clica em OK em Preferências. Cancelar ali mantém suas preferências atuais. Restored documents open unsaved: save them to keep them. Os documentos restaurados abrem sem salvar: salve-os para mantê-los. Restrict printing, editing and other tasks Restringir impressão, edição e outras tarefas Reverse Front to Back Inverter da Frente para Trás @@ -2631,6 +2644,20 @@ digits Malayalam Malaiala Set the selected color to the current color Definir a cor selecionada para a cor atual Setup… Configuração… Shadows and Glows Sombras e Brilhos + Game HUD HUD de jogo + Health Fill Vida + Mana Glow Mana + Damage Flash Dano + Quest Outline Missão + Coin Shine Moeda + Boss Alert Chefe + Status Effects Efeitos de status + Poison Tint Veneno + Freeze Edge Congelamento + Burn Aura Queimadura + Stun Pulse Atordoamento + Stealth Fade Furtividade + Shield Buff Escudo Shape Forma Shape Builder Construtor de Formas Shape Builder Regions Regiões do Construtor de Formas @@ -3825,6 +3852,7 @@ axis Both Ambos @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard nesta escala o desenho tem {w} × {h} pt, mais do que a tela comporta: escolha uma escala menor ou ajuste-o à prancheta @msg background {other}: transparent, white, black or a colour such as "#ff8800" plano de fundo {other}: transparent, white, black ou uma cor como "#ff8800" @msg bad color {v} cor inválida {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` é null, o nome de uma cor de camada ou #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio deve ser um número de 0,1 a 10 @msg bad colour `{hex}` (#rrggbb) cor inválida `{hex}` (#rrggbb) @msg bad page tree árvore de páginas inválida @@ -3882,6 +3910,11 @@ axis Both Ambos @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) erro interno em `{cmd}`: {msg} (o documento foi mantido como estava; relate este bug) @msg internal error: {msg} (please report this bug) erro interno: {msg} (relate este bug) @msg invalid PDF setting: {0} configuração de PDF inválida: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): ela é gravada com um UserUnit de {unit}, então apps que ignoram UserUnit a mostram essa quantidade de vezes menor +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): páginas {label} são gravadas sem UserUnit, em pontos, e apps que respeitam o limite cortam a página em 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 o PDF 1.5 não tem UserUnit, de que uma página com mais de 14.400 pt (200 in) precisa para manter seu tamanho: o arquivo é gravado como PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units a página da prancheta {n} tem {size}, longa e estreita demais para os lados de página do PDF, de 3 a 14.400 unidades: ela é gravada com uma UserUnit de {unit}, que mantém o lado menor em 3 unidades ou mais, e os apps que seguem o limite cortam o lado maior em 14.400 unidades +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard os arquivos {label} aceitam números reais de até {max}, e a página da prancheta {n}, com {side} pt, precisa de números maiores: escolha PDF 1.6 ou posterior sem padrão PDF/X, ou uma prancheta menor @msg invalid image data for `{0}` dados de imagem inválidos para `{0}` @msg invalid parameters for `{cmd}`: {msg} parâmetros inválidos para `{cmd}`: {msg} @msg is not an image VectorCraft reads não é uma imagem que o VectorCraft lê @@ -4217,6 +4250,20 @@ axis Both Ambos @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressões desconhecidas `{o}` (preserve, simulate ou discard) @msg unknown panel `{raw}` painel desconhecido `{raw}` @msg unknown preference `{key}` preferência desconhecida `{key}` +@msg Preferences imported. Preferências importadas. +@msg not a preferences file: {e} não é um arquivo de preferências: {e} +@msg not a preferences file: no JSON object não é um arquivo de preferências: nenhum objeto JSON +@msg not a preferences file (format `{format}`) não é um arquivo de preferências (formato `{format}`) +@msg not a preferences file: no `format` não é um arquivo de preferências: falta `format` +@msg not a preferences file: no `version` não é um arquivo de preferências: falta `version` +@msg not a preferences file: no `prefs` não é um arquivo de preferências: falta `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) o arquivo foi salvo por uma versão mais recente do VectorCraft (versão {v}) +@msg the file holds no preferences o arquivo não contém preferências +@msg `{key}` names a folder, which preferences files leave out `{key}` indica uma pasta, e os arquivos de preferências não incluem pastas +@msg the file is larger than 1 MB o arquivo tem mais de 1 MB +@msg give `path` or `data` informe `path` ou `data` +@msg `{key}` must be an object `{key}` deve ser um objeto +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` é executado sozinho, não como etapa de um lote @msg unknown preset (one of {_1}) predefinição desconhecida (uma de {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) predefinição desconhecida `{name}` (high, medium, low ou uma salva: veja flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) predefinição desconhecida `{name}` (veja flattener.presets.list) @@ -4389,6 +4436,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id a entrada {index} de `{key}` é {value}, não um id de objeto (inteiro não negativo) @msg no such object id {value} não existe objeto com o id {value} +@msg id {value} is a ruler guide, not an object o id {value} é de uma guia, não de um objeto +@msg a ruler guide goes only directly in a layer or sublayer uma guia só pode ficar diretamente em uma camada ou subcamada @msg id {value} is a layer, not an object o id {value} é uma camada, não um objeto @msg id {value} is a layer, not an object; move layers with layer.move o id {value} é uma camada, não um objeto; mova camadas com layer.move @msg automation filesystem access is not granted: read authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de leitura: {path} @@ -4982,6 +5031,8 @@ axis Both Ambos Text Wrap Options Opções de Quebra de Texto Tile Full Pages Dividir em Páginas Inteiras Tint Tom + Fill Tint Tom de Preenchimento + Stroke Tint Tom do Traçado Tip extends past the end point A ponta se estende além do ponto final Tip on the end point Ponta no ponto final Toolbar: Add Tool Barra de Ferramentas: Adicionar Ferramenta @@ -5029,6 +5080,7 @@ axis Both Ambos Stop Script Parar script Show External Script Warning Mostrar aviso de scripts externos A Script Is Running Um script está em execução + Script Alert Alerta do script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Um script pode alterar os documentos abertos e gravar arquivos pelos comandos que executa. Execute apenas scripts em que você confia. Do not show this warning again Não mostrar este aviso novamente Stop the script and undo what it did? Then quit again. Parar o script e desfazer o que ele fez? Depois, saia novamente. @@ -5042,6 +5094,9 @@ axis Both Ambos @msg the script is too large to run o script é grande demais para ser executado @msg a drag or an undo group is open; finish it before running a script há um arraste ou um grupo de desfazer aberto; conclua-o antes de executar um script @msg the script left a document that cannot be kept o script deixou um documento que não pode ser mantido +@msg the script is no longer in the Scripts folder o script não está mais na pasta Scripts + Some scripts were not listed Alguns scripts não foram listados + The Scripts folder cannot be read Não é possível ler a pasta Scripts @msg the script's run is no longer available a execução do script não está mais disponível @msg a failed command changed undo steps that could not be put back, so the script was stopped um comando que falhou alterou etapas de desfazer que não puderam ser restauradas, então o script foi interrompido @msg a recovery copy could not be written, so the script was not run não foi possível gravar uma cópia de recuperação, portanto o script não foi executado @@ -5424,6 +5479,7 @@ file-filter Swatch libraries Bibliotecas de amostras file-filter Flattener presets Predefinidos de achatamento file-filter PDF presets Predefinidos de PDF file-filter Print presets Predefinidos de impressão +file-filter Preferences Preferências file-filter Export presets Predefinições de exportação file-filter SVG Compressed SVG Compactado file-filter PDF-compatible .ai .ai compatível com PDF @@ -5554,7 +5610,8 @@ mark-style Japanese Japonesas Bevel both sides Chanfro nos Dois Lados Presets Predefinidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Um objeto aceita apenas um efeito 3D. Edite as opções do efeito existente ou remova-o primeiro. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} exige, por enquanto, um caminho, caminho composto, texto ou grupo. Converta antes imagens e outros objetos em caminhos. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} exige, por enquanto, um caminho, caminho composto, texto, grupo, mesclagem ou instância de símbolo. Expanda antes os outros objetos. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} não pode criar um sólido a partir de uma imagem: rastreie-a antes com Objeto › Rastreamento de Imagem › Criar. @msg {label} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma @@ -5629,14 +5686,17 @@ mark-style Japanese Japonesas @msg Invalid drawing scale Escala de desenho inválida @msg Invalid real scale Escala real inválida @msg Measured anchor no longer exists O ponto de ancoragem medido não existe mais +@msg Invalid measured point Ponto medido inválido @msg Measured segment no longer exists O segmento medido não existe mais @msg Non-finite dimension Cota não finita +@msg Only linear dimensions are horizontal or vertical Somente cotas lineares são horizontais ou verticais @msg Scale Text Attributes: selected type has no scale to bake Dimensionar Atributos de Texto: o texto selecionado não tem escala para aplicar @msg Scale must be drawing:real, for example 1:10 A escala deve ser desenho:real, por exemplo 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 A proporção da escala deve estar entre 1:1000000 e 1000000:1 @msg Second measured segment no longer exists O segundo segmento medido não existe mais @msg Unknown dimension mode Modo de cota desconhecido @msg Unknown dimension option {k} Opção de cota desconhecida {k} +@msg Unknown dimension orientation Orientação de cota desconhecida @msg the Color Picker is not open o Seletor de Cores não está aberto @msg the Simplify slider is not open o controle deslizante Simplificar não está aberto @msg the Smooth slider is not open o controle deslizante Suavizar não está aberto @@ -5656,6 +5716,7 @@ mark-style Japanese Japonesas This system has no screen color sampler Este sistema não tem coletor de cores da tela cancels | Double-click the tool for options cancela | Clique duas vezes na ferramenta para ver as opções out from a highlighted edge to place its dimension | para fora a partir de uma borda destacada para posicionar a cota | + two points, then move and click to place an aligned, horizontal or vertical dimension | em dois pontos, depois mova e clique para posicionar uma cota alinhada, horizontal ou vertical | Color Panel Options Opções do Painel Cor @msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` deve ser grayscale, rgb, hsb, cmyk ou websafe @msg the image can't be read as a texture a imagem não pode ser lida como textura @@ -5878,6 +5939,20 @@ axis Y Axis Eixo Y Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log Keep settings of its own Guardar configurações próprias Keep a cache of its own, which you can clear Guardar um cache próprio, que você pode limpar + Run these commands on the document: {ids} Executar estes comandos no documento: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” lista `{id}` em `commandAllowlist`, que um plug-in não pode chamar +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: o `commandAllowlist` do manifesto não o lista +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: o ID ou os argumentos estão fora da memória do módulo +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: o ID ou os argumentos não são UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: os argumentos não são um objeto JSON +@msg only a filter with the usual budget declares `commandAllowlist` somente um filtro com o orçamento habitual declara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) chamadas de comandos ({MAX_CALLS} chamadas e {MAX_CALL_BYTES} bytes por execução) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) tamanho de uma chamada de comando (vc_call `{shown}`: {len} bytes, limite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` no máximo {MAX_ALLOWED_COMMANDS} comandos em `commandAllowlist` +@msg a plug-in may not call this command um plug-in não pode chamar este comando +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` precisa da permissão `commands`, e `commands` precisa de um `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` lista `{shown}`, que não é um ID de comando ou aparece duas vezes Clear Plug-in Cache Limpar cache do plug-in @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `{permission}` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature} @@ -5967,6 +6042,23 @@ axis Y Axis Eixo Y Button Botão Panel Painel UI Frames Molduras de IU + Map Markers Marcadores de mapa + Pin Alfinete + Flag Bandeira + Crosshair Mira + Checkpoint Ponto de verificação + Inventory Icons Ícones de inventário + Skill Icons Ícones de habilidade + Attack Ataque + Defend Defesa + Heal Cura + Buff Buff + Ult Ult + Sword Espada + Shield Escudo + Potion Poção + Key Chave + Gem Gema Open Symbol Library Abrir Biblioteca de Símbolos Save Symbol Library Salvar Biblioteca de Símbolos Save Symbol Library… Salvar Biblioteca de Símbolos… @@ -6021,6 +6113,16 @@ axis Y Axis Eixo Y User Brush Library 9 Biblioteca de Pincéis do Usuário 9 User Brush Library 10 Biblioteca de Pincéis do Usuário 10 Art Ribbons Fitas de Arte + Zigzag Zigue-zague + Burst Ray Raio de impacto + Dash Trail Rastro tracejado + Speed Line Linha de velocidade + Game Trails Rastros de jogo + Energy Beams Feixes de energia + Laser Bolt Raio laser + Plasma Arc Arco de plasma + Charge Wave Onda de carga + Spark Trail Rastro de faíscas Calligraphic Nibs Penas Caligráficas Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão @@ -6767,3 +6869,33 @@ swatch Yellow Green Verde-amarelado Ignore All Ignorar Tudo Add to Dictionary Adicionar ao Dicionário No Suggestions Nenhuma Sugestão + Edit Custom Dictionary Editar Dicionário Personalizado + Entry: Entrada: +# Script Console + Script Console Console de scripts + Clear Script Console Limpar console de scripts + Copy Script Console Copiar console de scripts + Copy All Copiar tudo + Copy Selected Copiar seleção + Finished Concluído + Ended with an error Encerrado com um erro + Out of fuel Limite de instruções esgotado + Ended at a host limit Encerrado em um limite do host + Ended at a limit Encerrado em um limite + Stopped Interrompido + Ended by an internal fault Encerrado por um erro interno + time unavailable tempo indisponível + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} mais lentos: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} chamada ao host|{n} chamadas ao host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} linha anterior descartada|{n} linhas anteriores descartadas +@plural {n} line cut|{n} lines cut {n} linha cortada|{n} linhas cortadas +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last` + +# Deferred plug-in effects + Refresh Deferred Effects Atualizar Efeitos Adiados + Deferred Effects Efeitos Adiados +@msg no plug-in effect is deferred nenhum efeito de plug-in está adiado +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aberto com {n} efeito de plug-in adiado. Efeito › Atualizar Efeitos Adiados o executa.|Aberto com {n} efeitos de plug-in adiados. Efeito › Atualizar Efeitos Adiados os executa. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ccd9c605..8eb542582 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -287,6 +287,7 @@ font-technology Variable Вариативные Average… Усреднить… Axis: Ось: Background Color: Цвет фона: + Guide Label: Метка направляющих: Show Center Mark Показать центральную метку Show Cross Hairs Показать перекрестие Show Video Safe Areas Показать безопасные зоны видео @@ -1275,6 +1276,7 @@ file-filter Swatch libraries Библиотеки образцов file-filter Flattener presets Наборы объединения file-filter PDF presets Наборы PDF file-filter Print presets Наборы печати +file-filter Preferences Настройки file-filter Export presets Наборы настроек экспорта file-filter SVG Compressed Сжатый SVG file-filter PDF-compatible .ai .ai, совместимый с PDF @@ -1681,6 +1683,7 @@ file-filter Templates Шаблоны Include Linked Files Включать связанные файлы Include Metadata Включать метаданные Include non-printing layers Включать непечатаемые слои + Write pages longer than 200 in at full size in points Записывать страницы длиннее 200 in в полном размере, в пунктах Include SVG Code Включать код SVG Include Swatches for Tints Включать образцы для оттенков Inflate… Надувание… @@ -1922,6 +1925,7 @@ file-filter Templates Шаблоны Lock Grid Заблокировать сетку Lock Guides Заблокировать направляющие Lock Guides by Default Блокировать направляющие по умолчанию + Artboard Guides with Cmd/Ctrl + Ruler Drag Направляющие монтажной области с Cmd/Ctrl + перетаскиванием с линейки Lock Others Заблокировать остальные Lock Slices Заблокировать фрагменты Lock Station Point Заблокировать точку стояния @@ -2356,6 +2360,8 @@ file-filter Templates Шаблоны Open… Открыть… Optical Оптический Optical Margin Alignment Оптическое выравнивание полей + Dynamic Text Динамический текст +@msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames} Optimize for fast web view Оптимизировать для быстрого просмотра в web Optimized Оптимизировано Options Параметры @@ -2847,6 +2853,12 @@ digits Malayalam Малаялам Reset OpenType Features Сбросить функции OpenType Reset Panel Сбросить панель Reset Preferences Сбросить настройки + Export Preferences… Экспортировать настройки… + Import Preferences… Импортировать настройки… + Save the applied preferences to a JSON file Сохранить применённые настройки в файл JSON + Apply the preferences in a file Export Preferences saved Применить настройки из файла, сохранённого командой «Экспортировать настройки» + Export Preferences Экспортировать настройки + Import Preferences Импортировать настройки Search Preferences Поиск в настройках No settings found Настройки не найдены Reset Profiles Сбросить профили @@ -2870,6 +2882,8 @@ digits Malayalam Малаялам Responsive Адаптивный Restore Восстановить Restore every preference to its default (applied on OK) Восстановить все настройки по умолчанию (применяется при нажатии ОК) + Reset all preferences to their defaults? Сбросить все настройки до значений по умолчанию? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Значения по умолчанию применяются, когда вы нажимаете ОК в окне «Настройки». Отмена там сохраняет текущие настройки. Restore Recovered Documents Восстановить восстановленные документы Restored documents open unsaved: save them to keep them. Восстановленные документы открываются несохранёнными: сохраните их, чтобы оставить. Restrict printing, editing and other tasks Ограничить печать, редактирование и другие задачи @@ -3170,6 +3184,20 @@ digits Malayalam Малаялам Settings… Параметры… Setup… Настройка… Shadows and Glows Тени и свечения + Game HUD Игровой HUD + Health Fill Здоровье + Mana Glow Мана + Damage Flash Урон + Quest Outline Квест + Coin Shine Монета + Boss Alert Босс + Status Effects Эффекты статуса + Poison Tint Яд + Freeze Edge Заморозка + Burn Aura Ожог + Stun Pulse Оглушение + Stealth Fade Скрытность + Shield Buff Щит Shape Фигура Shape Builder Создание фигур Shape Builder Regions Области создания фигур @@ -3588,6 +3616,8 @@ digits Malayalam Малаялам Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) Tint Оттенок + Fill Tint Оттенок заливки + Stroke Tint Оттенок обводки Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке Title Case Как в заголовке @@ -4108,6 +4138,7 @@ axis Both Оба Stop Script Остановить скрипт Show External Script Warning Показывать предупреждение о внешних скриптах A Script Is Running Выполняется скрипт + Script Alert Сообщение скрипта A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт может изменять открытые документы и записывать файлы с помощью выполняемых команд. Запускайте только те скрипты, которым доверяете. Do not show this warning again Больше не показывать это предупреждение Stop the script and undo what it did? Then quit again. Остановить скрипт и отменить его изменения? Затем выйдите ещё раз. @@ -4121,6 +4152,9 @@ axis Both Оба @msg the script is too large to run скрипт слишком велик для выполнения @msg a drag or an undo group is open; finish it before running a script открыто перетаскивание или группа отмены; завершите их перед запуском скрипта @msg the script left a document that cannot be kept скрипт оставил документ, который нельзя сохранить +@msg the script is no longer in the Scripts folder скрипт больше не находится в папке Scripts + Some scripts were not listed Некоторые сценарии не показаны + The Scripts folder cannot be read Не удалось прочитать папку Scripts @msg the script's run is no longer available выполнение скрипта больше недоступно @msg a failed command changed undo steps that could not be put back, so the script was stopped неудавшаяся команда изменила шаги отмены, которые не удалось восстановить, поэтому скрипт остановлен @msg a recovery copy could not be written, so the script was not run не удалось записать копию для восстановления, поэтому скрипт не выполнен @@ -4305,6 +4339,7 @@ axis Both Оба @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black или цвет, например "#ff8800" @msg bad color {v} недопустимый цвет {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` должен быть null, именем цвета слоя или #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio должно быть числом от 0,1 до 10 @msg the Color Picker is not open палитра цветов не открыта @msg the Simplify slider is not open ползунок «Упростить» не открыт @@ -4368,6 +4403,11 @@ axis Both Оба @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) внутренняя ошибка в `{cmd}`: {msg} (документ оставлен в прежнем состоянии; пожалуйста, сообщите об этой ошибке) @msg internal error: {msg} (please report this bug) внутренняя ошибка: {msg} (пожалуйста, сообщите об этой ошибке) @msg invalid PDF setting: {0} недопустимый параметр PDF: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): она записывается с UserUnit {unit}, поэтому приложения, которые игнорируют UserUnit, показывают её во столько же раз меньше +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): страницы {label} записываются без UserUnit, в пунктах, и приложения, соблюдающие ограничение, обрезают страницу до 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 в PDF 1.5 нет UserUnit, который нужен странице длиннее 14 400 pt (200 in), чтобы сохранить размер: файл записывается как PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units страница монтажной области {n} имеет размер {size} и слишком длинная и узкая для сторон страницы PDF от 3 до 14 400 единиц: она записывается с UserUnit {unit}, которая держит короткую сторону не меньше 3 единиц, а приложения, соблюдающие предел, обрезают длинную сторону до 14 400 единиц +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard файлы {label} вмещают вещественные числа до {max}, а странице монтажной области {n} длиной {side} pt нужны большие: выберите PDF 1.6 или новее без стандарта PDF/X или монтажную область меньшего размера @msg invalid image data for `{0}` недопустимые данные изображения для `{0}` @msg invalid parameters for `{cmd}`: {msg} недопустимые параметры для `{cmd}`: {msg} @msg is not an image VectorCraft reads не является изображением, которое читает VectorCraft @@ -4705,6 +4745,20 @@ axis Both Оба @msg unknown overprints `{o}` (preserve, simulate or discard) неизвестные наложения `{o}` (preserve, simulate или discard) @msg unknown panel `{raw}` неизвестная панель `{raw}` @msg unknown preference `{key}` неизвестная настройка `{key}` +@msg Preferences imported. Настройки импортированы. +@msg not a preferences file: {e} не является файлом настроек: {e} +@msg not a preferences file: no JSON object не является файлом настроек: нет объекта JSON +@msg not a preferences file (format `{format}`) не является файлом настроек (формат `{format}`) +@msg not a preferences file: no `format` не является файлом настроек: нет `format` +@msg not a preferences file: no `version` не является файлом настроек: нет `version` +@msg not a preferences file: no `prefs` не является файлом настроек: нет `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) файл сохранён более новой версией VectorCraft (версия {v}) +@msg the file holds no preferences файл не содержит настроек +@msg `{key}` names a folder, which preferences files leave out `{key}` указывает папку, а файлы настроек не содержат папок +@msg the file is larger than 1 MB файл больше 1 МБ +@msg give `path` or `data` укажите `path` или `data` +@msg `{key}` must be an object `{key}` должен быть объектом +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` выполняется отдельно, а не как шаг пакета @msg unknown preset (one of {_1}) неизвестный набор (один из: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) неизвестный набор `{name}` (high, medium, low или сохранённый: см. flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) неизвестный набор `{name}` (см. flattener.presets.list) @@ -4913,6 +4967,8 @@ axis Both Оба @msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число @msg `{key}` entry {index} is {value}, not a non-negative integer object id элемент {index} в `{key}` равен {value}, а не идентификатору объекта (неотрицательному целому числу) @msg no such object id {value} объект с идентификатором {value} не существует +@msg id {value} is a ruler guide, not an object идентификатор {value} принадлежит направляющей, а не объекту +@msg a ruler guide goes only directly in a layer or sublayer направляющая может находиться только непосредственно в слое или подслое @msg id {value} is a layer, not an object идентификатор {value} относится к слою, а не к объекту @msg id {value} is a layer, not an object; move layers with layer.move идентификатор {value} относится к слою, а не к объекту; перемещайте слои с помощью layer.move @msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path} @@ -5212,7 +5268,8 @@ lighting Light Свет Bevel both sides Фаска с обеих сторон Presets Наборы @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. У объекта может быть только один 3D-эффект. Измените параметры существующего эффекта или сначала удалите его. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: пока требуется контур, составной контур, текст или группа. Сначала преобразуйте изображения и другие объекты в контуры. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: пока требуется контур, составной контур, текст, группа, переход или экземпляр символа. Сначала разберите другие объекты. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: из изображения нельзя построить тело. Сначала трассируйте его командой Объект › Трассировка изображения › Создать. @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру @@ -5271,6 +5328,7 @@ lighting Light Свет Preview (1 inch on the page) Просмотр (1 дюйм на странице) Set as Default Установить по умолчанию out from a highlighted edge to place its dimension | наружу от подсвеченного края, чтобы разместить его размер | + two points, then move and click to place an aligned, horizontal or vertical dimension | по двум точкам, затем переместите и щёлкните, чтобы разместить выровненный, горизонтальный или вертикальный размер | cancels | Double-click the tool for options отменяет | Двойной щелчок по инструменту открывает параметры Linear Линейный Angular Угловой @@ -5303,14 +5361,17 @@ lighting Light Свет @msg Invalid drawing scale Недопустимый масштаб чертежа @msg Invalid real scale Недопустимый реальный масштаб @msg Measured anchor no longer exists Измеряемая опорная точка больше не существует +@msg Invalid measured point Недопустимая измеряемая точка @msg Measured segment no longer exists Измеряемый сегмент больше не существует @msg Non-finite dimension Размер не является конечным числом +@msg Only linear dimensions are horizontal or vertical Только линейные размеры бывают горизонтальными или вертикальными @msg Scale Text Attributes: selected type has no scale to bake Масштабировать атрибуты текста: у выбранного текста нет масштаба для применения @msg Scale must be drawing:real, for example 1:10 Масштаб должен быть в виде чертёж:реальность, например 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Соотношение масштаба должно быть от 1:1000000 до 1000000:1 @msg Second measured segment no longer exists Второй измеряемый сегмент больше не существует @msg Unknown dimension mode Неизвестный режим размера @msg Unknown dimension option {k} Неизвестный параметр размера {k} +@msg Unknown dimension orientation Неизвестная ориентация размера @msg Unknown dimension style Неизвестный стиль размера @msg Unknown unit Неизвестная единица # Revolve cap description @@ -5574,6 +5635,20 @@ axis Y Axis Ось Y Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала Keep settings of its own Хранить собственные настройки Keep a cache of its own, which you can clear Хранить собственный кэш, который можно очистить + Run these commands on the document: {ids} Выполнять в документе эти команды: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call «{name}» указывает в `commandAllowlist` команду `{id}`, которую модуль вызывать не может +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: в `commandAllowlist` манифеста её нет +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID или аргументы лежат вне памяти модуля +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID или аргументы не в UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: аргументы не являются объектом JSON +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` может объявлять только фильтр с обычным бюджетом +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) вызовы команд ({MAX_CALLS} вызовов и {MAX_CALL_BYTES} байт за запуск) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) размер вызова команды (vc_call `{shown}`: {len} байт, предел {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` не более {MAX_ALLOWED_COMMANDS} команд в `commandAllowlist` +@msg a plug-in may not call this command модуль не может вызывать эту команду +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` для `commandAllowlist` нужно разрешение `commands`, а для `commands` нужен `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` указывает `{shown}`, который не является ID команды или указан дважды Clear Plug-in Cache Очистить кэш модуля @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `{permission}` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature} @@ -5663,6 +5738,23 @@ axis Y Axis Ось Y Button Кнопка Panel Панель UI Frames UI-рамки + Map Markers Маркеры карты + Pin Булавка + Flag Флаг + Crosshair Прицел + Checkpoint Контрольная точка + Inventory Icons Иконки инвентаря + Skill Icons Иконки навыков + Attack Атака + Defend Защита + Heal Лечение + Buff Усиление + Ult Ульт + Sword Меч + Shield Щит + Potion Зелье + Key Ключ + Gem Самоцвет Open Symbol Library Открыть библиотеку символов Save Symbol Library Сохранить библиотеку символов Save Symbol Library… Сохранить библиотеку символов… @@ -5717,6 +5809,16 @@ axis Y Axis Ось Y User Brush Library 9 Пользовательская библиотека кистей 9 User Brush Library 10 Пользовательская библиотека кистей 10 Art Ribbons Художественные ленты + Zigzag Зигзаг + Burst Ray Ударный луч + Dash Trail Пунктирный след + Speed Line Линия скорости + Game Trails Игровые следы + Energy Beams Энергетические лучи + Laser Bolt Лазерный заряд + Plasma Arc Плазменная дуга + Charge Wave Волна заряда + Spark Trail Искра-след Calligraphic Nibs Каллиграфические перья Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры @@ -6783,3 +6885,33 @@ shape Open Открытый Ignore All Пропустить все Add to Dictionary Добавить в словарь No Suggestions Нет вариантов + Edit Custom Dictionary Редактировать пользовательский словарь + Entry: Запись: +# Script Console + Script Console Консоль скриптов + Clear Script Console Очистить консоль скриптов + Copy Script Console Копировать консоль скриптов + Copy All Копировать всё + Copy Selected Копировать выделенное + Finished Завершено + Ended with an error Завершено с ошибкой + Out of fuel Исчерпан лимит инструкций + Ended at a host limit Завершено на лимите хоста + Ended at a limit Завершено на лимите + Stopped Остановлено + Ended by an internal fault Завершено из-за внутренней ошибки + time unavailable время недоступно + {ms} ms {ms} мс + {time}, {calls} {time}, {calls} + slowest: {commands} самые медленные: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} вызов хоста|{n} вызова хоста|{n} вызовов хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} прежняя строка отброшена|{n} прежние строки отброшены|{n} прежних строк отброшено +@plural {n} line cut|{n} lines cut {n} строка обрезана|{n} строки обрезаны|{n} строк обрезано +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last` + +# Deferred plug-in effects + Refresh Deferred Effects Обновить отложенные эффекты + Deferred Effects Отложенные эффекты +@msg no plug-in effect is deferred нет отложенных эффектов модулей +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Открыто с {n} отложенным эффектом модуля. Эффект › Обновить отложенные эффекты выполнит его.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a732ff6c0..fb2c463de 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -278,6 +278,7 @@ font-technology Variable Variabla Average… Medelvärde… Axis: Axel: Background Color: Bakgrundsfärg: + Guide Label: Stödlinjeetikett: Show Center Mark Visa mittmarkering Show Cross Hairs Visa hårkors Show Video Safe Areas Visa videosäkra områden @@ -1616,6 +1617,7 @@ font-technology Variable Variabla Include Linked Files Inkludera länkade filer Include Metadata Inkludera metadata Include non-printing layers Inkludera lager som inte skrivs ut + Write pages longer than 200 in at full size in points Skriv sidor längre än 200 in i full storlek i punkter Include SVG Code Inkludera SVG-kod Include Swatches for Tints Inkludera färgrutor för nyanser Inflate… Blås upp… @@ -1837,6 +1839,7 @@ font-technology Variable Variabla Lock Grid Lås rutnät Lock Guides Lås stödlinjer Lock Guides by Default Lås stödlinjer som standard + Artboard Guides with Cmd/Ctrl + Ruler Drag Stödlinjer för rityta med Cmd/Ctrl + dra från linjal Lock Others Lås övriga Lock Slices Lås skivor Lock Station Point Lås betraktarpunkt @@ -2271,6 +2274,8 @@ font-technology Variable Variabla Open… Öppna… Optical Optisk Optical Margin Alignment Optisk marginaljustering + Dynamic Text Dynamisk text +@msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames} Optimize for fast web view Optimera för snabb webbvisning Optimized Optimerad Options Alternativ @@ -2755,6 +2760,12 @@ digits Malayalam Malayalam Reset OpenType Features Återställ OpenType-funktioner Reset Panel Återställ panel Reset Preferences Återställ inställningar + Export Preferences… Exportera inställningar… + Import Preferences… Importera inställningar… + Save the applied preferences to a JSON file Spara de tillämpade inställningarna i en JSON-fil + Apply the preferences in a file Export Preferences saved Tillämpa inställningarna i en fil som Exportera inställningar har sparat + Export Preferences Exportera inställningar + Import Preferences Importera inställningar Search Preferences Sök i inställningar No settings found Inga inställningar hittades Reset Profiles Återställ profiler @@ -2777,6 +2788,8 @@ digits Malayalam Malayalam Responsive Responsiv Restore Återställ Restore every preference to its default (applied on OK) Återställ alla inställningar till standard (verkställs vid OK) + Reset all preferences to their defaults? Återställa alla inställningar till standard? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Standardvärdena verkställs när du klickar på OK i Inställningar. Avbryt där behåller dina nuvarande inställningar. Restore Recovered Documents Återställ återskapade dokument Restored documents open unsaved: save them to keep them. Återställda dokument öppnas osparade: spara dem för att behålla dem. Restrict printing, editing and other tasks Begränsa utskrift, redigering och andra åtgärder @@ -3063,6 +3076,20 @@ digits Malayalam Malayalam Settings… Inställningar… Setup… Konfigurera… Shadows and Glows Skuggor och glöd + Game HUD Spel-HUD + Health Fill Hälsa + Mana Glow Mana + Damage Flash Skada + Quest Outline Uppdrag + Coin Shine Mynt + Boss Alert Boss + Status Effects Statuseffekter + Poison Tint Gift + Freeze Edge Frost + Burn Aura Bränna + Stun Pulse Bedövning + Stealth Fade Smyga + Shield Buff Sköld Shape Form Shape Builder Formbyggare Shape Builder Regions Formbyggarområden @@ -3479,6 +3506,8 @@ digits Malayalam Malayalam Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor. Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät) Tint Färgton + Fill Tint Fyllnadsfärgton + Stroke Tint Konturfärgton Tip extends past the end point Spetsen sträcker sig förbi slutpunkten Tip on the end point Spetsen på slutpunkten Title Case Inledande versaler @@ -3982,6 +4011,7 @@ axis Both Båda Stop Script Stoppa skript Show External Script Warning Visa varning för externa skript A Script Is Running Ett skript körs + Script Alert Skriptmeddelande A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ett skript kan ändra de öppna dokumenten och skriva filer genom de kommandon det kör. Kör bara skript du litar på. Do not show this warning again Visa inte den här varningen igen Stop the script and undo what it did? Then quit again. Stoppa skriptet och ångra det det gjort? Avsluta sedan igen. @@ -3995,6 +4025,9 @@ axis Both Båda @msg the script is too large to run skriptet är för stort för att köras @msg a drag or an undo group is open; finish it before running a script en dragning eller ångragrupp är öppen; avsluta den innan du kör ett skript @msg the script left a document that cannot be kept skriptet lämnade ett dokument som inte kan behållas +@msg the script is no longer in the Scripts folder skriptet finns inte längre i mappen Scripts + Some scripts were not listed Vissa skript visas inte i listan + The Scripts folder cannot be read Mappen Scripts kan inte läsas @msg the script's run is no longer available skriptets körning är inte längre tillgänglig @msg a failed command changed undo steps that could not be put back, so the script was stopped ett misslyckat kommando ändrade ångrasteg som inte kunde återställas, så skriptet stoppades @msg a recovery copy could not be written, so the script was not run en återställningskopia kunde inte skrivas, så skriptet kördes inte @@ -4178,6 +4211,7 @@ axis Both Båda @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis måste vara "vertical", "horizontal" eller en vinkel i grader, inte `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black eller en färg som "#ff8800" @msg bad color {v} ogiltig färg {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` är null, namnet på en lagerfärg eller #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio måste vara ett tal från 0,1 till 10 @msg bad colour `{hex}` (#rrggbb) ogiltig färg `{hex}` (#rrggbb) @msg bad page tree ogiltigt sidträd @@ -4236,6 +4270,11 @@ axis Both Båda @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) internt fel i `{cmd}`: {msg} (dokumentet lämnades som det var innan; rapportera gärna felet) @msg internal error: {msg} (please report this bug) internt fel: {msg} (rapportera gärna felet) @msg invalid PDF setting: {0} ogiltig PDF-inställning: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): den skrivs med en UserUnit på {unit}, så appar som ignorerar UserUnit visar den så många gånger mindre +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): {label}-sidor skrivs utan UserUnit, i punkter, och appar som håller sig till gränsen beskär sidan till 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 har ingen UserUnit, som en sida längre än 14 400 pt (200 in) behöver för att behålla sin storlek: filen skrivs som PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units sidan för rityta {n} är {size}, för lång och smal för PDF:s sidlängder på 3 till 14 400 enheter: den skrivs med UserUnit {unit}, som håller den korta sidan på minst 3 enheter, och appar som följer gränsen klipper den långa sidan vid 14 400 enheter +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-filer rymmer reella tal upp till {max}, och sidan för rityta {n}, {side} pt lång, behöver större: välj PDF 1.6 eller senare utan PDF/X-standard, eller en mindre rityta @msg invalid image data for `{0}` ogiltiga bilddata för `{0}` @msg invalid parameters for `{cmd}`: {msg} ogiltiga parametrar för `{cmd}`: {msg} @msg is not an image VectorCraft reads är inte en bild som VectorCraft kan läsa @@ -4567,6 +4606,20 @@ axis Both Båda @msg unknown overprints `{o}` (preserve, simulate or discard) okänt värde för övertryck `{o}` (preserve, simulate eller discard) @msg unknown panel `{raw}` okänd panel `{raw}` @msg unknown preference `{key}` okänd inställning `{key}` +@msg Preferences imported. Inställningarna har importerats. +@msg not a preferences file: {e} inte en inställningsfil: {e} +@msg not a preferences file: no JSON object inte en inställningsfil: inget JSON-objekt +@msg not a preferences file (format `{format}`) inte en inställningsfil (format `{format}`) +@msg not a preferences file: no `format` inte en inställningsfil: inget `format` +@msg not a preferences file: no `version` inte en inställningsfil: ingen `version` +@msg not a preferences file: no `prefs` inte en inställningsfil: ingen `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) filen sparades av en nyare version av VectorCraft (version {v}) +@msg the file holds no preferences filen innehåller inga inställningar +@msg `{key}` names a folder, which preferences files leave out `{key}` anger en mapp, och inställningsfiler innehåller inga mappar +@msg the file is larger than 1 MB filen är större än 1 MB +@msg give `path` or `data` ange `path` eller `data` +@msg `{key}` must be an object `{key}` måste vara ett objekt +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` körs för sig, inte som ett steg i en batch @msg unknown preset (one of {_1}) okänd förinställning (en av {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) okänd förinställning `{name}` (high, medium, low eller en sparad: se flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) okänd förinställning `{name}` (se flattener.presets.list) @@ -4791,6 +4844,8 @@ axis Both Båda @msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades @msg `{key}` entry {index} is {value}, not a non-negative integer object id post {index} i `{key}` är {value}, inte ett objekt-id (ett icke-negativt heltal) @msg no such object id {value} det finns inget objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} tillhör en stödlinje, inte ett objekt +@msg a ruler guide goes only directly in a layer or sublayer en stödlinje kan bara ligga direkt i ett lager eller underlager @msg id {value} is a layer, not an object id {value} är ett lager, inte ett objekt @msg id {value} is a layer, not an object; move layers with layer.move id {value} är ett lager, inte ett objekt; flytta lager med layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: läsbehörighet saknas: {path} @@ -5084,7 +5139,8 @@ lighting Light Ljus Bevel both sides Fasning på båda sidor Presets Förinställningar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ett objekt kan bara ha en 3D-effekt. Redigera alternativen för den befintliga effekten eller ta bort den först. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} kräver för närvarande en bana, sammansatt bana, text eller grupp. Konvertera bilder och andra objekt till banor först. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} kräver för närvarande en bana, sammansatt bana, text, grupp, blandning eller symbolinstans. Expandera andra objekt först. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan inte skapa en solid av en bild: kalkera den först med Objekt › Bildkalkering › Skapa. @msg {label} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter @msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt @msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen @@ -5514,6 +5570,7 @@ file-filter Flattener presets Förinställningar för plattning file-filter PDF presets PDF-förinställningar file-filter PDF-compatible .ai PDF-kompatibel .ai file-filter Print presets Utskriftsförinställningar +file-filter Preferences Inställningar file-filter Export presets Exportförinställningar file-filter SVG Compressed SVG komprimerad file-filter Swatch libraries Färgrutebibliotek @@ -5560,6 +5617,7 @@ theme Appearance Utseende 3d Light Ljus cancels | Double-click the tool for options avbryter | Dubbelklicka på verktyget för alternativ out from a highlighted edge to place its dimension | utåt från en framhävd kant för att placera dess mått | + two points, then move and click to place an aligned, horizontal or vertical dimension | på två punkter, flytta sedan och klicka för att placera ett justerat, vågrätt eller lodrätt mått | A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. En äldre .ai-fil rymmer en rityta: varje rityta du markerar sparas som en separat fil. Arrange Intertwine Area Ordna sammanflätningsområde Batch… Batch… @@ -5656,14 +5714,17 @@ theme Appearance Utseende @msg Invalid drawing scale Ogiltig ritningsskala @msg Invalid real scale Ogiltig verklig skala @msg Measured anchor no longer exists Den mätta ankarpunkten finns inte längre +@msg Invalid measured point Ogiltig mätt punkt @msg Measured segment no longer exists Det mätta segmentet finns inte längre @msg Non-finite dimension Måttet är inte ett ändligt tal +@msg Only linear dimensions are horizontal or vertical Endast linjära mått är vågräta eller lodräta @msg Scale Text Attributes: selected type has no scale to bake Skala textattribut: det valda texten har ingen skalning att tillämpa @msg Scale must be drawing:real, for example 1:10 Skalan måste anges som ritning:verklighet, till exempel 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Skalförhållandet måste ligga mellan 1:1000000 och 1000000:1 @msg Second measured segment no longer exists Det andra mätta segmentet finns inte längre @msg Unknown dimension mode Okänt måttläge @msg Unknown dimension option {k} Okänt måttalternativ {k} +@msg Unknown dimension orientation Okänd måttriktning @msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` måste vara grayscale, rgb, hsb, cmyk eller websafe @msg `{key}` must be an array of these names: {_1} `{key}` måste vara en lista med dessa namn: {_1} @msg choose a destination folder välj en målmapp @@ -5986,6 +6047,20 @@ axis Y Axis Y-axel Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader Keep settings of its own Spara egna inställningar Keep a cache of its own, which you can clear Spara en egen cache, som du kan tömma + Run these commands on the document: {ids} Köra dessa kommandon på dokumentet: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call ”{name}” anger `{id}` i `commandAllowlist`, som ett plugin inte får anropa +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: manifestets `commandAllowlist` anger det inte +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: id:t eller argumenten ligger utanför modulens minne +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: id:t eller argumenten är inte UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumenten är inte ett JSON-objekt +@msg only a filter with the usual budget declares `commandAllowlist` bara ett filter med den vanliga budgeten anger `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) kommandoanrop ({MAX_CALLS} anrop och {MAX_CALL_BYTES} byte per körning) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) storlek på ett kommandoanrop (vc_call `{shown}`: {len} byte, gräns {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` högst {MAX_ALLOWED_COMMANDS} kommandon i `commandAllowlist` +@msg a plug-in may not call this command ett plugin får inte anropa det här kommandot +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` behöver behörigheten `commands`, och `commands` behöver en `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` anger `{shown}`, som inte är ett kommando-id eller finns två gånger Clear Plug-in Cache Töm pluginets cache @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `{permission}` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature} @@ -6075,6 +6150,23 @@ axis Y Axis Y-axel Button Knapp Panel Panel UI Frames UI-ramar + Map Markers Kartmarkörer + Pin Nål + Flag Flagga + Crosshair Hårkors + Checkpoint Kontrollpunkt + Inventory Icons Inventarieikoner + Skill Icons Färdighetsikoner + Attack Attack + Defend Försvar + Heal Läkning + Buff Buff + Ult Ult + Sword Svärd + Shield Sköld + Potion Dryck + Key Nyckel + Gem Ädelsten Open Symbol Library Öppna symbolbibliotek Save Symbol Library Spara symbolbibliotek Save Symbol Library… Spara symbolbibliotek… @@ -6129,6 +6221,16 @@ axis Y Axis Y-axel User Brush Library 9 Användarbibliotek för penslar 9 User Brush Library 10 Användarbibliotek för penslar 10 Art Ribbons Konstband + Zigzag Sicksack + Burst Ray Stötstråle + Dash Trail Streckat spår + Speed Line Hastighetslinje + Game Trails Spår från spel + Energy Beams Energistrålar + Laser Bolt Laserbult + Plasma Arc Plasmabåge + Charge Wave Laddningsvåg + Spark Trail Gnistsvår Calligraphic Nibs Kalligrafiska pennspetsar Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer @@ -8179,3 +8281,33 @@ digits Hindi Hindi Ignore All Ignorera alla Add to Dictionary Lägg till i ordlistan No Suggestions Inga förslag + Edit Custom Dictionary Redigera egen ordlista + Entry: Post: +# Script Console + Script Console Skriptkonsol + Clear Script Console Rensa skriptkonsolen + Copy Script Console Kopiera skriptkonsolen + Copy All Kopiera allt + Copy Selected Kopiera markerade + Finished Klar + Ended with an error Avslutades med ett fel + Out of fuel Instruktionsgränsen nåddes + Ended at a host limit Avslutades vid en värdgräns + Ended at a limit Avslutades vid en gräns + Stopped Stoppad + Ended by an internal fault Avslutades av ett internt fel + time unavailable tid ej tillgänglig + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} långsammast: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} värdanrop|{n} värdanrop +@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidigare rad borttagen|{n} tidigare rader borttagna +@plural {n} line cut|{n} lines cut {n} rad avkortad|{n} rader avkortade +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last` + +# Deferred plug-in effects + Refresh Deferred Effects Uppdatera uppskjutna effekter + Deferred Effects Uppskjutna effekter +@msg no plug-in effect is deferred ingen plugineffekt är uppskjuten +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Öppnades med {n} uppskjuten plugineffekt. Effekt › Uppdatera uppskjutna effekter kör den.|Öppnades med {n} uppskjutna plugineffekter. Effekt › Uppdatera uppskjutna effekter kör dem. diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index fd842dd34..79cbd7a64 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -227,6 +227,24 @@ fn each_language_counts_with_its_plural_rule() { } } +/// The Script Console's counted phrases have a row in every complete catalog, and a count picks +/// the language's own form. +#[test] +fn script_console_counts_take_each_language_s_form() { + const COUNTED: [(&str, &str); 3] = + [("{n} host call", "{n} host calls"), ("{n} earlier line dropped", "{n} earlier lines dropped"), ("{n} line cut", "{n} lines cut")]; + for l in LANGUAGES.iter().filter(|l| l.complete_menus) { + for (one, other) in COUNTED { + assert!(l.catalog().plural(one, other, 0).is_some(), "{} is missing the row for {other}", l.code); + } + } + assert_eq!([1, 2].map(|n| trn(de(), n, COUNTED[0].0, COUNTED[0].1)), ["1 Host-Aufruf", "2 Host-Aufrufe"]); + let ru = Lang::from_code("ru").expect("registered"); + assert_eq!([1, 3, 5].map(|n| trn(ru, n, COUNTED[2].0, COUNTED[2].1)), ["1 строка обрезана", "3 строки обрезаны", "5 строк обрезано"]); + let fr = Lang::from_code("fr").expect("registered"); + assert_eq!([0, 1].map(|n| trn(fr, n, COUNTED[1].0, COUNTED[1].1)), ["0 ligne antérieure supprimée", "1 ligne antérieure supprimée"]); +} + #[test] fn candidates_walk_from_specific_to_general() { assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dad8615f5..d45a171cc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -274,6 +274,7 @@ font-technology Variable Değişken Average… Ortalama… Axis: Eksen: Background Color: Arka Plan Rengi: + Guide Label: Kılavuz Etiketi: Show Center Mark Merkez İşaretini Göster Show Cross Hairs Artı Çizgilerini Göster Show Video Safe Areas Video Güvenli Alanlarını Göster @@ -1496,6 +1497,7 @@ font-technology Variable Değişken Include Linked Files Bağlantılı Dosyaları Ekle Include Metadata Meta Verileri Ekle Include non-printing layers Yazdırılmayan katmanları dahil et + Write pages longer than 200 in at full size in points 200 in'den uzun sayfaları tam boyutta, punto olarak yaz Include SVG Code SVG Kodunu Ekle Include Swatches for Tints Renk Tonları için Renk Örneklerini Ekle Inflate… Şişir… @@ -1730,6 +1732,7 @@ font-technology Variable Değişken Lock Grid Izgarayı Kilitle Lock Guides Kilit Kılavuzları Lock Guides by Default Kılavuzları Varsayılan Olarak Kilitle + Artboard Guides with Cmd/Ctrl + Ruler Drag Cmd/Ctrl + Cetvelden Sürükleme ile Çalışma Yüzeyi Kılavuzları Lock Others Diğerlerini Kilitle Lock Slices Dilimleri Kilitle Lock Station Point Kilit İstasyon Noktası @@ -2143,6 +2146,8 @@ shape Open Açık Open… Aç… Optical Optik Optical Margin Alignment Optik Kenar Boşluğu Hizalaması + Dynamic Text Dinamik Metin +@msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames} Optimize for fast web view Hızlı web görünümü için optimize edin Optimized Optimize edilmiş Options Seçenekler @@ -2624,6 +2629,12 @@ digits Malayalam Malayalam Reset OpenType Features OpenType Özelliklerini Sıfırla Reset Panel Paneli Sıfırla Reset Preferences Tercihleri Sıfırla + Export Preferences… Tercihleri Dışa Aktar… + Import Preferences… Tercihleri İçe Aktar… + Save the applied preferences to a JSON file Uygulanan tercihleri bir JSON dosyasına kaydet + Apply the preferences in a file Export Preferences saved Tercihleri Dışa Aktar ile kaydedilen bir dosyadaki tercihleri uygula + Export Preferences Tercihleri Dışa Aktar + Import Preferences Tercihleri İçe Aktar Search Preferences Tercihlerde Ara No settings found Ayar bulunamadı Reset Profiles Profilleri Sıfırla @@ -2646,6 +2657,8 @@ digits Malayalam Malayalam Responsive duyarlı Restore Geri yükle Restore every preference to its default (applied on OK) Her tercihi varsayılana geri yükleyin (Tamam'a uygulanır) + Reset all preferences to their defaults? Tüm tercihler varsayılanlarına sıfırlansın mı? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Varsayılanlar, Tercihler'de Tamam'a tıkladığınızda uygulanır. Oradaki İptal, mevcut tercihlerinizi korur. Restore Recovered Documents Kurtarılan Belgeleri Geri Yükle Restored documents open unsaved: save them to keep them. Geri yüklenen belgeler kaydedilmeden açılır: saklamak için kaydedin. Restrict printing, editing and other tasks Yazdırmayı, düzenlemeyi ve diğer görevleri kısıtlayın @@ -2967,6 +2980,20 @@ digits Malayalam Malayalam Settings… Ayarlar… Setup… Kurulum… Shadows and Glows Gölgeler ve Parıltılar + Game HUD Oyun HUD + Health Fill Can + Mana Glow Mana + Damage Flash Hasar + Quest Outline Görev + Coin Shine Jeton + Boss Alert Boss + Status Effects Durum Etkileri + Poison Tint Zehir + Freeze Edge Donma + Burn Aura Yanma + Stun Pulse Sersemletme + Stealth Fade Gizlilik + Shield Buff Kalkan Shape Şekil Shape Builder Şekil Oluşturucu Shape Builder Regions Şekil Oluşturucu Bölgeleri @@ -3368,6 +3395,8 @@ digits Malayalam Malayalam tile {number} kiremit {number} Tiles are numbered across, then down; leave the range empty for every tile. Fayanslar önce baştan sonra aşağı doğru numaralandırılır; aralığı her döşeme için boş bırakın. Tint Renk tonu + Fill Tint Dolgu Renk Tonu + Stroke Tint Kontur Renk Tonu Tip extends past the end point İpucu bitiş noktasının ötesine uzanıyor Tip on the end point Bitiş noktasına dair ipucu Title Case Başlık Durumu @@ -3864,6 +3893,7 @@ axis Both Her İkisi Stop Script Betiği Durdur Show External Script Warning Harici Betik Uyarısını Göster A Script Is Running Bir Betik Çalışıyor + Script Alert Betik Uyarısı A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Bir betik açık belgeleri değiştirebilir ve çalıştırdığı komutlarla dosya yazabilir. Yalnızca güvendiğiniz betikleri çalıştırın. Do not show this warning again Bu uyarıyı bir daha gösterme Stop the script and undo what it did? Then quit again. Betik durdurulsun ve yaptıkları geri alınsın mı? Ardından yeniden çıkın. @@ -3877,6 +3907,9 @@ axis Both Her İkisi @msg the script is too large to run betik çalıştırılamayacak kadar büyük @msg a drag or an undo group is open; finish it before running a script bir sürükleme veya geri alma grubu açık; betiği çalıştırmadan önce tamamlayın @msg the script left a document that cannot be kept betik, saklanamayan bir belge bıraktı +@msg the script is no longer in the Scripts folder betik artık Scripts klasöründe değil + Some scripts were not listed Bazı komut dosyaları listelenmedi + The Scripts folder cannot be read Scripts klasörü okunamıyor @msg the script's run is no longer available betiğin çalıştırması artık kullanılamıyor @msg a failed command changed undo steps that could not be put back, so the script was stopped başarısız bir komut geri yüklenemeyen geri alma adımlarını değiştirdi, bu yüzden betik durduruldu @msg a recovery copy could not be written, so the script was not run kurtarma kopyası yazılamadı, bu nedenle betik çalıştırılmadı @@ -4034,6 +4067,7 @@ axis Both Her İkisi @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis "vertical", "horizontal" veya derece cinsinden bir açı olmalıdır, `{v}` değil @msg background {other}: transparent, white, black or a colour such as "#ff8800" arka plan {other}: şeffaf, beyaz, siyah veya "#ff8800" gibi bir renk @msg bad color {v} kötü renk {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` null, bir katman renginin adı veya #rrggbb olur @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 0,1 ile 10 arasında bir sayı olmalıdır @msg the Color Picker is not open Renk Seçici açık değil @msg the screen color sampler is already open ekran rengi örnekleyici zaten açık @@ -4095,6 +4129,11 @@ axis Both Her İkisi @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}`de dahili hata: {msg} (belge daha önce olduğu gibi bırakıldı; lütfen bu hatayı bildirin) @msg internal error: {msg} (please report this bug) dahili hata: {msg} (lütfen bu hatayı bildirin) @msg invalid PDF setting: {0} geçersiz PDF ayarı: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {unit} UserUnit değeriyle yazılır, bu yüzden UserUnit'i yok sayan uygulamalar onu o kadar kat küçük gösterir +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {label} sayfaları UserUnit olmadan, punto cinsinden yazılır ve sınıra uyan uygulamalar sayfayı 200 in'de keser +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5'te, 14.400 pt (200 in) uzunluğunu aşan bir sayfanın boyutunu koruması için gereken UserUnit yoktur: dosya PDF 1.6 olarak yazılır +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units {n} çalışma yüzeyinin sayfası {size}, PDF'nin 3 ile 14.400 birim arasındaki sayfa kenarları için fazla uzun ve dar: kısa kenarı en az 3 birimde tutan {unit} UserUnit değeriyle yazılır ve sınıra uyan uygulamalar uzun kenarı 14.400 birimde keser +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} dosyaları en fazla {max} değerinde gerçek sayı tutar ve {n} çalışma yüzeyinin {side} pt uzunluğundaki sayfası daha büyüklerini gerektiriyor: PDF/X standardı olmadan PDF 1.6 veya sonrasını ya da daha küçük bir çalışma yüzeyi seçin @msg invalid image data for `{0}` `{0}` için geçersiz resim verileri @msg invalid parameters for `{cmd}`: {msg} `{cmd}` için geçersiz parametreler: {msg} @msg is not an image VectorCraft reads VectorCraft'ın okuduğu bir resim değil @@ -4416,6 +4455,20 @@ axis Both Her İkisi @msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at) @msg unknown panel `{raw}` bilinmeyen panel `{raw}` @msg unknown preference `{key}` bilinmeyen tercih `{key}` +@msg Preferences imported. Tercihler içe aktarıldı. +@msg not a preferences file: {e} tercih dosyası değil: {e} +@msg not a preferences file: no JSON object tercih dosyası değil: JSON nesnesi yok +@msg not a preferences file (format `{format}`) tercih dosyası değil (biçim `{format}`) +@msg not a preferences file: no `format` tercih dosyası değil: `format` yok +@msg not a preferences file: no `version` tercih dosyası değil: `version` yok +@msg not a preferences file: no `prefs` tercih dosyası değil: `prefs` yok +@msg the file was saved by a newer version of VectorCraft (version {v}) dosya VectorCraft'ın daha yeni bir sürümüyle kaydedilmiş (sürüm {v}) +@msg the file holds no preferences dosyada tercih yok +@msg `{key}` names a folder, which preferences files leave out `{key}` bir klasör belirtir ve tercih dosyaları klasör içermez +@msg the file is larger than 1 MB dosya 1 MB'tan büyük +@msg give `path` or `data` `path` veya `data` verin +@msg `{key}` must be an object `{key}` bir nesne olmalıdır +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` tek başına çalışır, toplu iş adımı olarak değil @msg unknown preset (one of {_1}) bilinmeyen ön ayar ({_1}'den biri) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) bilinmeyen ön ayar `{name}` (yüksek, orta, düşük veya kaydedilmiş bir ayar: bkz. flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) bilinmeyen ön ayar `{name}` (bkz. düzleştirici.presets.list) @@ -4880,7 +4933,8 @@ lighting Light Işık Bevel both sides Her iki tarafa da eğim verin Presets Ön ayarlar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Bir nesne bir 3D efekti alır. Mevcut efektin seçeneklerini düzenleyin veya önce onu kaldırın. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} şu anda bir yol, bileşik yol, yazı veya grup gerektiriyor. Önce görüntüleri ve diğer nesneleri yollara dönüştürün. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} şu anda bir yol, bileşik yol, yazı, grup, karışım veya sembol örneği gerektiriyor. Önce diğer nesneleri genişletin. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} bir görüntüden katı cisim oluşturamaz: önce Nesne › Görüntü İzleme › Yap ile izleyin. @msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir @msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir @@ -5311,6 +5365,7 @@ file-filter Flattener presets Düzleştirici önayarları file-filter PDF presets PDF önayarları file-filter PDF-compatible .ai PDF ile uyumlu .ai file-filter Print presets Baskı önayarları +file-filter Preferences Tercihler file-filter SVG Compressed Sıkıştırılmış SVG file-filter Swatch libraries Renk örneği kitaplıkları file-filter Templates Şablonlar @@ -5348,6 +5403,7 @@ theme Appearance Görünüm Preview (1 inch on the page) Önizleme (sayfada 1 inç) Set as Default Varsayılan Olarak Ayarla out from a highlighted edge to place its dimension | boyutunu yerleştirmek için vurgulanan kenardan dışarı | + two points, then move and click to place an aligned, horizontal or vertical dimension | ile iki nokta seçin, ardından hizalı, yatay veya dikey bir boyut yerleştirmek için hareket ettirip tıklayın | cancels | Double-click the tool for options iptal | Seçenekler için araca çift tıklayın Angular Açısal Away from the Line Çizgiden Uzakta @@ -5377,13 +5433,16 @@ theme Appearance Görünüm @msg Invalid drawing scale Geçersiz çizim ölçeği @msg Invalid real scale Geçersiz gerçek ölçek @msg Measured anchor no longer exists Ölçülen çapa artık mevcut değil +@msg Invalid measured point Geçersiz ölçülen nokta @msg Measured segment no longer exists Ölçülen segment artık mevcut değil @msg Non-finite dimension Sonlu olmayan boyut +@msg Only linear dimensions are horizontal or vertical Yalnızca doğrusal boyutlar yatay veya dikey olabilir @msg Scale must be drawing:real, for example 1:10 Ölçek çizim olmalıdır:gerçek, örneğin 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Ölçek oranı 1:1000000 ile 1000000:1 arasında olmalıdır @msg Second measured segment no longer exists Ölçülen ikinci segment artık mevcut değil @msg Unknown dimension mode Bilinmeyen boyut modu @msg Unknown dimension option {k} Bilinmeyen boyut seçeneği {k} +@msg Unknown dimension orientation Bilinmeyen boyut yönü @msg Unknown dimension style Bilinmeyen boyut stili @msg Unknown unit Bilinmeyen birim Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Açık profillerin dairesel uçlarını kapatın. Kısmi süpürmeler kesik taraflarını açık tutar. @@ -5616,6 +5675,8 @@ theme Appearance Görünüm @msg invalid object id {value}: expected a non-negative integer geçersiz nesne kimliği {value}: negatif olmayan bir tam sayı bekleniyordu @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` içindeki {index} dizinli öğe {value}, negatif olmayan tam sayı bir nesne kimliği değil @msg no such object id {value} böyle bir nesne kimliği yok: {value} +@msg id {value} is a ruler guide, not an object bu kimlik bir nesneye değil, bir kılavuza ait: {value} +@msg a ruler guide goes only directly in a layer or sublayer bir kılavuz yalnızca doğrudan bir katmanda veya alt katmanda bulunabilir @msg id {value} is a layer, not an object {value} kimliği bir katmana ait, bir nesneye değil @msg id {value} is a layer, not an object; move layers with layer.move {value} kimliği bir katmana ait, bir nesneye değil; katmanları layer.move ile taşıyın @msg {key} must be Unix seconds (an integer) or null {key}, Unix saniyesi (bir tam sayı) veya null olmalıdır @@ -5919,6 +5980,20 @@ axis Y Axis Y ekseni Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma Keep settings of its own Kendi ayarlarını saklama Keep a cache of its own, which you can clear Temizleyebileceğiniz kendi önbelleğini saklama + Run these commands on the document: {ids} Belgede şu komutları çalıştırma: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}”, `commandAllowlist` içinde bir eklentinin çağıramayacağı `{id}` komutunu listeliyor +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: bildirimin `commandAllowlist` listesinde yok +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: kimlik veya bağımsız değişkenler modülün belleğinin dışında +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: kimlik veya bağımsız değişkenler UTF-8 değil +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: bağımsız değişkenler bir JSON nesnesi değil +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` yalnızca olağan bütçeli bir filtrede bildirilebilir +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) komut çağrıları (çalıştırma başına {MAX_CALLS} çağrı ve {MAX_CALL_BYTES} bayt) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) komut çağrısı boyutu (vc_call `{shown}`: {len} bayt, sınır {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` içinde en fazla {MAX_ALLOWED_COMMANDS} komut +@msg a plug-in may not call this command bir eklenti bu komutu çağıramaz +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` için `commands` izni, `commands` için de bir `commandAllowlist` gerekir +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist`, komut kimliği olmayan veya iki kez listelenen `{shown}` öğesini adlandırıyor Clear Plug-in Cache Eklenti Önbelleğini Temizle @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin @msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature} @@ -6008,6 +6083,23 @@ axis Y Axis Y ekseni Button Düğme Panel Panel UI Frames UI çerçeveleri + Map Markers Harita İşaretleri + Pin İğne + Flag Bayrak + Crosshair Nişangah + Checkpoint Kontrol noktası + Inventory Icons Envanter Simgeleri + Skill Icons Yetenek Simgeleri + Attack Saldırı + Defend Savunma + Heal İyileştirme + Buff Güçlendirme + Ult Ult + Sword Kılıç + Shield Kalkan + Potion İksir + Key Anahtar + Gem Mücevher Open Symbol Library Sembol Kitaplığını Aç Save Symbol Library Sembol Kitaplığını Kaydet Save Symbol Library… Sembol Kitaplığını Kaydet… @@ -6062,6 +6154,16 @@ axis Y Axis Y ekseni User Brush Library 9 Kullanıcı Fırça Kitaplığı 9 User Brush Library 10 Kullanıcı Fırça Kitaplığı 10 Art Ribbons Sanat Şeritleri + Zigzag Zigzag + Burst Ray Darbe ışını + Dash Trail Kesik iz + Speed Line Hız çizgisi + Game Trails Oyun izleri + Energy Beams Enerji Işınları + Laser Bolt Lazer Mermisi + Plasma Arc Plazma Yayı + Charge Wave Şarj Dalgası + Spark Trail Kıvılcım İzi Calligraphic Nibs Kaligrafi Uçları Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri @@ -6650,3 +6752,33 @@ breakText Glyphs Glifler Ignore All Tümünü Yoksay Add to Dictionary Sözlüğe Ekle No Suggestions Öneri Yok + Edit Custom Dictionary Özel Sözlüğü Düzenle + Entry: Giriş: +# Script Console + Script Console Betik Konsolu + Clear Script Console Betik Konsolunu Temizle + Copy Script Console Betik Konsolunu Kopyala + Copy All Tümünü Kopyala + Copy Selected Seçileni Kopyala + Finished Bitti + Ended with an error Bir hatayla sona erdi + Out of fuel Komut sınırına ulaşıldı + Ended at a host limit Bir ana bilgisayar sınırında sona erdi + Ended at a limit Bir sınırda sona erdi + Stopped Durduruldu + Ended by an internal fault Bir iç hata nedeniyle sona erdi + time unavailable süre kullanılamıyor + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} en yavaşlar: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} ana bilgisayar çağrısı +@plural {n} earlier line dropped|{n} earlier lines dropped {n} eski satır atıldı +@plural {n} line cut|{n} lines cut {n} satır kesildi +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli + +# Deferred plug-in effects + Refresh Deferred Effects Ertelenen Efektleri Yenile + Deferred Effects Ertelenen Efektler +@msg no plug-in effect is deferred ertelenen eklenti efekti yok +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} eklenti efekti ertelenerek açıldı. Efekt › Ertelenen Efektleri Yenile bunları çalıştırır. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3fcc681b5..acaf20b6b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -283,6 +283,7 @@ font-technology Variable Варіативні Average… Усереднити… Axis: Вісь: Background Color: Колір тла: + Guide Label: Мітка напрямних: Show Center Mark Показати центральну позначку Show Cross Hairs Показати перехрестя Show Video Safe Areas Показати безпечні зони відео @@ -1272,6 +1273,7 @@ file-filter Swatch libraries Бібліотеки зразків file-filter Flattener presets Набори зведення file-filter PDF presets Набори PDF file-filter Print presets Набори друку +file-filter Preferences Налаштування file-filter Export presets Набори параметрів експорту file-filter SVG Compressed Стиснутий SVG file-filter PDF-compatible .ai .ai, сумісний з PDF @@ -1678,6 +1680,7 @@ file-filter Templates Шаблони Include Linked Files Включати пов’язані файли Include Metadata Включати метадані Include non-printing layers Включати недруковані шари + Write pages longer than 200 in at full size in points Записувати сторінки, довші за 200 in, у повному розмірі, у пунктах Include SVG Code Вмикати код SVG Include Swatches for Tints Включати зразки для відтінків Inflate… Надування… @@ -1919,6 +1922,7 @@ file-filter Templates Шаблони Lock Grid Заблокувати сітку Lock Guides Заблокувати напрямні Lock Guides by Default Блокувати напрямні за замовчуванням + Artboard Guides with Cmd/Ctrl + Ruler Drag Напрямні монтажної області з Cmd/Ctrl + перетягуванням з лінійки Lock Others Заблокувати решту Lock Slices Заблокувати фрагменти Lock Station Point Заблокувати точку стояння @@ -2353,6 +2357,8 @@ file-filter Templates Шаблони Open… Відкрити… Optical Оптичний Optical Margin Alignment Оптичне вирівнювання полів + Dynamic Text Динамічний текст +@msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames} Optimize for fast web view Оптимізувати для швидкого перегляду web Optimized Оптимізовано Options Параметри @@ -2844,6 +2850,12 @@ digits Malayalam Малаялам Reset OpenType Features Скинути функції OpenType Reset Panel Скинути панель Reset Preferences Скинути налаштування + Export Preferences… Експортувати налаштування… + Import Preferences… Імпортувати налаштування… + Save the applied preferences to a JSON file Зберегти застосовані налаштування у файл JSON + Apply the preferences in a file Export Preferences saved Застосувати налаштування з файлу, збереженого командою «Експортувати налаштування» + Export Preferences Експортувати налаштування + Import Preferences Імпортувати налаштування Search Preferences Пошук у налаштуваннях No settings found Налаштування не знайдено Reset Profiles Скинути профілі @@ -2867,6 +2879,8 @@ digits Malayalam Малаялам Responsive Адаптивний Restore Відновити Restore every preference to its default (applied on OK) Відновити всі параметри за промовчанням (застосовується при натисканні ОК) + Reset all preferences to their defaults? Скинути всі налаштування до значень за промовчанням? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Значення за промовчанням застосовуються, коли ви натискаєте ОК у вікні «Налаштування». Скасувати там зберігає поточні налаштування. Restore Recovered Documents Відновити відновлені документи Restored documents open unsaved: save them to keep them. Відновлені документи відкриваються незбереженими: збережіть їх, щоб залишити. Restrict printing, editing and other tasks Обмежити друк, редагування та інші завдання @@ -3167,6 +3181,20 @@ digits Malayalam Малаялам Settings… Параметри… Setup… Налаштування… Shadows and Glows Тіні та свічення + Game HUD Ігровий HUD + Health Fill Здоров'я + Mana Glow Мана + Damage Flash Шкода + Quest Outline Квест + Coin Shine Монета + Boss Alert Бос + Status Effects Ефекти статусу + Poison Tint Отрута + Freeze Edge Заморозка + Burn Aura Опік + Stun Pulse Оглушення + Stealth Fade Невидимість + Shield Buff Щит Shape Фігура Shape Builder Створення фігур Shape Builder Regions Області створення фігур @@ -3587,6 +3615,8 @@ effect Points Точки Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою) Gutter: Проміжок: Tint Відтінок + Fill Tint Відтінок заливки + Stroke Tint Відтінок обведення Tip extends past the end point Кінчик виходить за кінцеву точку Tip on the end point Кінчик на кінцевій точці Title Case Як у заголовку @@ -4098,6 +4128,7 @@ axis Both Обидві Stop Script Зупинити скрипт Show External Script Warning Показувати попередження про зовнішні скрипти A Script Is Running Виконується скрипт + Script Alert Повідомлення скрипту A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт може змінювати відкриті документи й записувати файли за допомогою команд, які виконує. Запускайте лише ті скрипти, яким довіряєте. Do not show this warning again Більше не показувати це попередження Stop the script and undo what it did? Then quit again. Зупинити скрипт і скасувати його зміни? Потім вийдіть ще раз. @@ -4111,6 +4142,9 @@ axis Both Обидві @msg the script is too large to run скрипт завеликий для виконання @msg a drag or an undo group is open; finish it before running a script відкрито перетягування або групу скасування; завершіть їх перед запуском скрипта @msg the script left a document that cannot be kept скрипт залишив документ, який не можна зберегти +@msg the script is no longer in the Scripts folder скрипт більше не міститься в папці Scripts + Some scripts were not listed Деякі сценарії не показано + The Scripts folder cannot be read Не вдалося прочитати папку Scripts @msg the script's run is no longer available виконання скрипта більше не доступне @msg a failed command changed undo steps that could not be put back, so the script was stopped невдала команда змінила кроки скасування, які не вдалося відновити, тому скрипт зупинено @msg a recovery copy could not be written, so the script was not run не вдалося записати копію для відновлення, тому скрипт не виконано @@ -4295,6 +4329,7 @@ axis Both Обидві @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis має бути "vertical", "horizontal" або кутом у градусах, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black або колір, наприклад "#ff8800" @msg bad color {v} неприпустимий колір {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` має бути null, назвою кольору шару або #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio має бути числом від 0,1 до 10 @msg the Color Picker is not open палітра кольорів не відкрита @msg the Simplify slider is not open повзунок «Спростити» не відкрито @@ -4358,6 +4393,11 @@ axis Both Обидві @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) внутрішня помилка `{cmd}`: {msg} (документ залишений у попередньому стані; будь ласка, повідомте про цю помилку) @msg internal error: {msg} (please report this bug) внутрішня помилка: {msg} (будь ласка, повідомте про цю помилку) @msg invalid PDF setting: {0} неприпустимий параметр PDF: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): її записано з UserUnit {unit}, тому застосунки, які ігнорують UserUnit, показують її в стільки ж разів меншою +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): сторінки {label} записуються без UserUnit, у пунктах, і застосунки, що дотримуються обмеження, обрізають сторінку до 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 у PDF 1.5 немає UserUnit, який потрібен сторінці, довшій за 14 400 pt (200 in), щоб зберегти розмір: файл записується як PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units сторінка монтажної області {n} має розмір {size} і задовга та завузька для сторін сторінки PDF від 3 до 14 400 одиниць: її записано з UserUnit {unit}, що тримає коротку сторону не менше 3 одиниць, а застосунки, які дотримуються межі, обрізають довгу сторону до 14 400 одиниць +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard файли {label} вміщують дійсні числа до {max}, а сторінці монтажної області {n} завдовжки {side} pt потрібні більші: виберіть PDF 1.6 або новіший без стандарту PDF/X чи меншу монтажну область @msg invalid image data for `{0}` неприпустимі дані зображення для `{0}` @msg invalid parameters for `{cmd}`: {msg} неприпустимі параметри для `{cmd}`: {msg} @msg is not an image VectorCraft reads не є зображенням, яке читає VectorCraft @@ -4695,6 +4735,20 @@ axis Both Обидві @msg unknown overprints `{o}` (preserve, simulate or discard) невідомі накладення `{o}` (preserve, simulate або discard) @msg unknown panel `{raw}` невідома панель `{raw}` @msg unknown preference `{key}` невідоме налаштування `{key}` +@msg Preferences imported. Налаштування імпортовано. +@msg not a preferences file: {e} не є файлом налаштувань: {e} +@msg not a preferences file: no JSON object не є файлом налаштувань: немає об’єкта JSON +@msg not a preferences file (format `{format}`) не є файлом налаштувань (формат `{format}`) +@msg not a preferences file: no `format` не є файлом налаштувань: немає `format` +@msg not a preferences file: no `version` не є файлом налаштувань: немає `version` +@msg not a preferences file: no `prefs` не є файлом налаштувань: немає `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) файл збережено новішою версією VectorCraft (версія {v}) +@msg the file holds no preferences файл не містить налаштувань +@msg `{key}` names a folder, which preferences files leave out `{key}` вказує теку, а файли налаштувань не містять тек +@msg the file is larger than 1 MB файл більший за 1 МБ +@msg give `path` or `data` вкажіть `path` або `data` +@msg `{key}` must be an object `{key}` має бути об’єктом +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` виконується окремо, а не як крок пакета @msg unknown preset (one of {_1}) невідомий набір (один із: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) невідомий набір `{name}` (high, medium, low або збережений: див. flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) невідомий набір `{name}` (див. flattener.presets.list) @@ -4901,6 +4955,8 @@ axis Both Обидві @msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} у `{key}` дорівнює {value}, а не ідентифікатору об’єкта (невід’ємному цілому числу) @msg no such object id {value} об’єкт з ідентифікатором {value} не існує +@msg id {value} is a ruler guide, not an object ідентифікатор {value} належить напрямній, а не об’єкту +@msg a ruler guide goes only directly in a layer or sublayer напрямна може бути лише безпосередньо в шарі або підшарі @msg id {value} is a layer, not an object ідентифікатор {value} належить шару, а не об’єкту @msg id {value} is a layer, not an object; move layers with layer.move ідентифікатор {value} належить шару, а не об’єкту; переміщуйте шари за допомогою layer.move @msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path} @@ -5198,7 +5254,8 @@ lighting Light Світло Bevel both sides Фаска з обох боків Presets Набори @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Об'єкт може мати лише один 3D-ефект. Змініть параметри наявного ефекту або спершу вилучіть його. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: поки що потрібен контур, складений контур, текст або група. Спершу перетворіть зображення та інші об'єкти на контури. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: поки що потрібен контур, складений контур, текст, група, перехід або екземпляр символу. Спершу розберіть інші об'єкти. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: із зображення не можна побудувати тіло. Спершу трасуйте його командою Об’єкт › Трасування зображення › Створити. @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру @@ -5257,6 +5314,7 @@ lighting Light Світло Preview (1 inch on the page) Попередній перегляд (1 дюйм на сторінці) Set as Default Встановити за замовчуванням out from a highlighted edge to place its dimension | назовні від підсвіченого краю, щоб розмістити його розмір | + two points, then move and click to place an aligned, horizontal or vertical dimension | по двох точках, потім перемістіть і клацніть, щоб розмістити вирівняний, горизонтальний або вертикальний розмір | cancels | Double-click the tool for options скасовує | Подвійне клацання по інструменту відкриває параметри Linear Лінійний Angular Кутовий @@ -5289,14 +5347,17 @@ lighting Light Світло @msg Invalid drawing scale Неприпустимий масштаб креслення @msg Invalid real scale Неприпустимий реальний масштаб @msg Measured anchor no longer exists Виміряна опорна точка більше не існує +@msg Invalid measured point Недійсна виміряна точка @msg Measured segment no longer exists Виміряний сегмент більше не існує @msg Non-finite dimension Розмір не є скінченним числом +@msg Only linear dimensions are horizontal or vertical Лише лінійні розміри бувають горизонтальними або вертикальними @msg Scale Text Attributes: selected type has no scale to bake Масштабувати атрибути тексту: у вибраного тексту немає масштабу для застосування @msg Scale must be drawing:real, for example 1:10 Масштаб має бути у вигляді креслення:реальність, наприклад 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Співвідношення масштабу має бути від 1:1000000 до 1000000:1 @msg Second measured segment no longer exists Другий виміряний сегмент більше не існує @msg Unknown dimension mode Невідомий режим розміру @msg Unknown dimension option {k} Невідомий параметр розміру {k} +@msg Unknown dimension orientation Невідома орієнтація розміру @msg Unknown dimension style Невідомий стиль розміру @msg Unknown unit Невідома одиниця # Revolve cap description @@ -5560,6 +5621,20 @@ axis Y Axis Вісь Y Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу Keep settings of its own Зберігати власні налаштування Keep a cache of its own, which you can clear Зберігати власний кеш, який можна очистити + Run these commands on the document: {ids} Виконувати в документі ці команди: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call «{name}» вказує в `commandAllowlist` команду `{id}`, яку модуль викликати не може +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: у `commandAllowlist` маніфесту її немає +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID або аргументи лежать поза пам’яттю модуля +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID або аргументи не в UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: аргументи не є об’єктом JSON +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` може оголошувати лише фільтр зі звичайним бюджетом +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) виклики команд ({MAX_CALLS} викликів і {MAX_CALL_BYTES} байтів за запуск) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) розмір виклику команди (vc_call `{shown}`: {len} байтів, межа {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` щонайбільше {MAX_ALLOWED_COMMANDS} команд у `commandAllowlist` +@msg a plug-in may not call this command модуль не може викликати цю команду +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` для `commandAllowlist` потрібен дозвіл `commands`, а для `commands` потрібен `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` вказує `{shown}`, що не є ID команди або вказане двічі Clear Plug-in Cache Очистити кеш модуля @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `{permission}` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature} @@ -5649,6 +5724,23 @@ axis Y Axis Вісь Y Button Кнопка Panel Панель UI Frames UI-рамки + Map Markers Маркери карти + Pin Шпилька + Flag Прапор + Crosshair Приціл + Checkpoint Контрольна точка + Inventory Icons Піктограми інвентарю + Skill Icons Іконки навичок + Attack Атака + Defend Захист + Heal Лікування + Buff Посилення + Ult Ульт + Sword Меч + Shield Щит + Potion Зілля + Key Ключ + Gem Самоцвіт Open Symbol Library Відкрити бібліотеку символів Save Symbol Library Зберегти бібліотеку символів Save Symbol Library… Зберегти бібліотеку символів… @@ -5703,6 +5795,16 @@ axis Y Axis Вісь Y User Brush Library 9 Користувацька бібліотека пензлів 9 User Brush Library 10 Користувацька бібліотека пензлів 10 Art Ribbons Художні стрічки + Zigzag Зигзаг + Burst Ray Ударний промінь + Dash Trail Пунктирний слід + Speed Line Лінія швидкості + Game Trails Ігрові сліди + Energy Beams Енергетичні промені + Laser Bolt Лазерний заряд + Plasma Arc Плазмова дуга + Charge Wave Хвиля заряду + Spark Trail Слід іскор Calligraphic Nibs Каліграфічні пера Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури @@ -6295,3 +6397,33 @@ breakText Glyphs Гліфи Ignore All Пропустити все Add to Dictionary Додати до словника No Suggestions Немає варіантів + Edit Custom Dictionary Редагувати словник користувача + Entry: Запис: +# Script Console + Script Console Консоль скриптів + Clear Script Console Очистити консоль скриптів + Copy Script Console Копіювати консоль скриптів + Copy All Копіювати все + Copy Selected Копіювати виділене + Finished Завершено + Ended with an error Завершено з помилкою + Out of fuel Вичерпано ліміт інструкцій + Ended at a host limit Завершено на ліміті хоста + Ended at a limit Завершено на ліміті + Stopped Зупинено + Ended by an internal fault Завершено через внутрішню помилку + time unavailable час недоступний + {ms} ms {ms} мс + {time}, {calls} {time}, {calls} + slowest: {commands} найповільніші: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} виклик хоста|{n} виклики хоста|{n} викликів хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} попередній рядок відкинуто|{n} попередні рядки відкинуто|{n} попередніх рядків відкинуто +@plural {n} line cut|{n} lines cut {n} рядок обрізано|{n} рядки обрізано|{n} рядків обрізано +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last` + +# Deferred plug-in effects + Refresh Deferred Effects Оновити відкладені ефекти + Deferred Effects Відкладені ефекти +@msg no plug-in effect is deferred немає відкладених ефектів модулів +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Відкрито з {n} відкладеним ефектом модуля. Ефект › Оновити відкладені ефекти виконає його.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5151b4a57..4a2bbd5a6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -312,6 +312,7 @@ font-technology Variable Biến thiên Axis: Trục: Back Sau Background Color: Màu nền: + Guide Label: Nhãn đường căn: Show Center Mark Hiện dấu tâm Show Cross Hairs Hiện đường chữ thập Show Video Safe Areas Hiện vùng an toàn video @@ -1475,6 +1476,7 @@ file-filter Swatch libraries Thư viện mẫu màu file-filter Flattener presets Thiết lập sẵn làm phẳng file-filter PDF presets Thiết lập sẵn PDF file-filter Print presets Thiết lập sẵn in +file-filter Preferences Tùy chọn file-filter Export presets Cài đặt sẵn xuất file-filter SVG Compressed SVG nén file-filter PDF-compatible .ai .ai tương thích PDF @@ -1948,6 +1950,7 @@ digits Hindi Hindi Include Linked Files Bao gồm tệp được liên kết Include Metadata Bao gồm siêu dữ liệu Include non-printing layers Bao gồm các lớp không in + Write pages longer than 200 in at full size in points Ghi các trang dài hơn 200 in ở kích thước đầy đủ, theo điểm Include SVG Code Bao gồm mã SVG Include Swatches for Tints Bao gồm mẫu màu cho sắc pha Increase Font Size Tăng cỡ chữ @@ -2203,6 +2206,7 @@ digits Hindi Hindi Lock Grid Khóa lưới Lock Guides Khóa đường căn Lock Guides by Default Khóa đường căn theo mặc định + Artboard Guides with Cmd/Ctrl + Ruler Drag Đường căn bảng vẽ với Cmd/Ctrl + kéo từ thước Lock Others Khóa các mục khác Lock Slices Khóa lát cắt Lock Station Point Khóa điểm trạm @@ -2631,6 +2635,8 @@ digits Hindi Hindi Open… Mở… Optical Quang học Optical Margin Alignment Căn lề quang học + Dynamic Text Văn bản động +@msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames} Optimize for fast web view Tối ưu để xem web nhanh Optimized Đã tối ưu Options Tùy chọn @@ -3125,6 +3131,12 @@ mark-style Japanese Kiểu Nhật Reset OpenType Features Đặt lại tính năng OpenType Reset Panel Đặt lại bảng Reset Preferences Đặt lại tùy chọn + Export Preferences… Xuất tùy chọn… + Import Preferences… Nhập tùy chọn… + Save the applied preferences to a JSON file Lưu các tùy chọn đang áp dụng vào tệp JSON + Apply the preferences in a file Export Preferences saved Áp dụng các tùy chọn trong tệp do Xuất tùy chọn lưu + Export Preferences Xuất tùy chọn + Import Preferences Nhập tùy chọn Search Preferences Tìm trong tùy chọn No settings found Không tìm thấy cài đặt Reset Profiles Đặt lại hồ sơ màu @@ -3158,6 +3170,8 @@ mark-style Japanese Kiểu Nhật Responsive Đáp ứng Restore Khôi phục Restore every preference to its default (applied on OK) Khôi phục tất cả tùy chọn về mặc định (áp dụng khi nhấn OK) + Reset all preferences to their defaults? Đặt lại tất cả tùy chọn về mặc định? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Giá trị mặc định được áp dụng khi bạn nhấn OK trong Tùy chọn. Hủy ở đó giữ nguyên các tùy chọn hiện tại của bạn. Restore Recovered Documents Khôi phục tài liệu đã phục hồi Restored documents open unsaved: save them to keep them. Tài liệu đã khôi phục mở ở trạng thái chưa lưu: hãy lưu để giữ lại. Restrict printing, editing and other tasks Hạn chế in, chỉnh sửa và các tác vụ khác @@ -3499,6 +3513,20 @@ mark-style Japanese Kiểu Nhật Setup… Thiết lập… Shading Tô bóng Shadows and Glows Bóng và ánh sáng + Game HUD HUD trò chơi + Health Fill Máu + Mana Glow Mana + Damage Flash Sát thương + Quest Outline Nhiệm vụ + Coin Shine Xu + Boss Alert Trùm + Status Effects Hiệu ứng trạng thái + Poison Tint Độc + Freeze Edge Đóng băng + Burn Aura Bỏng + Stun Pulse Choáng + Stealth Fade Ẩn thân + Shield Buff Khiên Shape Hình dạng Shape Builder Tạo hình dạng Shape Builder Regions Vùng Tạo hình dạng @@ -3934,6 +3962,8 @@ mark-style Japanese Kiểu Nhật Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới) Gutter: Khoảng cách: Tint Tông màu + Fill Tint Tông màu tô + Stroke Tint Tông màu nét Tints Sắc pha Tints and Shades Sắc pha và bóng Tips Mẹo @@ -4465,6 +4495,7 @@ theme Appearance Giao diện @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis phải là "vertical", "horizontal" hoặc góc theo độ, không phải `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black hoặc màu như "#ff8800" @msg bad color {v} màu không hợp lệ {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` là null, tên một màu lớp hoặc #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio phải là một số từ 0,1 đến 10 @msg the Color Picker is not open Bộ chọn màu chưa được mở @msg the Simplify slider is not open Thanh trượt Đơn giản hóa chưa được mở @@ -4516,6 +4547,7 @@ theme Appearance Giao diện Stop Script Dừng tập lệnh Show External Script Warning Hiện cảnh báo tập lệnh bên ngoài A Script Is Running Một tập lệnh đang chạy + Script Alert Thông báo của tập lệnh A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Tập lệnh có thể thay đổi các tài liệu đang mở và ghi tệp thông qua các lệnh mà nó chạy. Chỉ chạy những tập lệnh bạn tin cậy. Do not show this warning again Không hiện lại cảnh báo này Stop the script and undo what it did? Then quit again. Dừng tập lệnh và hoàn tác những gì nó đã làm? Sau đó hãy thoát lại. @@ -4529,6 +4561,9 @@ theme Appearance Giao diện @msg the script is too large to run tập lệnh quá lớn để chạy @msg a drag or an undo group is open; finish it before running a script đang có thao tác kéo hoặc nhóm hoàn tác; hãy hoàn thành trước khi chạy tập lệnh @msg the script left a document that cannot be kept tập lệnh để lại một tài liệu không thể giữ +@msg the script is no longer in the Scripts folder tập lệnh không còn trong thư mục Scripts + Some scripts were not listed Một số tập lệnh không được liệt kê + The Scripts folder cannot be read Không thể đọc thư mục Scripts @msg the script's run is no longer available lần chạy tập lệnh không còn khả dụng @msg a failed command changed undo steps that could not be put back, so the script was stopped một lệnh thất bại đã thay đổi các bước hoàn tác không thể khôi phục, nên tập lệnh đã bị dừng @msg a recovery copy could not be written, so the script was not run không thể ghi bản sao khôi phục nên tập lệnh không được chạy @@ -4617,6 +4652,11 @@ theme Appearance Giao diện @msg Invalid panel layout; restored the default dock Bố cục bảng không hợp lệ; đã phục hồi dock mặc định @msg invalid parameters for `{cmd}`: {msg} tham số không hợp lệ cho `{cmd}`: {msg} @msg invalid PDF setting: {0} cài đặt PDF không hợp lệ: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): trang được ghi với UserUnit là {unit}, nên các ứng dụng bỏ qua UserUnit sẽ hiển thị nó nhỏ đi bấy nhiêu lần +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): các trang {label} được ghi không có UserUnit, theo đơn vị điểm, và các ứng dụng tuân theo giới hạn sẽ cắt trang ở 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 không có UserUnit, thứ mà trang dài hơn 14.400 pt (200 in) cần để giữ kích thước: tệp được ghi dưới dạng PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units trang của bảng vẽ {n} có kích thước {size}, quá dài và hẹp so với cạnh trang PDF từ 3 đến 14.400 đơn vị: trang được ghi với UserUnit {unit}, giữ cạnh ngắn từ 3 đơn vị trở lên, và các ứng dụng tuân theo giới hạn sẽ cắt cạnh dài ở 14.400 đơn vị +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard tệp {label} chỉ chứa số thực tối đa {max}, còn trang của bảng vẽ {n} dài {side} pt cần số lớn hơn: hãy chọn PDF 1.6 trở lên không theo tiêu chuẩn PDF/X, hoặc một bảng vẽ nhỏ hơn @msg invalid restored panel group nhóm bảng được phục hồi không hợp lệ @msg is not an image VectorCraft reads không phải ảnh mà VectorCraft đọc được @msg it ends inside a group or compound path nó kết thúc bên trong một nhóm hoặc đường dẫn phức @@ -4840,6 +4880,8 @@ theme Appearance Giao diện @msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm @msg `{key}` entry {index} is {value}, not a non-negative integer object id mục {index} của `{key}` là {value}, không phải id đối tượng (số nguyên không âm) @msg no such object id {value} không có đối tượng nào có id {value} +@msg id {value} is a ruler guide, not an object id {value} là của đường căn, không phải của đối tượng +@msg a ruler guide goes only directly in a layer or sublayer đường căn chỉ có thể nằm trực tiếp trong lớp hoặc lớp con @msg id {value} is a layer, not an object id {value} là một lớp, không phải đối tượng @msg id {value} is a layer, not an object; move layers with layer.move id {value} là một lớp, không phải đối tượng; hãy di chuyển lớp bằng layer.move @msg object {0} cannot have children đối tượng {0} không thể có phần tử con @@ -5147,6 +5189,20 @@ theme Appearance Giao diện @msg missing `{path}` thiếu `{path}` @msg `{path}` must be {expected}, not {got} `{path}` phải là {expected}, không phải {got} @msg unknown preference `{key}` tùy chọn không xác định `{key}` +@msg Preferences imported. Đã nhập tùy chọn. +@msg not a preferences file: {e} không phải tệp tùy chọn: {e} +@msg not a preferences file: no JSON object không phải tệp tùy chọn: không có đối tượng JSON +@msg not a preferences file (format `{format}`) không phải tệp tùy chọn (định dạng `{format}`) +@msg not a preferences file: no `format` không phải tệp tùy chọn: thiếu `format` +@msg not a preferences file: no `version` không phải tệp tùy chọn: thiếu `version` +@msg not a preferences file: no `prefs` không phải tệp tùy chọn: thiếu `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) tệp được lưu bằng phiên bản VectorCraft mới hơn (phiên bản {v}) +@msg the file holds no preferences tệp không chứa tùy chọn nào +@msg `{key}` names a folder, which preferences files leave out `{key}` chỉ định một thư mục, và tệp tùy chọn không chứa thư mục +@msg the file is larger than 1 MB tệp lớn hơn 1 MB +@msg give `path` or `data` hãy cung cấp `path` hoặc `data` +@msg `{key}` must be an object `{key}` phải là một đối tượng +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` chạy riêng, không phải là một bước trong lô @msg unknown preset (one of {_1}) thiết lập sẵn không xác định (một trong {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) thiết lập sẵn không xác định `{name}` (high, medium, low hoặc thiết lập đã lưu: xem flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) thiết lập sẵn không xác định `{name}` (xem flattener.presets.list) @@ -5198,7 +5254,8 @@ theme Appearance Giao diện @msg {C}: no effect at index {from} {C}: không có hiệu ứng tại chỉ mục {from} @msg {depth}-bit PSD files in this colour mode can't be read tệp PSD {depth}-bit ở chế độ màu này không thể đọc được @msg {key} must be a finite coordinate {key} phải là tọa độ hữu hạn -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} hiện yêu cầu một đường dẫn, đường dẫn phức, văn bản hoặc nhóm. Hãy chuyển hình ảnh và các đối tượng khác thành đường dẫn trước. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} hiện yêu cầu một đường dẫn, đường dẫn phức, văn bản, nhóm, hòa trộn hoặc thực thể ký hiệu. Hãy mở rộng các đối tượng khác trước. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} không thể tạo khối từ hình ảnh: hãy vector hóa ảnh trước bằng Đối tượng › Vector hóa ảnh › Tạo. @msg {label} produced an empty result {label} tạo ra kết quả trống @msg {label} requires a shape with at least two anchors {label} yêu cầu một hình dạng có ít nhất hai điểm neo @msg {label}: lost track of a shape's style {label}: mất dấu kiểu của hình dạng @@ -5260,6 +5317,7 @@ theme Appearance Giao diện Preview (1 inch on the page) Xem trước (1 inch trên trang) Set as Default Đặt làm mặc định out from a highlighted edge to place its dimension | ra ngoài từ một cạnh được tô sáng để đặt kích thước của nó | + two points, then move and click to place an aligned, horizontal or vertical dimension | hai điểm, rồi di chuyển và bấm để đặt kích thước căn theo hai điểm, nằm ngang hoặc thẳng đứng | cancels | Double-click the tool for options để hủy | Nhấp đúp vào công cụ để mở tùy chọn Linear Tuyến tính Angular Góc @@ -5290,14 +5348,17 @@ theme Appearance Giao diện @msg Invalid drawing scale Tỷ lệ bản vẽ không hợp lệ @msg Invalid real scale Tỷ lệ thực không hợp lệ @msg Measured anchor no longer exists Điểm neo được đo không còn tồn tại +@msg Invalid measured point Điểm được đo không hợp lệ @msg Measured segment no longer exists Đoạn được đo không còn tồn tại @msg Non-finite dimension Kích thước không phải là số hữu hạn +@msg Only linear dimensions are horizontal or vertical Chỉ kích thước tuyến tính mới nằm ngang hoặc thẳng đứng @msg Scale Text Attributes: selected type has no scale to bake Tỷ lệ thuộc tính văn bản: văn bản được chọn không có tỷ lệ để áp dụng @msg Scale must be drawing:real, for example 1:10 Tỷ lệ phải có dạng bản vẽ:thực tế, ví dụ 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Tỷ lệ phải nằm trong khoảng 1:1000000 đến 1000000:1 @msg Second measured segment no longer exists Đoạn được đo thứ hai không còn tồn tại @msg Unknown dimension mode Chế độ kích thước không xác định @msg Unknown dimension option {k} Tùy chọn kích thước không xác định {k} +@msg Unknown dimension orientation Hướng kích thước không xác định @msg Unknown dimension style Kiểu kích thước không xác định @msg Unknown unit Đơn vị không xác định # Revolve cap description @@ -5561,6 +5622,20 @@ axis Y Axis Trục Y Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký Keep settings of its own Lưu cài đặt riêng Keep a cache of its own, which you can clear Lưu bộ nhớ đệm riêng mà bạn có thể xóa + Run these commands on the document: {ids} Chạy các lệnh này trên tài liệu: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” liệt kê `{id}` trong `commandAllowlist`, lệnh mà plug-in không được gọi +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: `commandAllowlist` của manifest không liệt kê lệnh này +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID hoặc đối số nằm ngoài bộ nhớ của mô-đun +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID hoặc đối số không phải UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: đối số không phải là đối tượng JSON +@msg only a filter with the usual budget declares `commandAllowlist` chỉ bộ lọc có ngân sách thông thường mới khai báo `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) lệnh gọi lệnh ({MAX_CALLS} lần gọi và {MAX_CALL_BYTES} byte mỗi lần chạy) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) kích thước lệnh gọi lệnh (vc_call `{shown}`: {len} byte, giới hạn {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` tối đa {MAX_ALLOWED_COMMANDS} lệnh trong `commandAllowlist` +@msg a plug-in may not call this command plug-in không được gọi lệnh này +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` cần quyền `commands`, và `commands` cần một `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` nêu `{shown}`, không phải ID lệnh hoặc được liệt kê hai lần Clear Plug-in Cache Xóa bộ nhớ đệm của plug-in @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `{permission}` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature} @@ -5650,6 +5725,23 @@ axis Y Axis Trục Y Button Nút Panel Bảng UI Frames Khung UI + Map Markers Đánh dấu bản đồ + Pin Ghim + Flag Cờ + Crosshair Tâm ngắm + Checkpoint Điểm kiểm tra + Inventory Icons Biểu tượng túi đồ + Skill Icons Biểu tượng kỹ năng + Attack Tấn công + Defend Phòng thủ + Heal Hồi máu + Buff Buff + Ult Ult + Sword Kiếm + Shield Khiên + Potion Thuốc + Key Chìa khóa + Gem Ngọc Open Symbol Library Mở thư viện ký hiệu Save Symbol Library Lưu thư viện ký hiệu Save Symbol Library… Lưu thư viện ký hiệu… @@ -5704,6 +5796,16 @@ axis Y Axis Trục Y User Brush Library 9 Thư viện cọ người dùng 9 User Brush Library 10 Thư viện cọ người dùng 10 Art Ribbons Dải băng nghệ thuật + Zigzag Zigzag + Burst Ray Tia va chạm + Dash Trail Vệt đứt đoạn + Speed Line Đường tốc độ + Game Trails Vệt game + Energy Beams Chùm năng lượng + Laser Bolt Tia laser + Plasma Arc Cung plasma + Charge Wave Sóng nạp + Spark Trail Vệt tia lửa Calligraphic Nibs Ngòi bút thư pháp Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán @@ -6296,3 +6398,33 @@ breakText Glyphs Glyph Ignore All Bỏ qua tất cả Add to Dictionary Thêm vào từ điển No Suggestions Không có gợi ý + Edit Custom Dictionary Chỉnh sửa từ điển tùy chỉnh + Entry: Mục nhập: +# Script Console + Script Console Bảng điều khiển tập lệnh + Clear Script Console Xóa bảng điều khiển tập lệnh + Copy Script Console Sao chép bảng điều khiển tập lệnh + Copy All Sao chép tất cả + Copy Selected Sao chép mục đã chọn + Finished Đã xong + Ended with an error Kết thúc do lỗi + Out of fuel Đã hết giới hạn lệnh + Ended at a host limit Kết thúc ở giới hạn của máy chủ + Ended at a limit Kết thúc ở giới hạn + Stopped Đã dừng + Ended by an internal fault Kết thúc do lỗi nội bộ + time unavailable không có thời gian + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} chậm nhất: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} lệnh gọi máy chủ +@plural {n} earlier line dropped|{n} earlier lines dropped đã bỏ {n} dòng cũ +@plural {n} line cut|{n} lines cut đã cắt {n} dòng +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last` + +# Deferred plug-in effects + Refresh Deferred Effects Làm mới hiệu ứng bị hoãn + Deferred Effects Hiệu ứng bị hoãn +@msg no plug-in effect is deferred không có hiệu ứng plug-in nào bị hoãn +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Đã mở với {n} hiệu ứng plug-in bị hoãn. Hiệu ứng › Làm mới hiệu ứng bị hoãn sẽ chạy chúng. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e527dbb1f..0eb7c7ebe 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -235,6 +235,7 @@ antiAlias Sharp 锐利 Average… 平均… Axis: 轴: Background Color: 背景颜色: + Guide Label: 参考线标签: Show Center Mark 显示中心标记 Show Cross Hairs 显示十字线 Show Video Safe Areas 显示视频安全区域 @@ -1216,6 +1217,7 @@ file-filter Swatch libraries 色板库 file-filter Flattener presets 拼合预设 file-filter PDF presets PDF 预设 file-filter Print presets 打印预设 +file-filter Preferences 首选项 file-filter Export presets 导出预设 file-filter SVG Compressed 压缩 SVG file-filter PDF-compatible .ai PDF 兼容 .ai @@ -1611,6 +1613,7 @@ file-filter Templates 模板 Include Linked Files 包含链接文件 Include Metadata 包含元数据 Include non-printing layers 包含非打印图层 + Write pages longer than 200 in at full size in points 以实际尺寸(点)写入长于 200 in 的页面 Include SVG Code 包含 SVG 代码 Include Swatches for Tints 包含淡色色板 Inflate… 膨胀… @@ -1806,6 +1809,7 @@ file-filter Templates 模板 Lock Grid 锁定网格 Lock Guides 锁定参考线 Lock Guides by Default 默认锁定参考线 + Artboard Guides with Cmd/Ctrl + Ruler Drag 按住 Cmd/Ctrl 从标尺拖动创建画板参考线 Lock Slices 锁定切片 Lock Station Point 锁定基准点 Lossless (lossy WebP isn't available yet) 无失真 (尚未提供有失真的 WebP) @@ -2229,6 +2233,8 @@ file-filter Templates 模板 Open… 打开… Optical 视觉 Optical Margin Alignment 视觉边界对齐 + Dynamic Text 动态文本 +@msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames} Optimize for fast web view 优化快速 Web 查看 Optimized 优化 Options 选项 @@ -2718,6 +2724,12 @@ digits Malayalam 马拉雅拉姆数字 Reset OpenType Features 重置 OpenType 特性 Reset Panel 重置面板 Reset Preferences 重置首选项 + Export Preferences… 导出首选项… + Import Preferences… 导入首选项… + Save the applied preferences to a JSON file 将已应用的首选项保存到 JSON 文件 + Apply the preferences in a file Export Preferences saved 应用“导出首选项”所保存文件中的首选项 + Export Preferences 导出首选项 + Import Preferences 导入首选项 Search Preferences 搜索首选项 No settings found 未找到设置 Reset Profiles 重置配置文件 @@ -2740,6 +2752,8 @@ digits Malayalam 马拉雅拉姆数字 Responsive 响应式 Restore 恢复 Restore every preference to its default (applied on OK) 把所有首选项恢复为默认值 (点击确定后应用) + Reset all preferences to their defaults? 将所有首选项重置为默认值? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 在首选项中点击确定后才会应用默认值。在那里点击取消会保留当前首选项。 Restore Recovered Documents 恢复已恢复的文档 Restored documents open unsaved: save them to keep them. 恢复的文档处于未存储状态:请存储以保留。 Restrict printing, editing and other tasks 限制打印、编辑及其他操作 @@ -3005,6 +3019,20 @@ digits Malayalam 马拉雅拉姆数字 Settings… 设置… Setup… 设置… Shadows and Glows 阴影与光晕 + Game HUD 游戏 HUD + Health Fill 生命 + Mana Glow 法力 + Damage Flash 伤害 + Quest Outline 任务 + Coin Shine 金币 + Boss Alert 首领 + Status Effects 状态效果 + Poison Tint 中毒 + Freeze Edge 冰冻 + Burn Aura 灼烧 + Stun Pulse 眩晕 + Stealth Fade 隐身 + Shield Buff 护盾 Shape 形状 Shape Builder 形状生成器 Shape Builder Regions 形状生成器区域 @@ -3420,6 +3448,8 @@ digits Malayalam 马拉雅拉姆数字 Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。 Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列) Tint 色调 + Fill Tint 填色色调 + Stroke Tint 描边色调 Tip extends past the end point 箭头超出端点 Tip on the end point 箭头位于端点 Title Case 词首大写 @@ -4141,6 +4171,20 @@ imageTrace Outline 轮廓 @msg `{key}` must be between {min} and {max} (got {x}) `{key}` 必须介于 {min} 和 {max} 之间 (实际为 {x}) @msg `{key}` must be one of {_1} `{key}` 必须是 {_1} 之一 @msg unknown preference `{key}` 未知首选项 `{key}` +@msg Preferences imported. 已导入首选项。 +@msg not a preferences file: {e} 不是首选项文件:{e} +@msg not a preferences file: no JSON object 不是首选项文件:没有 JSON 对象 +@msg not a preferences file (format `{format}`) 不是首选项文件(格式 `{format}`) +@msg not a preferences file: no `format` 不是首选项文件:没有 `format` +@msg not a preferences file: no `version` 不是首选项文件:没有 `version` +@msg not a preferences file: no `prefs` 不是首选项文件:没有 `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) 该文件由更新版本的 VectorCraft 保存(版本 {v}) +@msg the file holds no preferences 该文件不包含首选项 +@msg `{key}` names a folder, which preferences files leave out `{key}` 指定的是文件夹,首选项文件不包含文件夹 +@msg the file is larger than 1 MB 文件大于 1 MB +@msg give `path` or `data` 请提供 `path` 或 `data` +@msg `{key}` must be an object `{key}` 必须是对象 +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` 需单独运行,不能作为批处理步骤 @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 数据恢复没有可保存副本的位置:请设置“首选项 → 文件处理 → 数据恢复 → 文件夹” (recoveryFolder) @msg Data Recovery can't keep copies in {path}: {e} 数据恢复无法将副本保存到 {path}:{e} @msg Plug-ins can't be loaded from {path}: {e} 无法从 {path} 载入增效工具:{e} @@ -4313,6 +4357,11 @@ imageTrace Outline 轮廓 @msg artboard {0} does not exist 画板 {0} 不存在 @msg cannot read PDF: {0} 无法读取 PDF:{0} @msg invalid PDF setting: {0} PDF 设置无效:{0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):它以 UserUnit {unit} 写入,因此忽略 UserUnit 的应用会将其显示为缩小相应倍数 +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):{label} 页面不带 UserUnit,以点为单位写入,遵守上限的应用会将页面裁切为 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 没有 UserUnit,而长于 14,400 pt (200 in) 的页面需要它来保持尺寸:文件将以 PDF 1.6 写入 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 画板 {n} 的页面为 {size},对 PDF 3 至 14,400 单位的页面边长而言过长过窄:以 UserUnit {unit} 写入,使短边保持 3 单位以上,遵守上限的应用会把长边裁到 14,400 单位 +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 文件的实数最大为 {max},而画板 {n} 的页面长 {side} pt,需要更大的数:请选择不带 PDF/X 标准的 PDF 1.6 或更高版本,或使用更小的画板 @msg page {0} does not exist (the PDF has {1}) 页面 {0} 不存在 (该 PDF 有 {1} 页) @msg thumbnail: no page {n} (the file has 1) thumbnail:页面 {n} 不存在 (该文件有 1 页) @msg the PDF has no pages 该 PDF 没有页面 @@ -4360,6 +4409,7 @@ imageTrace Outline 轮廓 @msg {label}… {label}… @msg no blend options 没有混合选项 @msg bad color {v} 颜色 {v} 无效 +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` 应为 null、图层颜色名称或 #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 必须是 0.1 到 10 之间的数字 @msg the Color Picker is not open 拾色器未打开 @msg the Simplify slider is not open 简化滑块未打开 @@ -4531,6 +4581,7 @@ imageTrace Outline 轮廓 Stop Script 停止脚本 Show External Script Warning 显示外部脚本警告 A Script Is Running 脚本正在运行 + Script Alert 脚本提示 A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 脚本可以更改打开的文档,并可通过其运行的命令写入文件。请只运行您信任的脚本。 Do not show this warning again 不再显示此警告 Stop the script and undo what it did? Then quit again. 要停止脚本并撤销其更改吗?然后请再次退出。 @@ -4544,6 +4595,9 @@ imageTrace Outline 轮廓 @msg the script is too large to run 脚本过大,无法运行 @msg a drag or an undo group is open; finish it before running a script 拖动或撤销组尚未结束;请在运行脚本前完成 @msg the script left a document that cannot be kept 脚本留下了无法保留的文档 +@msg the script is no longer in the Scripts folder 脚本已不在 Scripts 文件夹中 + Some scripts were not listed 部分脚本未列出 + The Scripts folder cannot be read 无法读取 Scripts 文件夹 @msg the script's run is no longer available 脚本运行已不可用 @msg a failed command changed undo steps that could not be put back, so the script was stopped 失败的命令更改了无法恢复的撤销步骤,因此脚本已停止 @msg a recovery copy could not be written, so the script was not run 无法写入恢复副本,因此未运行脚本 @@ -4662,6 +4716,8 @@ imageTrace Outline 轮廓 @msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 处的项为 {value},不是非负整数对象 id @msg no such object id {value} 没有 id 为 {value} 的对象 +@msg id {value} is a ruler guide, not an object id {value} 是参考线,不是对象 +@msg a ruler guide goes only directly in a layer or sublayer 参考线只能直接位于图层或子图层中 @msg id {value} is a layer, not an object id {value} 是图层,不是对象 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是图层,不是对象;请使用 layer.move 移动图层 @msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path} @@ -4742,14 +4798,17 @@ imageTrace Outline 轮廓 @msg Invalid drawing scale 图纸比例无效 @msg Invalid real scale 实际比例无效 @msg Measured anchor no longer exists 测量锚点已不存在 +@msg Invalid measured point 测量点无效 @msg Measured segment no longer exists 测量线段已不存在 @msg Non-finite dimension 尺寸标注不是有限值 +@msg Only linear dimensions are horizontal or vertical 只有线性尺寸可以是水平或垂直的 @msg Scale Text Attributes: selected type has no scale to bake 缩放文本属性:所选文本没有可应用的缩放 @msg Scale must be drawing:real, for example 1:10 比例必须为 图纸:实际,例如 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 比例必须介于 1:1000000 和 1000000:1 之间 @msg Second measured segment no longer exists 第二条测量线段已不存在 @msg Unknown dimension mode 未知的尺寸标注模式 @msg Unknown dimension option {k} 未知的尺寸标注选项 {k} +@msg Unknown dimension orientation 未知的尺寸标注方向 @msg span must be "object" or "selection", got {v} span 必须是 "object" 或 "selection",实际为 {v} @msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而不是色板 @msg span: "selection" places a gradient: give `gradient` span: "selection" 放置渐变:请给出 `gradient` @@ -5479,7 +5538,8 @@ lighting Light 光源 Bevel both sides 双面斜角 Presets 预设 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一个对象只能应用一种 3D 效果。请编辑现有效果的选项,或先将其移去。 -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路径、复合路径、文字或编组。请先将图像和其他对象转换为路径。 +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}目前需要路径、复合路径、文字、编组、混合或符号实例。请先扩展其他对象。 +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}无法从图像生成立体:请先使用“对象 › 图像描摹 › 创建”进行描摹。 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 @@ -5545,6 +5605,7 @@ lighting Light 光源 Preview (1 inch on the page) 预览(页面上 1 英寸) Set as Default 设为默认 out from a highlighted edge to place its dimension | 从高亮的边向外拖动以放置其尺寸 | + two points, then move and click to place an aligned, horizontal or vertical dimension | 两个点,然后移动并单击以放置对齐、水平或垂直尺寸 | cancels | Double-click the tool for options 取消 | 双击工具打开选项 Linear 线性 Angular 角度 @@ -5801,6 +5862,20 @@ axis Y Axis Y 轴 Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行 Keep settings of its own 保存自己的设置 Keep a cache of its own, which you can clear 保存自己的缓存,您可以清除它 + Run these commands on the document: {ids} 在文档上运行这些命令:{ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}”在 `commandAllowlist` 中列出了增效工具不能调用的 `{id}` +@msg vc_call `{shown}`: {why} vc_call `{shown}`:{why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`:清单的 `commandAllowlist` 未列出它 +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`:ID 或参数位于模块内存之外 +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`:ID 或参数不是 UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`:参数不是 JSON 对象 +@msg only a filter with the usual budget declares `commandAllowlist` 只有使用通常预算的滤镜才能声明 `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) 命令调用(每次运行 {MAX_CALLS} 次调用和 {MAX_CALL_BYTES} 字节) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) 命令调用大小(vc_call `{shown}`:{len} 字节,上限 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` 中最多 {MAX_ALLOWED_COMMANDS} 个命令 +@msg a plug-in may not call this command 增效工具不能调用此命令 +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` 需要权限 `commands`,而 `commands` 需要 `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` 列出的 `{shown}` 不是命令 ID,或列出了两次 Clear Plug-in Cache 清除增效工具缓存 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `{permission}` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature} @@ -5890,6 +5965,23 @@ axis Y Axis Y 轴 Button 按钮 Panel 面板 UI Frames UI 框架 + Map Markers 地图标记 + Pin 图钉 + Flag 旗帜 + Crosshair 准星 + Checkpoint 检查点 + Inventory Icons 物品栏图标 + Skill Icons 技能图标 + Attack 攻击 + Defend 防御 + Heal 治疗 + Buff 增益 + Ult 终极技 + Sword 剑 + Shield 盾 + Potion 药水 + Key 钥匙 + Gem 宝石 Open Symbol Library 打开符号库 Save Symbol Library 保存符号库 Save Symbol Library… 保存符号库… @@ -5944,6 +6036,16 @@ axis Y Axis Y 轴 User Brush Library 9 用户画笔库 9 User Brush Library 10 用户画笔库 10 Art Ribbons 艺术丝带 + Zigzag 锯齿 + Burst Ray 冲击射线 + Dash Trail 虚线轨迹 + Speed Line 速度线 + Game Trails 游戏轨迹 + Energy Beams 能量光束 + Laser Bolt 激光弹 + Plasma Arc 等离子弧 + Charge Wave 充能波 + Spark Trail 火花尾迹 Calligraphic Nibs 书法笔尖 Pattern Borders 图案边框 Scatter Shapes 散布形状 @@ -6532,3 +6634,33 @@ breakText Glyphs 字形 Align to Pixel Grid 对齐像素网格 @msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格 @msg side is left or right, not `{s}` side 应为 left 或 right,而不是 `{s}` + Edit Custom Dictionary 编辑自定义词典 + Entry: 条目: +# Script Console + Script Console 脚本控制台 + Clear Script Console 清除脚本控制台 + Copy Script Console 复制脚本控制台 + Copy All 全部复制 + Copy Selected 复制所选内容 + Finished 已完成 + Ended with an error 因错误结束 + Out of fuel 已用尽指令上限 + Ended at a host limit 在宿主限制处结束 + Ended at a limit 在限制处结束 + Stopped 已停止 + Ended by an internal fault 因内部错误结束 + time unavailable 时间不可用 + {ms} ms {ms} 毫秒 + {time}, {calls} {time},{calls} + slowest: {commands} 最慢的命令:{commands} + {dropped}, {cut} {dropped},{cut} +@plural {n} host call|{n} host calls 宿主调用 {n} 次 +@plural {n} earlier line dropped|{n} earlier lines dropped 已丢弃 {n} 行较早内容 +@plural {n} line cut|{n} lines cut 已截断 {n} 行 +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后 + +# Deferred plug-in effects + Refresh Deferred Effects 刷新延迟的效果 + Deferred Effects 延迟的效果 +@msg no plug-in effect is deferred 没有延迟的增效工具效果 +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 打开时延迟了 {n} 个增效工具效果。效果 › 刷新延迟的效果可运行它们。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 16e6898bf..084a7e770 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -248,6 +248,7 @@ font-technology Variable 可變 Average… 平均… Axis: 軸: Background Color: 背景顏色: + Guide Label: 參考線標籤: Show Center Mark 顯示中心標記 Show Cross Hairs 顯示十字線 Show Video Safe Areas 顯示視訊安全區域 @@ -1242,6 +1243,7 @@ file-filter Swatch libraries 色票資料庫 file-filter Flattener presets 平面化預設集 file-filter PDF presets PDF 預設集 file-filter Print presets 列印預設集 +file-filter Preferences 偏好設定 file-filter Export presets 轉存預設集 file-filter SVG Compressed 壓縮 SVG file-filter PDF-compatible .ai PDF 相容 .ai @@ -1649,6 +1651,7 @@ file-filter Templates 範本 Include Linked Files 包含連結檔案 Include Metadata 包含中繼資料 Include non-printing layers 包含不列印的圖層 + Write pages longer than 200 in at full size in points 以實際尺寸(點)寫入長於 200 in 的頁面 Include SVG Code 包含 SVG 程式碼 Include Swatches for Tints 包含色調的色票 Inflate… 膨脹… @@ -1982,6 +1985,20 @@ file-filter Templates 範本 @msg `{key}` must be between {min} and {max} (got {x}) `{key}` 必須介於 {min} 和 {max} 之間 (收到 {x}) @msg `{key}` must be one of {_1} `{key}` 必須是 {_1} 其中之一 @msg unknown preference `{key}` 未知的偏好設定 `{key}` +@msg Preferences imported. 已匯入偏好設定。 +@msg not a preferences file: {e} 不是偏好設定檔案:{e} +@msg not a preferences file: no JSON object 不是偏好設定檔案:沒有 JSON 物件 +@msg not a preferences file (format `{format}`) 不是偏好設定檔案(格式 `{format}`) +@msg not a preferences file: no `format` 不是偏好設定檔案:沒有 `format` +@msg not a preferences file: no `version` 不是偏好設定檔案:沒有 `version` +@msg not a preferences file: no `prefs` 不是偏好設定檔案:沒有 `prefs` +@msg the file was saved by a newer version of VectorCraft (version {v}) 此檔案由較新版本的 VectorCraft 儲存(版本 {v}) +@msg the file holds no preferences 此檔案不含偏好設定 +@msg `{key}` names a folder, which preferences files leave out `{key}` 指定的是資料夾,偏好設定檔案不含資料夾 +@msg the file is larger than 1 MB 檔案大於 1 MB +@msg give `path` or `data` 請提供 `path` 或 `data` +@msg `{key}` must be an object `{key}` 必須是物件 +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` 須單獨執行,不能作為批次步驟 @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 「資料復原」沒有可存放拷貝的位置:請設定「偏好設定 → 檔案處理 → 資料復原 → 資料夾」 (recoveryFolder) @msg Data Recovery can't keep copies in {path}: {e} 「資料復原」無法將拷貝存放到 {path}:{e} @msg Plug-ins can't be loaded from {path}: {e} 無法從 {path} 載入增效模組:{e} @@ -2158,6 +2175,11 @@ file-filter Templates 範本 @msg artboard {0} does not exist 工作區域 {0} 不存在 @msg cannot read PDF: {0} 無法讀取 PDF:{0} @msg invalid PDF setting: {0} PDF 設定無效:{0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):它以 UserUnit {unit} 寫入,因此忽略 UserUnit 的應用程式會將其顯示為縮小相應倍數 +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):{label} 頁面不含 UserUnit,以點為單位寫入,遵守上限的應用程式會將頁面裁切為 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 沒有 UserUnit,而長於 14,400 pt (200 in) 的頁面需要它來保持尺寸:檔案將以 PDF 1.6 寫入 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 工作區域 {n} 的頁面為 {size},對 PDF 3 至 14,400 單位的頁面邊長而言過長過窄:以 UserUnit {unit} 寫入,使短邊保持 3 單位以上,遵守上限的應用程式會把長邊裁到 14,400 單位 +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 檔案的實數最大為 {max},而工作區域 {n} 的頁面長 {side} pt,需要更大的數:請選擇不含 PDF/X 標準的 PDF 1.6 或更新版本,或使用較小的工作區域 @msg page {0} does not exist (the PDF has {1}) 頁面 {0} 不存在 (此 PDF 有 {1} 頁) @msg the PDF has no pages 此 PDF 沒有頁面 @msg the PDF is password-protected: give its password 此 PDF 受密碼保護:請提供其密碼 @@ -2204,6 +2226,7 @@ file-filter Templates 範本 @msg {label}… {label}… @msg no blend options 沒有漸變選項 @msg bad color {v} 顏色 {v} 無效 +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` 應為 null、圖層顏色名稱或 #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 必須是 0.1 到 10 之間的數字 @msg the Color Picker is not open 「顏色選擇器」未開啟 @msg the Simplify slider is not open 「簡化」滑桿未開啟 @@ -2375,6 +2398,7 @@ file-filter Templates 範本 Stop Script 停止指令碼 Show External Script Warning 顯示外部指令碼警告 A Script Is Running 指令碼正在執行 + Script Alert 指令碼提示 A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 指令碼可以變更開啟的文件,並可透過其執行的命令寫入檔案。請只執行您信任的指令碼。 Do not show this warning again 不再顯示此警告 Stop the script and undo what it did? Then quit again. 要停止指令碼並還原其變更嗎?然後請再次結束。 @@ -2388,6 +2412,9 @@ file-filter Templates 範本 @msg the script is too large to run 指令碼過大,無法執行 @msg a drag or an undo group is open; finish it before running a script 拖曳或還原群組尚未結束;請在執行指令碼前完成 @msg the script left a document that cannot be kept 指令碼留下了無法保留的文件 +@msg the script is no longer in the Scripts folder 指令碼已不在 Scripts 資料夾中 + Some scripts were not listed 部分指令碼未列出 + The Scripts folder cannot be read 無法讀取 Scripts 資料夾 @msg the script's run is no longer available 指令碼執行已無法使用 @msg a failed command changed undo steps that could not be put back, so the script was stopped 失敗的命令變更了無法還原的復原步驟,因此指令碼已停止 @msg a recovery copy could not be written, so the script was not run 無法寫入復原副本,因此未執行指令碼 @@ -2584,14 +2611,17 @@ file-filter Templates 範本 @msg Invalid drawing scale 圖紙比例無效 @msg Invalid real scale 實際比例無效 @msg Measured anchor no longer exists 測量的錨點已不存在 +@msg Invalid measured point 測量點無效 @msg Measured segment no longer exists 測量的線段已不存在 @msg Non-finite dimension 尺寸標註不是有限值 +@msg Only linear dimensions are horizontal or vertical 只有線性尺寸可以是水平或垂直的 @msg Scale Text Attributes: selected type has no scale to bake 縮放文字屬性:所選文字沒有可套用的縮放 @msg Scale must be drawing:real, for example 1:10 比例必須為 圖紙:實際,例如 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 比例必須介於 1:1000000 和 1000000:1 之間 @msg Second measured segment no longer exists 第二條測量線段已不存在 @msg Unknown dimension mode 未知的尺寸標註模式 @msg Unknown dimension option {k} 未知的尺寸標註選項 {k} +@msg Unknown dimension orientation 未知的尺寸標註方向 @msg span must be "object" or "selection", got {v} span 必須是 "object" 或 "selection",收到 {v} @msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而非色票 @msg span: "selection" places a gradient: give `gradient` span: "selection" 會放置漸層:請提供 `gradient` @@ -2823,6 +2853,7 @@ file-filter Templates 範本 Lock Grid 鎖定格點 Lock Guides 鎖定參考線 Lock Guides by Default 預設鎖定參考線 + Artboard Guides with Cmd/Ctrl + Ruler Drag 按住 Cmd/Ctrl 從尺標拖曳建立工作區域參考線 Lock Others 鎖定其他 Lock Slices 鎖定切片 Lock Station Point 鎖定基準點 @@ -3274,6 +3305,8 @@ file-filter Templates 範本 Open… 開啟… Optical 視覺 Optical Margin Alignment 視覺邊界對齊 + Dynamic Text 動態文字 +@msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames} Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 Options 選項 @@ -3769,6 +3802,12 @@ digits Malayalam 馬來亞拉姆數字 Reset OpenType Features 重設 OpenType 功能 Reset Panel 重設面板 Reset Preferences 重設偏好設定 + Export Preferences… 匯出偏好設定… + Import Preferences… 匯入偏好設定… + Save the applied preferences to a JSON file 將已套用的偏好設定儲存為 JSON 檔案 + Apply the preferences in a file Export Preferences saved 套用「匯出偏好設定」所儲存檔案中的偏好設定 + Export Preferences 匯出偏好設定 + Import Preferences 匯入偏好設定 Search Preferences 搜尋偏好設定 No settings found 找不到設定 Reset Profiles 重設描述檔 @@ -3792,6 +3831,8 @@ digits Malayalam 馬來亞拉姆數字 Responsive 回應式 Restore 還原 Restore every preference to its default (applied on OK) 將所有偏好設定還原為預設值 (按下確定後套用) + Reset all preferences to their defaults? 要將所有偏好設定重設為預設值嗎? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 在偏好設定中按下確定後才會套用預設值。在那裡按下取消會保留目前的偏好設定。 Restore Recovered Documents 還原找回的文件 Restored documents open unsaved: save them to keep them. 還原的文件會以未儲存狀態開啟:請儲存以保留。 Restrict printing, editing and other tasks 限制列印、編輯及其他操作 @@ -4090,6 +4131,20 @@ digits Malayalam 馬來亞拉姆數字 Settings… 設定… Setup… 設定… Shadows and Glows 陰影與光暈 + Game HUD 遊戲 HUD + Health Fill 生命 + Mana Glow 魔力 + Damage Flash 傷害 + Quest Outline 任務 + Coin Shine 金幣 + Boss Alert 首領 + Status Effects 狀態效果 + Poison Tint 中毒 + Freeze Edge 冰凍 + Burn Aura 灼燒 + Stun Pulse 暈眩 + Stealth Fade 隱身 + Shield Buff 護盾 Shape 形狀 Shape Builder 形狀建立 Shape Builder Regions 形狀建立區域 @@ -4509,6 +4564,8 @@ digits Malayalam 馬來亞拉姆數字 Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列) Gutter: 間距: Tint 色調 + Fill Tint 填色色調 + Stroke Tint 筆畫色調 Tip extends past the end point 尖端延伸超過終點 Tip on the end point 尖端位於終點 Title Case 標題式大小寫 @@ -5305,7 +5362,8 @@ lighting Light 光源 Bevel both sides 雙面斜角 Presets 預設集 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一個物件只能套用一種 3D 效果。請編輯現有效果的選項,或先將其移除。 -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路徑、複合路徑、文字或群組。請先將影像和其他物件轉換為路徑。 +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}目前需要路徑、複合路徑、文字、群組、漸變或符號範例。請先展開其他物件。 +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}無法從影像建立立體:請先使用「物件 › 影像描圖 › 製作」描圖。 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 @@ -5368,6 +5426,7 @@ lighting Light 光源 Preview (1 inch on the page) 預覽(頁面上 1 英寸) Set as Default 設為預設 out from a highlighted edge to place its dimension | 從醒目提示的邊緣向外拖曳以放置其尺寸 | + two points, then move and click to place an aligned, horizontal or vertical dimension | 兩個點,然後移動並按一下以放置對齊、水平或垂直尺寸 | cancels | Double-click the tool for options 取消 | 按兩下工具開啟選項 Linear 線性 Angular 角度 @@ -5502,6 +5561,8 @@ lighting Light 光源 @msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 處的項目為 {value},不是非負整數物件 id @msg no such object id {value} 沒有 id 為 {value} 的物件 +@msg id {value} is a ruler guide, not an object id {value} 是參考線,不是物件 +@msg a ruler guide goes only directly in a layer or sublayer 參考線只能直接位於圖層或子圖層中 @msg id {value} is a layer, not an object id {value} 是圖層,不是物件 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是圖層,不是物件;請使用 layer.move 移動圖層 Move Anchor Point 移動錨點 @@ -5598,6 +5659,20 @@ axis Y Axis Y 軸 Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行 Keep settings of its own 保存自己的設定 Keep a cache of its own, which you can clear 保存自己的快取,您可以清除它 + Run these commands on the document: {ids} 在文件上執行這些指令:{ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call 「{name}」在 `commandAllowlist` 中列出了增效模組不能呼叫的 `{id}` +@msg vc_call `{shown}`: {why} vc_call `{shown}`:{why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`:資訊清單的 `commandAllowlist` 未列出它 +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`:ID 或引數位於模組記憶體之外 +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`:ID 或引數不是 UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`:引數不是 JSON 物件 +@msg only a filter with the usual budget declares `commandAllowlist` 只有使用一般預算的濾鏡才能宣告 `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) 指令呼叫(每次執行 {MAX_CALLS} 次呼叫和 {MAX_CALL_BYTES} 位元組) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) 指令呼叫大小(vc_call `{shown}`:{len} 位元組,上限 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` 中最多 {MAX_ALLOWED_COMMANDS} 個指令 +@msg a plug-in may not call this command 增效模組不能呼叫此指令 +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` 需要權限 `commands`,而 `commands` 需要 `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` 列出的 `{shown}` 不是指令 ID,或列出了兩次 Clear Plug-in Cache 清除增效模組快取 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `{permission}` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature} @@ -5687,6 +5762,23 @@ axis Y Axis Y 軸 Button 按鈕 Panel 面板 UI Frames UI 框架 + Map Markers 地圖標記 + Pin 圖釘 + Flag 旗幟 + Crosshair 準星 + Checkpoint 檢查點 + Inventory Icons 物品欄圖示 + Skill Icons 技能圖示 + Attack 攻擊 + Defend 防禦 + Heal 治療 + Buff 增益 + Ult 必殺 + Sword 劍 + Shield 盾 + Potion 藥水 + Key 鑰匙 + Gem 寶石 Open Symbol Library 開啟符號資料庫 Save Symbol Library 儲存符號資料庫 Save Symbol Library… 儲存符號資料庫… @@ -5741,6 +5833,16 @@ axis Y Axis Y 軸 User Brush Library 9 使用者筆刷資料庫 9 User Brush Library 10 使用者筆刷資料庫 10 Art Ribbons 藝術緞帶 + Zigzag 鋸齒 + Burst Ray 衝擊射線 + Dash Trail 虛線軌跡 + Speed Line 速度線 + Game Trails 遊戲軌跡 + Energy Beams 能量光束 + Laser Bolt 雷射彈 + Plasma Arc 電漿弧 + Charge Wave 充能波 + Spark Trail 火花尾跡 Calligraphic Nibs 書法筆尖 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 @@ -6330,3 +6432,33 @@ breakText Glyphs 字符 Align to Pixel Grid 對齊像素格點 @msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點 @msg side is left or right, not `{s}` side 應為 left 或 right,而不是 `{s}` + Edit Custom Dictionary 編輯自訂字典 + Entry: 項目: +# Script Console + Script Console 指令碼主控台 + Clear Script Console 清除指令碼主控台 + Copy Script Console 複製指令碼主控台 + Copy All 全部複製 + Copy Selected 複製所選內容 + Finished 已完成 + Ended with an error 因錯誤結束 + Out of fuel 已用盡指令上限 + Ended at a host limit 在主機限制處結束 + Ended at a limit 在限制處結束 + Stopped 已停止 + Ended by an internal fault 因內部錯誤結束 + time unavailable 時間無法取得 + {ms} ms {ms} 毫秒 + {time}, {calls} {time},{calls} + slowest: {commands} 最慢的命令:{commands} + {dropped}, {cut} {dropped},{cut} +@plural {n} host call|{n} host calls 主機呼叫 {n} 次 +@plural {n} earlier line dropped|{n} earlier lines dropped 已捨棄 {n} 行較早內容 +@plural {n} line cut|{n} lines cut 已截斷 {n} 行 +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後 + +# Deferred plug-in effects + Refresh Deferred Effects 重新整理延後的效果 + Deferred Effects 延後的效果 +@msg no plug-in effect is deferred 沒有延後的增效模組效果 +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 開啟時延後了 {n} 個增效模組效果。效果 › 重新整理延後的效果可執行它們。 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index a75171ae3..50f13e695 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -172,10 +172,13 @@ pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result String { +/// What permission `name` lets a plug-in do, in plain words in the UI language, with the ids of +/// the `commands` it may call for `commands`; a name the app doesn't know is shown as it is. +fn permission_words(name: &str, commands: &[String]) -> String { let words = match permissions::Permission::parse(name) { + Some(permissions::Permission::Commands) => { + return crate::i18n::fmt(tl!("Run these commands on the document: {ids}"), &[("ids", &commands.join(", "))]); + } Some(permissions::Permission::DocRead) => tl!("Read the objects it is given"), Some(permissions::Permission::DocWrite) => tl!("Change, add and delete objects in the document"), Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), @@ -244,7 +247,11 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result = info["commandAllowlist"] + .as_array() + .map(|ids| ids.iter().filter_map(Value::as_str).map(|id| permissions::display_text(id, permissions::MAX_SHOWN_FIELD)).collect()) + .unwrap_or_default(); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p, &commands)))); paragraphs.push(lines.join("\n")); install["grant"] = Value::Array(asked.clone()); } diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index e9860e99a..cfbdaaadb 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -132,6 +132,7 @@ const FILE_TYPES: &[(&str, &[&str])] = &[ ("Composite fonts", vectorcraft_engine::cmd::compositefonts::EXTS), ("CSS", &["css"]), ("PostScript", &["ps"]), + ("Preferences", &["json"]), ]; /// The file type a save panel shows for a file with extension `ext`: its format's, else its diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d6c91d3d1..644125b54 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,7 @@ pub mod recovery; pub mod render_worker; pub mod screen_color; pub mod script_runs; +pub mod scripts_menu; mod scrub; pub mod shortcut_editor; pub mod shortcuts; @@ -187,6 +188,8 @@ pub struct VectorcraftApp { frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, + /// An unfinished control-channel Rotate View gesture, kept only for this app instance. + pub(crate) control_rotate_press: Option, /// Hover position in document coordinates. pub hover_doc: Option, /// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index cbc1536fc..3f34af13a 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -23,6 +23,7 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { ctx.data_mut(|d| d.insert_temp::(field_key, field)); } let mut clicked: Option<(String, Value)> = None; + let mut file_open = false; let tree = menu_tree_named(app.session.prefs.font_names_in_english); // No application menu in the window (as in Illustrator): the bar starts at File on every // platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there. @@ -37,11 +38,21 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text); let resp = ui.add(egui::Button::new(text)); egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked)); + if *title == "File" { + file_open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)); + } titles.push(resp); } (ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles)) }) .inner; + // The File menu opening reads the Scripts folder again, for File › Scripts. + let file_key = egui::Id::unique("menu-bar-file-open"); + let file_was_open = ctx.data(|d| d.get_temp::(file_key)).unwrap_or(false); + ctx.data_mut(|d| d.insert_temp(file_key, file_open)); + if file_open && !file_was_open { + crate::scripts_menu::refresh(app, Some(&ctx)); + } // Whether a top-level menu is open: the docking renderer lowers the floating panels' OS windows // while one is, or the menu would open under them. ctx.data_mut(|d| d.insert_temp(menu_open_id(), open)); diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 5d203fe87..815a00d6f 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -1,6 +1,7 @@ use super::context::context_items; use super::invoke::click_target; use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, spelling_item_label, unlock_item_label}; +use super::slots::{recent_readable, recent_slot}; use super::state::{shortcut_of, shown_state}; use super::tree::menu_tree_named; use super::{Entry, Item, Serialize, Value, VectorcraftApp}; @@ -33,8 +34,11 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { Some(match it { Item::Sep => Entry::Sep, Item::Header(h) => Entry::Header(t(h)), - // A plug-in's submenu shows its name as it is, as its items do. - Item::Sub(label, children) if super::labels::holds_plugin_commands(children) => Entry::Sub(label, children), + // A plug-in's submenu shows its name as it is, as its items do, and so does a subfolder of + // the Scripts folder. + Item::Sub(label, children) if super::labels::holds_plugin_commands(children) || crate::scripts_menu::is_folder(children) => { + Entry::Sub(label, children) + } Item::Sub(label, children) => Entry::Sub(t(label), children), Item::Todo(label, sc) => Entry::Item(Row { command: None, @@ -127,6 +131,13 @@ fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Ve for it in items { match it { Item::Cmd(l, id, p) => { + if *id == crate::scripts_menu::COMMAND && !crate::scripts_menu::readable(p) { + continue; + } + // The other rows keep their ids: `file.openRecent3` still opens the third file. + if id.starts_with("file.openRecent") && recent_slot(app, id).is_some_and(|path| !recent_readable(path)) { + continue; + } let Some((enabled, _)) = shown_state(app, id, p) else { continue }; out.push(MenuEntry { path: path.clone(), diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 7b3adaffe..ab530b803 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -2,11 +2,12 @@ use super::state::{SUBJECT_COMMANDS, enabled, has_subject, waits_for_ime}; use super::{Value, VectorcraftApp, json}; /// What a menu click runs: items whose label ends with "…" and that carry default params open a -/// generic parameter dialog (fields = the defaults) instead of running immediately. +/// generic parameter dialog (fields = the defaults) instead of running immediately. A script of the +/// Scripts folder runs, whatever its name ends with. pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { let dialog = label.ends_with('…') && p.as_object().is_some_and(|o| !o.is_empty()) - && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); + && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog" | crate::scripts_menu::COMMAND); if dialog { return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); } @@ -287,6 +288,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { } return true; } + // Edit Custom Dictionary…: the custom dictionary's words in a list, to add, delete or change. + if id == crate::dialogs::custom_dictionary::COMMAND && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::custom_dictionary::open(app) { + app.status(e); + } + return true; + } // Edit Views…: the saved views in a list, to rename or delete. if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { if let Err(e) = crate::dialogs::edit_views::open(app) { diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 148368a27..408e5f077 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -123,8 +123,8 @@ fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option /// Does the menu item `id` show a name that is user, file or system data rather than an interface /// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a -/// plug-in, an SVG filter? Such names are shown as they are, never translated (a workspace the user calls -/// "Layers" stays "Layers"). +/// plug-in, an SVG filter, a script of the Scripts folder? Such names are shown as they are, never +/// translated (a workspace the user calls "Layers" stays "Layers"). pub(super) fn shows_a_name(id: &str, label: &str) -> bool { const SLOTS: [&str; 8] = [ "file.openRecent", @@ -138,7 +138,10 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { ]; SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some() - || matches!(id, "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom" | "svgFilter.apply") + || matches!( + id, + "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom" | "svgFilter.apply" | crate::scripts_menu::COMMAND + ) || vectorcraft_engine::cmd::plugin::is_command_id(id) || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) @@ -328,12 +331,12 @@ pub(crate) fn holds_plugin_commands(children: &[Item]) -> bool { /// Every English string the menus, the command palette, the panel registry, the toolbar and the /// Preferences dialog can show: what a complete language catalog has to cover. Names that are user -/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the -/// view angles are left out. +/// data (fonts, recent files, saved views, libraries, installed plug-ins, the scripts and subfolders +/// of the Scripts folder), the point sizes and the view angles are left out. pub fn menu_strings() -> std::collections::BTreeSet { use std::collections::BTreeSet; fn user_data(id: &str, params: &Value) -> bool { - hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) + hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) || id == crate::scripts_menu::COMMAND } fn walk(items: &[Item], under: &str, out: &mut BTreeSet) { for it in items { @@ -354,8 +357,8 @@ pub fn menu_strings() -> std::collections::BTreeSet { Item::Todo(l, _) | Item::Header(l) => { out.insert(l.to_string()); } - // A plug-in's submenu is named after the plug-in. - Item::Sub(_, children) if holds_plugin_commands(children) => walk(children, under, out), + // A plug-in's submenu is named after the plug-in, and a subfolder's after the folder. + Item::Sub(_, children) if holds_plugin_commands(children) || crate::scripts_menu::is_folder(children) => walk(children, under, out), Item::Sub(l, children) => { out.insert(l.to_string()); // Type › Font lists the installed families: names, not UI text. @@ -375,6 +378,7 @@ pub fn menu_strings() -> std::collections::BTreeSet { out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string())); out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string())); out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string())); + out.extend(crate::scripts_menu::LABELS.iter().map(|s| s.to_string())); out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string())); for c in vectorcraft_engine::cmd::command_specs() { out.insert(c.label.to_string()); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 92186e9f3..1223e1e42 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::bar::home_key; use super::invoke::menu_dialog; -use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::slots::{recent_readable, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; use super::state::{checked, selected_image}; use super::ui_commands::UI_COMMANDS; use super::{Brightness, Face, SaveMode, Value, VectorcraftApp, io, json, next_zoom}; @@ -137,7 +137,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(io::hide_exports(app)), "file.cancelOpen" => Ok(crate::opening::cancel_all(app)), "file.openInBackground" => Ok(crate::opening::hide_all(app)), - id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { + id if id.starts_with("file.openRecent") => match recent_slot(app, id).filter(|path| recent_readable(path)).cloned() { Some(path) => io::open_reporting(app, &path), None => Err("no such recent file".into()), }, @@ -164,7 +164,7 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(json!(io::recent_files(app))), + "file.recentFiles" => Ok(json!(io::recent_files(app).iter().filter(|path| recent_readable(path)).collect::>())), "file.place" => crate::place::run(app, p), "file.place.queue" => crate::place::queue(app, p), "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { @@ -206,9 +206,13 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - crate::panel_docking::reveal_panel(app, "separations").and_then(|_| app.session.execute(id, p).map_err(|e| e.to_string())) + crate::panel_docking::reveal_panel(app, "separations").and_then(|_| { + app.ui.color_settings_scroll = true; + app.session.execute(id, p).map_err(|e| e.to_string()) + }) } // Outline or Preview is a setting of the active document's view. "view.outline" => match app.view_mut() { @@ -767,6 +771,7 @@ fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::trace_presets::open(app, p), "ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p), + "ui.copyScriptConsole" => crate::panels::script_console::copy(app, p), "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), "ui.libraryPanel" => crate::panels::library_panel::command(app, p), "ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p), @@ -970,6 +975,7 @@ fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(crate::trace_runs::hide_all(app)), "ui.checkScript" => crate::dialogs::script_check::command(app, p), "ui.runScript" => crate::dialogs::script_run::command(app, p), + crate::scripts_menu::COMMAND => crate::scripts_menu::run(app, p), "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), "ui.perspectivePresetsDialog" => { crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); diff --git a/crates/ui-egui/src/menus/slots.rs b/crates/ui-egui/src/menus/slots.rs index 2dee19dcf..5bd3bfb00 100644 --- a/crates/ui-egui/src/menus/slots.rs +++ b/crates/ui-egui/src/menus/slots.rs @@ -51,6 +51,14 @@ pub(super) fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a S io::recent_files(app).get(n.checked_sub(1)?) } +/// May the request being served read recent file `path`? Under an automation read root (a control +/// client's request), a recent file outside the root is left out of `ui.menu.list` and +/// `file.recentFiles`, and its `file.openRecentN` slot answers as an empty one, as a listed script +/// outside the root is left out ([`crate::scripts_menu::readable`]). +pub(super) fn recent_readable(path: &str) -> bool { + vectorcraft_engine::file_access::check_read(path).is_ok() +} + /// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. pub(super) const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ [ diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 1941504e5..2e45a8848 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -41,6 +41,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical") } "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), + // Dynamic Text of the first selected type on a path, which the item turns the other way. + "type.dynamicText" => selected_text(true).is_some_and(|t| t.dynamic_text), "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index 9e8a7ac65..469c40e7c 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -136,6 +136,7 @@ pub(super) fn effect_menu() -> Vec { c("Last Effect…", "effect.last"), Sep, c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), + c("Refresh Deferred Effects", "effect.refreshDeferred"), Sep, Item::Header("Vector Effects"), ]; diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index a0e493561..a449fb036 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -130,6 +130,57 @@ fn hovering_another_title_switches_the_open_menu() { assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone"); } +#[test] +fn opening_the_file_menu_reads_the_scripts_folder_again() { + let dir = std::env::temp_dir().join(format!("vc-menu-bar-scripts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = vectorcraft_engine::Session::new(); + session.attach_scripts_dir(dir.clone()); + let mut app = VectorcraftApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + // The scripts File › Scripts lists. + let listed = || { + let (_, file) = menu_tree().into_iter().find(|(title, _)| *title == "File").unwrap(); + let scripts = file.iter().find_map(|item| match item { + Item::Sub("Scripts", items) => Some(items.clone()), + _ => None, + }); + scripts + .unwrap() + .iter() + .filter_map(|item| if let Item::Cmd(label, crate::scripts_menu::COMMAND, _) = item { Some(*label) } else { None }) + .collect::>() + }; + // One frame of the bar, then the listing a read it started gave. + let frame = |app: &mut VectorcraftApp| { + let titles = bar_frame(app, &ctx, vec![]); + crate::scripts_menu::wait(); + titles + }; + std::fs::write(dir.join("a.js"), "").unwrap(); + frame(&mut app); + let titles = frame(&mut app); + assert!(listed().is_empty(), "the bar reads nothing while its menus are closed"); + egui::Popup::open_id(&ctx, titles[0].1); + frame(&mut app); + assert_eq!(listed(), ["a"]); + // While File stays open the folder is not read again. + std::fs::write(dir.join("b.js"), "").unwrap(); + frame(&mut app); + assert_eq!(listed(), ["a"]); + // Nor when another menu opens; the next time File opens it is. + egui::Popup::open_id(&ctx, titles[1].1); + let t = frame(&mut app); + assert_eq!((open_titles(&ctx, &t), listed()), (vec![1], vec!["a"])); + egui::Popup::open_id(&ctx, titles[0].1); + frame(&mut app); + assert_eq!(listed(), ["a", "b"]); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_popup_covering_a_title_does_not_switch_menus() { let ctx = egui::Context::default(); @@ -262,6 +313,44 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() { assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); } +/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path that is in no +/// thread, and is checked while the selected type on a path has it on. +#[test] +fn dynamic_text_is_a_checked_type_menu_item() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let ids: Vec<&str> = menu_tree() + .into_iter() + .filter(|(menu, _)| *menu == "Type") + .flat_map(|(_, items)| items) + .filter_map(|item| match item { + Item::Cmd("Dynamic Text", id, _) => Some(id), + _ => None, + }) + .collect(); + assert_eq!(ids, ["type.dynamicText"]); + let id = "type.dynamicText"; + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + assert!(!enabled(&app, id)); + let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [point]})).unwrap(); + assert!(!enabled(&app, id), "point type"); + let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); + let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [t]})).unwrap(); + assert!(enabled(&app, id)); + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true)); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + let frame = app.run("shape.rectangle", json!({"x": 60, "y": 200, "width": 200, "height": 100})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [t, frame]})).unwrap(); + app.run("text.thread.create", json!({})).unwrap(); + app.run("select.set", json!({"ids": [t]})).unwrap(); + assert!(!enabled(&app, id), "type on a path in a thread"); +} + /// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked. #[test] fn optical_margin_alignment_menu_unchecked_when_shared_flag_none() { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index ebea83e90..0c46950f3 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -1,6 +1,6 @@ use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items}; -use super::{Item, Sep, c, cp, json, sub, todo, todos}; +use super::{Item, Sep, c, cp, json, sub, todos}; /// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see /// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). @@ -62,7 +62,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Export Selection…", "file.exportSelection"), Sep, c("Package…", "ui.packageDialog"), - sub("Scripts", vec![c("Check Script…", "ui.checkScript"), c("Other Script…", "ui.runScript")]), + sub("Scripts", crate::scripts_menu::items()), Sep, c("Document Setup…", "file.documentSetup"), sub( @@ -97,10 +97,10 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { Sep, cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), cp("Find Next", "edit.findNext", json!({"find": ""})), - sub( - "Spelling", - vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")], - ), + sub("Spelling", vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I")]), + // Edit › Edit Custom Dictionary…, beside Spelling, on the public Check and correct + // spelling page. + c("Edit Custom Dictionary…", crate::dialogs::custom_dictionary::COMMAND), Sep, sub( "Edit Colors", @@ -399,6 +399,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) .collect(), ), + c("Dynamic Text", "type.dynamicText"), sub( "Threaded Text", vec![ @@ -664,6 +665,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Pathfinder", "pathfinder"), panel("Pattern Options", "patternOptions"), panel("Properties", "properties"), + panel("Script Console", crate::panels::script_console::ID), panel("Separations Preview", "separations"), panel("Stroke", "stroke"), panel("SVG Interactivity", crate::panels::svg_interactivity::ID), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index fb78fc207..a96f13e67 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -137,7 +137,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", ), - ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first; under an automation read root, only the files inside it"), ( "file.export.svg", "Export As SVG…", @@ -976,6 +976,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", ), + ( + "ui.copyScriptConsole", + "Copy Script Console", + "", + "{first?, last?: numbers of lines the Script Console holds, both or neither} copy those lines of the Script Console to the clipboard as the panel shows them, each with its run's number and script name; without them every line, after a line counting the lines it dropped and cut when it dropped or cut any, which n does not count (Script Console panel: Copy Selected, Copy All; script.console gives the lines) → {lines: n}", + ), ( "window.graphicStyleLibrary", "Graphic Style Library", @@ -1526,6 +1532,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+F12", "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)", ), + ( + crate::scripts_menu::COMMAND, + "Run Script", + "", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + ), ( "ui.checkScript", "Check Script…", diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 8d1b7ee08..56baee51a 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -590,7 +590,7 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) { /// What the menus' rows depend on, cheaply (no allocation): commands run, the active document, /// its revision (edits, selection and view changes), history and clipboard, the language, -/// shortcuts, plug-ins, an open file dialog and the fonts (Type › Font lists the loaded fonts +/// shortcuts, plug-ins, the Scripts folder's listing, an open file dialog and the fonts (Type › Font lists the loaded fonts /// until the installed ones are scanned, then all of them, #5483). `input` forces a change on frames with a click or /// a key press, which covers what the hash doesn't list (panels shown, a tool picked). While an /// interaction is open (for example a move, scale or shape drag, a dialog's or the font menu's @@ -608,7 +608,7 @@ fn state_hash(app: &VectorcraftApp, input: Option) -> u64 { } (app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h); app.file_dialog_open().hash(&mut h); - (crate::i18n::current().code(), menus::plugin_revision()).hash(&mut h); + (crate::i18n::current().code(), menus::plugin_revision(), crate::scripts_menu::revision()).hash(&mut h); crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed).hash(&mut h); let fonts = vectorcraft_text::FontDb::global(); (fonts.generation(), fonts.installed_fonts_ready()).hash(&mut h); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index e895bef6c..d5bf7b609 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -17,8 +17,9 @@ pub fn items() -> Vec<(String, String, String)> { let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) }; items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string())); } - // `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. - for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog") { + // `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. A script of + // the Scripts folder runs from File › Scripts, by its path. + for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog" && c.0 != crate::scripts_menu::COMMAND) { items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string())); } for tool in vectorcraft_tools::catalog::all_tools() { diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a74f47b6a..3447cd1e1 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -949,6 +949,7 @@ impl Transaction { } if let Some(saved) = &location.group { apply(&mut candidate, Action::ResizeGroup { panel: panel.clone(), height: saved.height })?; + apply(&mut candidate, Action::ResizeFlyoutWidth { panel: panel.clone(), width: saved.flyout_width })?; apply(&mut candidate, Action::SetMinimized { panel: panel.clone(), minimized: saved.minimized })?; } self.workspace.layout = candidate; @@ -1087,6 +1088,7 @@ fn known(action: &Action) -> bool { | Action::SetIconic { panel, .. } | Action::ResizeColumn { panel, .. } | Action::ResizeGroup { panel, .. } + | Action::ResizeFlyoutWidth { panel, .. } | Action::ResizeIconic { panel, .. } | Action::SetNative { panel, .. } | Action::SetMonitor { panel, .. } => registered(panel), @@ -1169,7 +1171,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if let Some(panel) = app.dock_rt.raise.take() { dock = dock.raise_panel(panel); } - let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app }); + let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app, layout: &layout }); app.dock_rt.space = craft_ui::docking::float_space(&ctx, area); if let Some(panel) = output.focused.take() { app.dock_rt.focused = Some(panel); @@ -1343,9 +1345,11 @@ pub(crate) fn floating_size(panel: &str) -> egui::Vec2 { /// The panels' content, labels and icons. struct PanelContent<'a> { app: &'a mut VectorcraftApp, + layout: &'a Layout, } impl craft_ui::docking::DockContent for PanelContent<'_> { + /// Draw a registered panel body, fitting untouched flyouts and pinning sized panels' footers. fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { crate::toolbar::show_beside_canvas(self.app, ui); @@ -1364,15 +1368,23 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } let width = ui.available_width(); let height = (ui.available_height() - 20.0).max(0.0); - if DockTab::from_id(id).is_some() { - crate::dock::panel_body(self.app, ui, id, width, height); - } else { - // Shrinking to the content's height lets craft-ui fit a float or flyout to it. - crate::widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("shared-panel-body", id)).auto_shrink([false, true]), |ui| { - let width = ui.available_width(); - crate::dock::panel_body(self.app, ui, id, width, height) - }); - } + let fitted = in_iconic_column(self.layout, id) + && self.layout.location(id).ok().and_then(|location| location.group).is_none_or(|group| group.height.is_none()); + // Properties embeds other panels as sections, so their local button rows + // must remain beside those sections rather than pinning to the outer panel. + let bottom = (!fitted && id != "properties").then_some(back.bottom() - 10.0); + let info = egui::UiStackInfo::default().with_tag_value(crate::widgets::PANEL_FOOTER, bottom); + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| { + if DockTab::from_id(id).is_some() { + crate::dock::panel_body(self.app, ui, id, width, height); + } else { + // Shrinking to the content's height lets craft-ui fit a float or flyout to it. + crate::widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("shared-panel-body", id)).auto_shrink([false, true]), |ui| { + let width = ui.available_width(); + crate::dock::panel_body(self.app, ui, id, width, height) + }); + } + }); if field_had_keyboard { keep_field_ending_keys(ui); } diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 1b8b1a168..2d33420b9 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -12,13 +12,16 @@ struct Harness { app: VectorcraftApp, ctx: egui::Context, time: f64, + cursor: egui::CursorIcon, + nodes: Vec<(egui::accesskit::NodeId, egui::accesskit::Node)>, } impl Harness { + /// Create a blank document and settle the initial docking layout over three frames. fn new() -> Self { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 }; + let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, cursor: egui::CursorIcon::Default, nodes: Vec::new() }; for _ in 0..3 { h.frame(vec![]); } @@ -40,6 +43,7 @@ impl Harness { self.run(input); } + /// Draw one application frame and retain its cursor and accessibility output for assertions. fn run(&mut self, input: egui::RawInput) { let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| { @@ -47,6 +51,8 @@ impl Harness { app.ui(ui); }); out.textures_delta.clear(); + self.cursor = out.platform_output.cursor_icon; + self.nodes = out.platform_output.accesskit_update.map_or_else(Vec::new, |update| update.nodes); } fn hit(&self, hit: &Hit) -> Option { @@ -138,6 +144,120 @@ fn an_icon_opens_its_flyout_and_open_panel_follows() { assert_eq!(craft_ui::docking::flyout::(&h.ctx, area_id()), None); } +/// #5713: real Glyphs and Artboards bodies must retain a flyout resize through +/// content fitting, closing/reopening and serialization of the workspace layout. +#[test] +fn glyphs_and_artboards_flyouts_keep_user_sizes() { + for panel in ["glyphs", "artboards"] { + let mut h = Harness::new(); + let icon = h.hit(&Hit::Icon(panel.into())).unwrap().center(); + h.click(icon); + // Glyphs reserves space from the available height, so its untouched auto-fit + // converges over several frames before the first user resize. + for _ in 0..30 { + h.frame(vec![]); + } + // Area memory can include a panel body's minimum allocation beyond its clip. + // The frame's actual edge widgets describe the visible, resizable rectangle. + let rect = |h: &Harness| { + let id = area_id().with("flyout"); + let left = h.ctx.read_response(id.with("resize-left")).unwrap().rect; + let bottom = h.ctx.read_response(id.with("resize-bottom")).unwrap().rect; + Rect::from_min_max(pos2(left.left(), left.top() - 12.0), pos2(bottom.right() + 12.0, bottom.bottom())) + }; + let before = rect(&h); + for (at, cursor) in [ + (before.left_center() + vec2(2.0, 0.0), egui::CursorIcon::ResizeHorizontal), + (before.center_bottom() - vec2(0.0, 2.0), egui::CursorIcon::ResizeVertical), + ] { + h.frame(vec![Event::PointerMoved(at)]); + h.frame(vec![]); + assert_eq!(h.cursor, cursor, "{panel} at {at:?}, frame {before:?}"); + } + let from = before.left_bottom() + vec2(2.0, -2.0); + h.drag(from, from + vec2(-100.0, 150.0)); + for _ in 0..3 { + h.frame(vec![]); + } + let grown = rect(&h); + assert!((grown.width() - before.width() - 100.0).abs() < 1.0, "{panel}: {before:?} -> {grown:?}"); + assert!((grown.height() - before.height() - 150.0).abs() < 1.0, "{panel}: {before:?} -> {grown:?}"); + assert_eq!(grown.right(), before.right(), "the icon-column edge stays anchored"); + h.click(icon); + assert_eq!(craft_ui::docking::flyout::(&h.ctx, area_id()), None); + h.click(icon); + for _ in 0..3 { + h.frame(vec![]); + } + assert_eq!(rect(&h).size(), grown.size()); + let saved = serde_json::to_string(&h.layout()).unwrap(); + let mut restored = Harness::new(); + restored.app.ui.docking = Some(serde_json::from_str(&saved).unwrap()); + for _ in 0..3 { + restored.frame(vec![]); + } + restored.click(restored.hit(&Hit::Icon(panel.into())).unwrap().center()); + for _ in 0..3 { + restored.frame(vec![]); + } + assert_eq!(rect(&restored).size(), grown.size(), "{panel} saved layout"); + } +} + +/// Footer controls stay at the resized panel's bottom, including empty or short lists. +/// The same shared footer is used by the other icon panels and library windows. +#[test] +fn expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists() { + for (panel, label, artboards) in [ + ("artboards", "New Artboard", 1), + ("artboards", "New Artboard", 30), + ("brushes", "New Brush", 1), + ("swatches", "New Swatch", 1), + ("symbols", "New Symbol", 1), + ("graphicStyles", "New Graphic Style (Alt-click to name it)", 1), + ("appearance", "Add New Stroke", 1), + ("charStyles", "Create New Character Style", 1), + ("paraStyles", "Create New Paragraph Style", 1), + ("colorGuide", "Save color group to Swatch panel", 1), + ("links", "Show Link Info", 1), + ("actions", "Create New Action", 1), + ("variables", "Capture Data Set", 1), + ("assetExport", "Export for Screens…", 1), + ("cssProperties", "Generate CSS: the whole document's CSS while nothing is selected", 1), + ("svgInteractivity", "Link JavaScript Files…", 1), + ("colorThemes", "Add to Swatches", 1), + ("glyphs", "Zoom In", 1), + ] { + let mut h = Harness::new(); + h.ctx.enable_accesskit(); + for _ in 1..artboards { + h.app.run("artboard.new", json!({})).unwrap(); + } + h.app.run("window.panel.dock", json!({"panel":panel,"icon":true})).unwrap(); + let mut layout = h.layout(); + layout.apply(Action::ResizeGroup { panel: panel.into(), height: Some(540.0) }).unwrap(); + layout.apply(Action::ResizeFlyoutWidth { panel: panel.into(), width: Some(420.0) }).unwrap(); + h.app.ui.docking = Some(layout); + h.app.ui.open_panel = Some(panel.into()); + for _ in 0..5 { + h.frame(vec![]); + } + let bottom = h.ctx.read_response(area_id().with("flyout").with("resize-bottom")).unwrap().rect.bottom(); + let control = h + .nodes + .iter() + .find(|(_, node)| node.label() == Some(label)) + .and_then(|(_, node)| node.bounds()) + .unwrap_or_else(|| panic!("missing {label} in {panel}")); + assert!((control.y1 as f32 - (bottom - 10.0)).abs() < 2.0, "{panel}/{artboards}: footer {control:?}, bottom {bottom}"); + if panel == "artboards" && artboards == 1 { + let at = pos2(((control.x0 + control.x1) * 0.5) as f32, ((control.y0 + control.y1) * 0.5) as f32); + h.click(at); + assert_eq!(h.app.session.active().unwrap().doc.artboards.len(), 2, "the pinned New button works"); + } + } +} + #[test] fn a_tab_dragged_out_floats_and_comes_back_where_it_was_after_its_window_closes() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2282633eb..1106406bc 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -37,6 +37,7 @@ pub mod paragraph; pub mod pathfinder; pub mod pattern_options; pub mod properties; +pub mod script_console; pub mod separations; pub mod stroke; pub mod svg_interactivity; @@ -183,6 +184,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), three_d::ID => three_d::show(app, ui), + script_console::ID => script_console::show(app, ui), crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui), crate::addons::color::ID => crate::addons::color::show(app, ui), crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui), @@ -238,6 +240,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool asset_export::ID => asset_export::menu(app, ui), css_properties::ID => css_properties::menu(app, ui), variables::ID => variables::menu(app, ui), + script_console::ID => script_console::menu(app, ui), "actions" => actions::menu(app, ui), id if library_panel::parse(id).is_some() => library_panel::menu(app, ui, id), _ => return false, @@ -675,6 +678,8 @@ mod tests_long_popups; #[cfg(test)] mod tests_maskview; #[cfg(test)] +mod tests_script_console; +#[cfg(test)] mod tests_stroke; #[cfg(test)] mod tests_strokedepth; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 93d781e5a..2533ef029 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -1377,6 +1377,7 @@ enum InclusionChange { Step(usize, usize, usize, bool), } +/// Draw action sets, steps and playback controls, keeping the list above a pinned footer. pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // The selected action as (set, action). Read as the type it is stored as: egui keeps a @@ -1398,7 +1399,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let mut expanded: BTreeSet = pstate(ui.ctx(), "actions-expanded"); let mut inclusion_changes = Vec::new(); let mut play_req = None; - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + let list_height = widgets::panel_list_height(ui, 260.0, 0.0).min(260.0); + egui::ScrollArea::vertical().max_height(list_height).min_scrolled_height(list_height.min(64.0)).show(ui, |ui| { for (si, set) in app.ui.action_sets.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); if sets_selected.contains(&si) && selected.is_none() { @@ -1551,9 +1553,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { set_pstate(ui.ctx(), SELECTED_STEP, step_selected); app.ui.action_set = current; app.ui.action_sets_selected = sets_selected; - widgets::divider(ui); + if app.ui.action_sets.is_empty() { + dim_label(ui, tl!("No actions. Press ● to record.")); + } // Bottom bar: stop, record, play, new set, new action, delete. - ui.horizontal(|ui| { + widgets::bottom_bar(ui, |ui| { let recording = app.ui.recording.is_some(); if widgets::icon_button(ui, "square", tl!("Stop Playing/Recording"), false, 24.0).clicked() && let Err(e) = app.run(STOP_RECORDING, json!({})) @@ -1598,9 +1602,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.status(e); } let _ = icons::exists; - if app.ui.action_sets.is_empty() { - dim_label(ui, tl!("No actions. Press ● to record.")); - } } #[cfg(test)] @@ -1608,6 +1609,57 @@ mod tests { use super::*; use vectorcraft_engine::Session; + /// A short sized panel clips the list above its footer and can scroll to select its final row. + #[test] + fn a_short_actions_panel_keeps_its_last_row_reachable_above_the_footer() { + use egui::{Event, PointerButton, Rect, UiBuilder, UiStackInfo, pos2, vec2}; + for height in [60.0, 120.0] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events| { + time += 0.1; + let input = egui::RawInput { + time: Some(time), + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, height))), + events, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| { + let info = UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, Some(height)); + ui.scope_builder(UiBuilder::new().ui_stack_info(info), |ui| show(app, ui)); + }); + output.textures_delta.clear(); + output.shapes + }; + let last_row = |shapes: &[egui::epaint::ClippedShape]| { + shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text() == "Reflect Horizontal" => Some((text.visual_bounding_rect(), shape.clip_rect)), + _ => None, + }) + .expect("the final action is drawn") + }; + for _ in 0..3 { + let shapes = frame(&mut app, vec![]); + assert!(last_row(&shapes).1.bottom() <= height - 31.0, "height={height}: the list overlaps its footer"); + } + for _ in 0..12 { + frame(&mut app, crate::toolbar::tests::wheel(pos2(100.0, 12.0), 1000.0)); + } + let shapes = frame(&mut app, vec![]); + let (row, clip) = last_row(&shapes); + assert!(clip.contains_rect(row), "height={height}: the final row {row:?} is outside {clip:?}"); + let at = row.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + for events in [vec![Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + frame(&mut app, events); + } + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((0, 5))); + } + } + #[test] fn defaults_are_valid_commands() { for set in default_sets() { diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 0c322f0a0..e1076145d 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -210,7 +210,9 @@ fn has_key(app: &VectorcraftApp) -> bool { /// The first selected ruler guide while objects are selected too: the one Align to Key Object /// makes the key when there is no other object to be it. fn guide_for_key(app: &VectorcraftApp) -> Option { - app.session.active().filter(|st| !st.selection.is_empty()).and_then(|st| st.selection.guides.first().copied()) + let st = app.session.active().filter(|st| !st.selection.is_empty())?; + let first = st.selection.guides.first().copied()?; + vectorcraft_engine::cmd::guide_indexes(&st.doc, &[first]).first().copied() } /// The number of selected ruler guides, which Distribute spaces with the objects (#1597). diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs index 76b7e3b60..6cd9364cd 100644 --- a/crates/ui-egui/src/panels/align/tests.rs +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -28,11 +28,13 @@ fn align_to_key_object_makes_a_selected_guide_the_key() { let ctx = egui::Context::default(); let ([_, key, _], left) = panel_buttons(&mut app, &ctx); click(&ctx, &mut app, key.center(), show); - assert_eq!(app.session.active().unwrap().selection.key_guide, Some(0), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let key = st.selection.key_guide.map(|k| vectorcraft_engine::cmd::guide_indexes(&st.doc, &[k])); + assert_eq!(key, Some(vec![0]), "{}", app.ui.status); assert_eq!(align_to(&app), AlignTo::Key); click(&ctx, &mut app, left.center(), show); assert_eq!(left_edge(&app, &a), 120.0, "on the guide, not the artboard: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 120.0); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 120.0); } /// A Shift-click that leaves the key object selected on its own lets the key go, and the align @@ -532,13 +534,13 @@ fn the_distribute_buttons_send_the_spacing_with_a_key_guide() { app.ui.align_spacing = 15.0; click(&ctx, &mut app, left.center(), show); assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); // Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it, // the one after it starts 15 pt after it. click(&ctx, &mut app, space.center(), show); assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0)))); assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); } /// Three vertical ruler guides selected without objects: Align Objects buttons are on, and @@ -560,7 +562,7 @@ fn the_align_buttons_align_guides_selected_alone_to_artboard() { row.sort_by(|a, b| a.left().total_cmp(&b.left())); assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled"); click(&ctx, &mut app, row[0].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status); } @@ -585,7 +587,7 @@ fn the_distribute_buttons_space_guides_selected_alone() { assert_eq!(row.len(), 6, "the Distribute row"); // Its fifth button is Horizontal Distribute Center. click(&ctx, &mut app, row[4].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status); app.run("edit.undo", json!({})).unwrap(); assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step"); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index ca1f3c28f..32963ce68 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -385,10 +385,22 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re let area_id = egui::Id::unique("ap-row-popup"); let shown = ctx.memory(|m| m.area_rect(area_id)); // A press outside it (not one that closes a dropdown the popup opened) or Escape puts it away. + // With a field in it that has the keyboard, a press draws it once more first, so the field lets + // go of the keyboard to that press and commits what was typed, as in a panel (#6429). Escape + // drops what was typed. let pressed_outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| shown.is_some_and(|r| !r.contains(p)))); - if pressed_outside && !egui::Popup::is_any_open(ctx) || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + let mut last = false; + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { return close(app); + } else if pressed_outside && !egui::Popup::is_any_open(ctx) { + if !widgets::field_focused_in(ctx, egui::LayerId::new(egui::Order::Foreground, area_id)) { + return close(app); + } + last = true; } + // Fields let go of the keyboard on a click, which this press isn't yet. The app-wide option + // goes back however this function ends, a panic in the panel included. + let _surrender = last.then(|| SurrenderOnPress::set(ctx)); let screen = ctx.content_rect(); let left = panel.left() - POPUP_GAP - POPUP_W; let x = if left >= screen.left() + POPUP_GAP { left } else { (panel.right() + POPUP_GAP).min(screen.right() - POPUP_W - POPUP_GAP) }; @@ -415,6 +427,28 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re } }); }); + if last { + close(app); + } +} + +/// While it lives, a press anywhere makes a field let go of the keyboard, as a click does +/// otherwise; dropped, egui's previous `surrender_focus_on` is back ([`row_popup`]'s last frame). +struct SurrenderOnPress(egui::Context, egui::SurrenderFocusOn); + +impl SurrenderOnPress { + fn set(ctx: &egui::Context) -> Self { + let before = ctx.options(|o| o.input_options.surrender_focus_on); + ctx.options_mut(|o| o.input_options.surrender_focus_on = egui::SurrenderFocusOn::Presses); + Self(ctx.clone(), before) + } +} + +impl Drop for SurrenderOnPress { + fn drop(&mut self) { + let before = self.1; + self.0.options_mut(|o| o.input_options.surrender_focus_on = before); + } } /// A row's name that edits the row when clicked ([`link`]), with the tooltip "Click to edit". diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index 12fd3155e..27bae8fcf 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -1,4 +1,7 @@ -use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll, fx_menu_ends}; +use super::super::tests_long_popups::{ + PopupWindow, assert_last_row_scrolls_into_view, assert_popup_stays_put, assert_rows_stay_on_one_line, assert_short_list_does_not_scroll, + fx_menu_ends, +}; use super::*; /// A selected rectangle, for the fill row's swatch picker and the Add Effect button. @@ -63,6 +66,20 @@ fn a_short_swatch_picker_does_not_scroll() { assert_eq!(window.clickable(TILE), tiles, "a list that fits does not scroll"); } +/// The Add New Effect popup keeps each group's name on one line when its list scrolls: the scroll +/// bar's column widens the popup instead of narrowing the rows. +#[test] +fn the_add_effect_popup_keeps_its_rows_on_one_line_when_it_scrolls() { + let mut groups: Vec = vec![]; + for effect in vectorcraft_effects::effect_catalog() { + let group = effect.menu.get(1).copied().unwrap_or("Other").to_string(); + if !groups.contains(&group) { + groups.push(group); + } + } + assert_rows_stay_on_one_line(&mut app_with_a_selected_rectangle(), draw_bottom_bar, fx_button, &groups, "Distort & Transform", 120.0); +} + /// The Add New Effect button: the third of the bottom bar's 24 pt buttons. fn fx_button(window: &PopupWindow) -> egui::Pos2 { window.clickable(vec2(24.0, 24.0))[2].center() @@ -81,6 +98,36 @@ fn the_add_effect_popup_scrolls_inside_a_short_window() { assert_short_list_does_not_scroll(&mut app_with_a_selected_rectangle(), draw_bottom_bar, fx_button, &first, &last); } +/// The Add New Effect popup opened from a button `below` pt above the bottom of a window `height` +/// pt tall stays at one place on every frame, inside the window. +fn assert_add_effect_popup_stays_put(height: f32, below: f32) -> Rect { + let (first, _) = fx_menu_ends(); + let draw = move |app: &mut VectorcraftApp, ui: &mut Ui| { + ui.add_space((height - below - 24.0).max(0.0)); + draw_bottom_bar(app, ui); + }; + assert_popup_stays_put(&mut app_with_a_selected_rectangle(), height, draw, fx_button, &first) +} + +/// Opened from a button near the bottom of a short window, the Add New Effect popup stays at one +/// place instead of being drawn above and below the button on alternate frames. +#[test] +fn the_add_effect_popup_stays_put_near_the_bottom_of_a_short_window() { + for height in [200.0, 300.0, 400.0, 600.0] { + let row = assert_add_effect_popup_stays_put(height, 8.0); + assert!(row.bottom() < height - 32.0, "in a {height} pt window the popup opens above the button: {row:?}"); + } +} + +/// Opened from a button near the top of a window, the popup opens below it and stays there. +#[test] +fn the_add_effect_popup_stays_put_below_a_button_near_the_top() { + for height in [200.0, 400.0, 900.0] { + let row = assert_add_effect_popup_stays_put(height, height - 32.0); + assert!(row.top() > 24.0, "in a {height} pt window the popup opens below the button: {row:?}"); + } +} + /// Duplicating rows highlights the copies, each right above its original, as the Layers panel /// does: the active row's copy becomes active, the anchor's copy the anchor. #[test] diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index d46e5c4ac..04f2719e7 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -503,6 +503,7 @@ fn select_copies(app: &mut VectorcraftApp, all: &[usize], r: &Value) { } } +/// Draw the artboard list and its commands, filling the space above a sized panel's footer. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let abs: Vec = app.session.active().map(|d| d.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); @@ -517,10 +518,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut row_clicked = false; // A row dropped on another row: (from, to), the index it lands at. let mut dropped = None; + let fixed = widgets::panel_footer_bottom(ui).is_some(); + let list_height = widgets::panel_list_height(ui, 220.0, 2.0 + ui.spacing().item_spacing.y); let list_rect = widgets::list_box(ui, |ui| { - ui.set_min_height(110.0); + ui.set_min_height(if fixed { list_height.min(110.0) } else { 110.0 }); ui.spacing_mut().item_spacing.y = 0.0; - let scrolled = egui::ScrollArea::vertical().id_salt("ab-scroll").max_height(220.0).show(ui, |ui| { + let scroll = egui::ScrollArea::vertical() + .id_salt("ab-scroll") + .max_height(list_height) + .min_scrolled_height(list_height.min(64.0)) + .auto_shrink([false, !fixed]); + let scrolled = scroll.show(ui, |ui| { for (i, name) in abs.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag()); if resp.drag_started() { diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 5c07441be..9192aa5f0 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -212,6 +212,7 @@ fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) { } } +/// Browse and insert glyphs from the chosen font, with font controls below the glyph grid. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let db = FontDb::global(); @@ -244,14 +245,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let cols = ((w / cell).floor() as usize).max(1); let rows = chars.len().div_ceil(cols); let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; - let list_h = (ui.available_height() - 64.0).max(cell * 3.0); + let list_h = widgets::panel_list_height(ui, (ui.available_height() - 64.0).max(cell * 3.0), 33.0); let hover_id = egui::Id::unique("gl-hover"); let selected = selected_char(app); // The selected character and the rows shown last frame: the grid scrolls to the selected glyph // only when the selection moves to another character, so it still scrolls freely meanwhile. let follow_id = egui::Id::unique("gl-follow"); let (followed, shown): (Option, std::ops::Range) = ui.ctx().data(|d| d.get_temp(follow_id)).unwrap_or_default(); - let mut area = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]); + let mut area = egui::ScrollArea::vertical().max_height(list_h).min_scrolled_height(list_h.min(64.0)).auto_shrink([false, false]); if selected != followed && let Some(c) = selected && let Ok(i) = chars.binary_search_by_key(&c, |x| x.0) @@ -308,6 +309,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { /// The family and style menus of the font the panel browses, and Zoom Out and Zoom In. fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w: f32) { let db = FontDb::global(); + widgets::panel_footer_space(ui, 24.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; // Picks the font the panel browses only: nothing is previewed on the document. @@ -371,10 +373,14 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let cols = ((w / cell).floor() as usize).max(1); let rows = list.len().div_ceil(cols); let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; - let list_h = (ui.available_height() - 64.0).max(cell * 3.0); + let list_h = widgets::panel_list_height(ui, (ui.available_height() - 64.0).max(cell * 3.0), 33.0); let mut pick = None; // A scroll position of its own for each character, so the grid starts at the top for another. - let area = egui::ScrollArea::vertical().id_salt(("gl-alternates", id.0, byte)).max_height(list_h).auto_shrink([false, false]); + let area = egui::ScrollArea::vertical() + .id_salt(("gl-alternates", id.0, byte)) + .max_height(list_h) + .min_scrolled_height(list_h.min(64.0)) + .auto_shrink([false, false]); area.show_rows(ui, cell, rows, |ui, range| { for r in range { ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index 7e797cead..1586943c0 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -1,4 +1,6 @@ -use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll}; +use super::super::tests_long_popups::{ + PopupWindow, assert_last_row_scrolls_into_view, assert_rows_stay_on_one_line, assert_short_list_does_not_scroll, +}; use super::*; use crate::panels::library_panel; use egui::{Event, PointerButton, Pos2, Rect, vec2}; @@ -66,6 +68,20 @@ fn a_long_tracing_preset_list_scrolls_inside_a_short_window() { assert_last_row_scrolls_into_view(&mut app, 200.0, draw, tracing_presets_arrow, &first, "Saved 29"); } +/// The list of presets keeps each name on one line when it scrolls: the scroll bar's column widens +/// the list instead of narrowing the rows. +#[test] +fn a_long_tracing_preset_list_keeps_its_rows_on_one_line() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let draw = |app: &mut VectorcraftApp, ui: &mut Ui| trace_button(app, ui, TRACE_BUTTON_W); + for n in 0..30 { + app.session.execute("imageTrace.presets.save", &json!({"name": format!("Saved {n}"), "preset": "Default"})).unwrap(); + } + let presets = app.session.execute("imageTrace.presets", &json!({})).unwrap(); + let names: Vec = presets["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect(); + assert_rows_stay_on_one_line(&mut app, draw, tracing_presets_arrow, &names, "Black and White Logo", 120.0); +} + #[test] fn a_large_image_traces_and_the_status_bar_says_it_is_slower() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 313242bb4..f027f2c71 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -151,8 +151,10 @@ struct View<'a> { /// Where the row thumbnails come from. thumbs: ThumbSource<'a>, sel: HashSet, - /// The selected ruler guides. + /// The selected ruler guides, as indexes into [`Self::ruler_guides`]. guides: Vec, + /// The document's ruler guides ([`Document::ruler_guides`]). + ruler_guides: Vec, target: Option, current: Option, rows: Vec, @@ -262,7 +264,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { doc: &doc, thumbs: ThumbSource::of(app, st, &doc), sel: st.selection.objects.iter().copied().collect(), - guides: st.selection.guides.clone(), + guides: vectorcraft_engine::cmd::guide_indexes(&doc, &st.selection.guides), + ruler_guides: doc.ruler_guides(), target: st.selection.target, current: st.current_layer(), rows, @@ -468,7 +471,8 @@ fn listed_when_opened(doc: &Document, open: &OpenRows, opts: &PanelOptions, quer open.set(*a, true); } let mut rows = vec![]; - push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, doc), &mut rows); + // Only object rows are looked for, so the guide rows are left out. + push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, &[]), &mut rows); rows.iter().any(|r| r.node.id == id && r.guide.is_none() && !r.spine) } diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index bbafbb755..c24863ffb 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -1,5 +1,5 @@ use super::rows::key; -use super::{Arc, Color32, Document, LiveShape, Node, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; +use super::{Arc, Color32, Document, LiveShape, Node, NodeId, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; /// Where a dragged row goes relative to the row it is dropped on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -42,7 +42,8 @@ pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang pub(super) fn opens(n: &Node, opts: &PanelOptions) -> bool { match n.kind { NodeKind::Compound { .. } => false, - _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), + // Ruler guide nodes are not object rows (a layer's guide rows are counted by its caller). + _ => n.children().is_some_and(|c| c.iter().any(|c| !c.is_ruler_guide() && (!opts.layers_only || c.is_layer()))), } } @@ -226,25 +227,29 @@ struct RowsKey { /// contains `query` (lowercase) and that `filter` keeps, and every row holding one. pub(super) fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { let mask_layer = doc.mask_edit.map(|m| m.layer); + // The ruler guides' ids by `guide.list` index, listed once for every layer. + let guides: Vec = doc.ruler_guides().into_iter().filter_map(|g| g.id).collect(); let mut out = vec![]; for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, doc), &mut out); + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, &guides), &mut out); } out } /// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds -/// it (the layer's colour, and whether it is visible). +/// it (the layer's colour, and whether it is visible). The last item of `cx` is the document's +/// ruler guide ids by `guide.list` index. pub(super) fn push_rows( n: &Arc, depth: usize, clip_path: bool, (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), + cx: (&OpenRows, &PanelOptions, &str, &Filter, &[NodeId]), out: &mut Vec, ) { - let (open_rows, opts, query, filter, doc) = cx; - if opts.layers_only && !n.is_layer() { + let (open_rows, opts, query, filter, all_guides) = cx; + // A ruler guide node gets no object row: its layer lists the guide rows. + if n.is_ruler_guide() || (opts.layers_only && !n.is_layer()) { return; } let colour = match &n.kind { @@ -257,10 +262,16 @@ pub(super) fn push_rows( let shown = shown && n.visible; let searching = !query.is_empty(); let narrowing = searching || filter.is_on(); - // A layer's ruler guides are rows in it, above its art (not while searching, nor while the - // filter leaves them out). - let guides: Vec = - if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + // A layer's ruler guides are rows in it, above its art, the highest `guide.list` index first + // (not while searching, nor while the filter leaves them out). + let guides: Vec = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { + let mut found: Vec = + n.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).filter_map(|c| all_guides.binary_search(&c.id).ok()).collect(); + found.sort_unstable(); + found + } else { + vec![] + }; let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (narrowing || open_rows.contains(n.id)); let at = out.len(); diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 376c021c4..13b6ff1e2 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -122,7 +122,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - let Some(g) = view.doc.guides.get(i) else { return }; + let Some(g) = view.ruler_guides.get(i) else { return }; let selected = view.guides.contains(&i); let editable = view.doc.guide_editable(g); if editable && resp.hovered() { diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index cad2530e5..212778957 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -650,14 +650,17 @@ fn guide_rows_select_and_move_their_guides() { let (circles, _) = frame(&mut app, &ctx, vec![], false); assert_eq!(circles.len(), 3); let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); - let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + let guides = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides) + }; click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert_eq!(guides(&app), [0], "its row selects it"); // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. let undo = app.session.active().unwrap().history.undo.len(); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); let st = app.session.active().unwrap(); - assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); + assert_eq!((st.doc.ruler_guides()[0].layer, st.history.undo.len()), (Some(second), undo + 1)); // Now under Layer 2: locked, its row neither selects nor drags it. run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); assert!(guides(&app).is_empty(), "deselected with its layer locked"); @@ -666,7 +669,7 @@ fn guide_rows_select_and_move_their_guides() { click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert!(guides(&app).is_empty()); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); - assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].layer, Some(second)); } /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows @@ -1783,3 +1786,48 @@ fn a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend() { click_with(&mut app, &ctx, spine_row, egui::Modifiers::NONE); assert!(selected(&app).is_empty(), "a locked blend's spine can't be selected"); } + +/// A ruler guide node gets no object row, and a layer that holds only one has no rows to open. +#[test] +fn ruler_guide_nodes_get_no_object_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let rect = NodeId(app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + expand(&mut app, &json!({})).unwrap(); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let (first, second) = (doc.layers[0].id, doc.add_layer(Some("Guides only"))); + for l in [first, second] { + let g = Node::new(doc.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 20.0, artboard: None }); + doc.insert(Some(l), usize::MAX, g).unwrap(); + } + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let ids: Vec = rows.iter().filter(|r| r.guide.is_none()).map(|r| r.node.id).collect(); + assert_eq!(ids, [second, first, rect]); + // Each guide is a guide row of its layer instead. + let guide_rows: Vec = rows.iter().filter(|r| r.guide.is_some()).map(|r| r.node.id).collect(); + assert_eq!(guide_rows, [first], "the open layer lists its guide"); + assert!(rows[0].opens, "a layer with only a ruler guide opens to its guide row"); +} + +/// A layer lists its guide rows by `guide.list` index, the highest first, whatever the order of +/// the guide nodes in the layer (`guide.setLayer` puts a moved guide at the top of its new layer). +#[test] +fn guide_rows_list_by_index_whatever_the_node_order() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for pos in [10, 20, 30] { + app.session.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let layer = doc.layers[0].id; + // The guide of index 0 moves to the top of the layer's guides. + let first = doc.ruler_guides()[0].id.unwrap(); + doc.move_node(first, Some(layer), usize::MAX).unwrap(); + let kids: Vec = doc.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(kids.last(), Some(&first)); + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let indexes: Vec = rows.iter().filter_map(|r| r.guide).collect(); + assert_eq!(indexes, [2, 1, 0]); +} diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 4fcdc03e4..dacf77425 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -230,7 +230,8 @@ fn weigh(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<&Docum return false; } let mask = n.extras().mask.as_deref().filter(|m| left.masks && !m.disabled).map(|m| &m.art); - n.children().into_iter().flatten().chain(mask).all(|c| left.object() && count(c, doc, left, depth)) + // A layer's ruler guides draw nothing in its thumbnail and use no budget. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).chain(mask).all(|c| left.object() && count(c, doc, left, depth)) } let mut left = Left { nodes, anchors, steps: THUMB_COUNT_STEPS, masks }; let small = count(n, doc, &mut left, 0); @@ -928,6 +929,7 @@ mod tests { thumbs.receive(ctx, done); } + /// Prioritize the shown document’s thumbnails, requeue them when revisited, and retain cached rows. #[test] fn the_shown_documents_thumbnails_are_made_before_the_queued_ones_of_another() { let ctx = egui::Context::default(); @@ -957,10 +959,15 @@ mod tests { assert!(thumbs.texture(&ctx, &src_b, n, 24).is_some(), "the shown document's thumbnails are shown"); } // Shown again, document 1's rows are asked for again and come back. + // Hold the worker so texture() cannot collect a completed row before the count. + let release = hold(&thumbs); for n in &a_rows { assert!(thumbs.texture(&ctx, &src_a, n, 24).is_none()); } assert_eq!(thumbs.pending.keys().filter(|k| k.0 == 1).count(), 3); + release.send(()).expect("the held job is waiting"); + let held = thumbs.worker.as_ref().and_then(ThumbWorker::wait).expect("the held job finishes"); + assert_eq!(held.key.0, (0, NodeId(0))); for _ in &a_rows { receive_checked(&ctx, &mut thumbs, 1, &a, &a_rows); } diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index ea1a60095..a9853d317 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1733,7 +1733,7 @@ fn selected_guides_show_their_position_in_a_transform_section() { app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let v = app.run("guide.add", json!({"vertical": true, "pos": 30})).unwrap()["index"].as_u64().unwrap(); let h = app.run("guide.add", json!({"vertical": false, "pos": 20})).unwrap()["index"].as_u64().unwrap(); - let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.guides[i as usize].pos; + let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.ruler_guides()[i as usize].pos; let ctx = egui::Context::default(); // Steps the field labelled `label` up by one with ↑. let step = |app: &mut VectorcraftApp, label: &str| { diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs new file mode 100644 index 000000000..78540b211 --- /dev/null +++ b/crates/ui-egui/src/panels/script_console.rs @@ -0,0 +1,195 @@ +//! Script Console panel (Window › Script Console): the lines of this session's script runs +//! (`script.console`), each marked with its run's number and its script's name: a start, the +//! script's `$.write` and `$.writeln` output and `vc.log` entries, its uncaught error, an end and a +//! report of its time and host calls. The panel lays out only the rows in view. A click selects a +//! line and Shift-click a range. Clear runs `script.console.clear`; Copy Selected and Copy All run +//! `ui.copyScriptConsole`. Closing the panel only hides it: runs go on writing to the console. + +use egui::{Sense, Ui, vec2}; +use serde_json::{Value, json}; +use vectorcraft_engine::script::console::{Console, ConsoleLine, Ending, Entry}; + +use crate::VectorcraftApp; +use crate::theme::Tokens; + +pub const ID: &str = "scriptConsole"; +/// Panel state: the selected lines, by their numbers ([`Console::first_number`]), first and last. +const SELECTION: &str = "script-console-selection"; +/// Panel state: how many rows the last frame laid out. +pub(crate) const DRAWN: &str = "script-console-drawn"; +const ROW_HEIGHT: f32 = 16.0; + +/// How a run ended, in the UI language. +fn ending_label(e: Ending) -> &'static str { + match e { + Ending::Finished => tl!("Finished"), + Ending::Error => tl!("Ended with an error"), + Ending::Fuel => tl!("Out of fuel"), + Ending::HostLimit => tl!("Ended at a host limit"), + Ending::Limit => tl!("Ended at a limit"), + Ending::Stopped => tl!("Stopped"), + Ending::Internal => tl!("Ended by an internal fault"), + } +} + +/// A line as the panel shows it, in the UI language: its run's number, its script and its text. +pub(crate) fn shown(line: &ConsoleLine) -> String { + let text = match &line.entry { + Entry::Start => "▶".to_string(), + Entry::Output(t) | Entry::Log(t) | Entry::Error(t) => t.clone(), + Entry::End(e) => format!("■ {}", ending_label(*e)), + Entry::Report { ms, host_calls, slowest } => { + let time = ms.map_or_else(|| tl!("time unavailable").to_string(), millis); + let calls = crate::i18n::tn(*host_calls, "{n} host call", "{n} host calls"); + let mut s = crate::i18n::fmt(tl!("{time}, {calls}"), &[("time", &time), ("calls", &calls)]); + if !slowest.is_empty() { + let list: Vec = slowest.iter().map(|c| format!("{} ×{} {}", c.id, c.calls, millis(c.total_ms))).collect(); + s.push_str("; "); + s.push_str(&crate::i18n::fmt(tl!("slowest: {commands}"), &[("commands", &list.join(", "))])); + } + s + } + }; + format!("#{} {}: {}", line.run, line.name, text) +} + +/// A time in milliseconds, in the UI language. +fn millis(ms: u64) -> String { + crate::i18n::fmt(tl!("{ms} ms"), &[("ms", &ms.to_string())]) +} + +/// The notice of the lines the console dropped and cut, or `None` when it has done neither. +fn notice(c: &Console) -> Option { + (c.dropped_lines() > 0 || c.truncated_lines() > 0).then(|| { + let dropped = crate::i18n::tn(c.dropped_lines(), "{n} earlier line dropped", "{n} earlier lines dropped"); + let cut = crate::i18n::tn(c.truncated_lines(), "{n} line cut", "{n} lines cut"); + crate::i18n::fmt(tl!("{dropped}, {cut}"), &[("dropped", &dropped), ("cut", &cut)]) + }) +} + +/// `ui.copyScriptConsole {first?, last?}`: copy the console's lines numbered `first` to `last` +/// (both kept lines; default every line, with the notice of dropped and cut lines first when the +/// console dropped or cut any) to the clipboard, as the panel shows them → `{lines: n}`, where `n` +/// does not count the notice. +pub(crate) fn copy(app: &mut VectorcraftApp, p: &Value) -> Result { + let c = app.session.script_console(); + let start = c.first_number(); + let end = start + c.lines().len() as u64; + let (first, last) = (p.get("first"), p.get("last")); + let mut text: Vec = Vec::new(); + let (from, to) = if first.is_none() && last.is_none() { + text.extend(notice(c)); + (start, end) + } else { + let held = |v: Option<&Value>| v.and_then(Value::as_u64).filter(|n| (start..end).contains(n)); + match (held(first), held(last)) { + (Some(a), Some(b)) if a <= b => (a, b + 1), + _ => return Err("`first` and `last` must be numbers of lines the console holds, first before last".to_string()), + } + }; + let picked = c.lines().iter().skip((from - start) as usize).take((to - from) as usize); + let lines = picked.map(shown).collect::>(); + let n = lines.len(); + text.extend(lines); + app.clipboard_out = Some(text.join("\n")); + Ok(json!({"lines": n})) +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let c = app.session.script_console(); + let start = c.first_number(); + let count = c.lines().len(); + let mut selection: Option<(u64, u64)> = super::pstate(ui.ctx(), SELECTION); + // A selection whose lines were dropped or cleared is gone. + if selection.is_some_and(|(a, b)| a < start || b >= start + count as u64) { + selection = None; + } + if let Some(n) = notice(c) { + crate::widgets::dim_label(ui, &n); + } + let mut clicked: Option<(u64, bool)> = None; + let mut drawn = 0usize; + let height = crate::widgets::panel_list_height(ui, 260.0, 0.0); + crate::widgets::list_box(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + egui::ScrollArea::vertical().id_salt("script-console").max_height(height).stick_to_bottom(true).auto_shrink([false, true]).show_rows( + ui, + ROW_HEIGHT, + count, + |ui, range| { + drawn = range.len(); + for (i, line) in c.lines().range(range.clone()).enumerate() { + let number = start + (range.start + i) as u64; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click()); + if selection.is_some_and(|(a, b)| (a..=b).contains(&number)) { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let color = match line.entry { + Entry::Error(_) => t.error, + Entry::Start | Entry::End(_) | Entry::Report { .. } => t.text_dim, + Entry::Output(_) | Entry::Log(_) => t.text, + }; + let text = shown(line); + ui.painter().with_clip_rect(r).text( + r.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + &text, + egui::FontId::monospace(11.5), + color, + ); + let resp = resp.on_hover_text(&text); + if resp.clicked() { + clicked = Some((number, ui.input(|i| i.modifiers.shift))); + } + } + }, + ); + }); + super::set_pstate(ui.ctx(), DRAWN, drawn); + if let Some((number, shift)) = clicked { + selection = Some(match (selection, shift) { + (Some((a, b)), true) => (a.min(number), b.max(number)), + _ => (number, number), + }); + } + super::set_pstate(ui.ctx(), SELECTION, selection); + crate::widgets::bottom_bar(ui, |ui| { + if ui.add_enabled(count > 0, egui::Button::new(tl!("Clear"))).clicked() { + run(app, "script.console.clear", json!({})); + } + if ui.add_enabled(selection.is_some(), egui::Button::new(tl!("Copy Selected"))).clicked() + && let Some((first, last)) = selection + { + run(app, "ui.copyScriptConsole", json!({"first": first, "last": last})); + } + if ui.add_enabled(count > 0, egui::Button::new(tl!("Copy All"))).clicked() { + run(app, "ui.copyScriptConsole", json!({})); + } + }); +} + +/// The panel menu: Clear, Copy Selected and Copy All. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let count = app.session.script_console().lines().len(); + let selection: Option<(u64, u64)> = super::pstate(ui.ctx(), SELECTION); + if crate::widgets::menu_item(ui, tl!("Clear"), count > 0, false) { + run(app, "script.console.clear", json!({})); + } + if crate::widgets::menu_item(ui, tl!("Copy Selected"), selection.is_some(), false) + && let Some((first, last)) = selection + { + run(app, "ui.copyScriptConsole", json!({"first": first, "last": last})); + } + if crate::widgets::menu_item(ui, tl!("Copy All"), count > 0, false) { + run(app, "ui.copyScriptConsole", json!({})); + } +} + +fn run(app: &mut VectorcraftApp, id: &str, params: Value) { + if let Err(e) = app.run(id, params) { + app.ui.status = e; + } +} diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 38cfa604e..04709b42c 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -83,7 +83,26 @@ fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, ext widgets::dropdown_names(ui, id, super::label_or_name(current, extra == Some(current)), &refs, 210.0).and_then(|i| names.get(i).cloned()) } +/// The Color Settings section (Edit › Color Settings… / Assign Profile…), scrolled into view once +/// when one of those commands opened the panel ([`crate::state::UiState::color_settings_scroll`]). fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { + let top = ui.cursor().top(); + settings_rows(app, ui); + // Edit › Color Settings… / Assign Profile… opened the panel for this section: scroll it into + // view once, as the plates and Proof Setup above it fill a short window (#6419). At once, not + // animated: the panel has just opened. egui moves the scroll area on a later frame, so ask for + // one rather than wait for the pointer to move. + if app.ui.color_settings_scroll && !ui.is_sizing_pass() { + let rect = egui::Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=ui.min_rect().bottom().max(top)); + ui.scroll_to_rect_animation(rect, Some(egui::Align::TOP), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + app.ui.color_settings_scroll = false; + } +} + +/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, Load +/// Profile… and Gamut Check, and the profile the active document is assigned. +fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { let st = cms::active_settings(); widgets::section_header(ui, tl!("Color Settings")); ui.horizontal(|ui| { @@ -215,4 +234,55 @@ mod tests { assert!(panel.iter().any(|(id, _)| id == "tritanopia")); assert!(panel.iter().all(|(id, _)| cms::ProofTarget::parse(id).is_some_and(|t| t.id() == *id))); } + + /// #6419: Edit › Assign Profile… and Color Settings… show the panel scrolled to its Color + /// Settings section, which a window shorter than the panel (a floated panel stops at 320 + /// points) otherwise shows only after scrolling past the plates and Proof Setup. Once: a + /// scroll away afterwards stays. + #[test] + fn color_settings_and_assign_profile_scroll_the_color_settings_into_view() { + const VIEW: f32 = 120.0; + fn find(s: &egui::Shape, text: &str) -> Option { + match s { + egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.y), + egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, text)), + _ => None, + } + } + // One frame of the panel in a scroll area VIEW points high, scrolled to `offset` when + // given: where the section's Black Point Compensation box shows in the view. + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, offset: Option) -> f32 { + let mut top = 0.0; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + top = ui.cursor().top(); + let mut area = egui::ScrollArea::vertical().id_salt("separations").max_height(VIEW); + if let Some(y) = offset { + area = area.vertical_scroll_offset(y); + } + area.show(ui, |ui| show(app, ui)); + }); + out.textures_delta.clear(); + out.shapes.iter().find_map(|c| find(&c.shape, "Use Black Point Compensation")).unwrap() - top + } + let visible = |y: f32| (0.0..VIEW).contains(&y); + for id in ["edit.assignProfile", "edit.colorSettings"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let ctx = egui::Context::default(); + let before = frame(&ctx, &mut app, None); + assert!(!visible(before), "the section starts below the view: {before}"); + app.run(id, json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert!(!app.ui.color_settings_scroll); + // egui moves a scroll area on the frame after the request and paints it moved on the + // one after that; both ask for a repaint. + assert!(ctx.has_requested_repaint()); + frame(&ctx, &mut app, None); + assert!(ctx.has_requested_repaint()); + let shown = frame(&ctx, &mut app, None); + assert!(visible(shown), "{id} scrolls the section into the view: {shown}"); + frame(&ctx, &mut app, Some(0.0)); + assert_eq!(frame(&ctx, &mut app, None), before, "scrolled back to the top, it stays there"); + } + } } diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 0431d6dec..3d486dfe2 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -1994,3 +1994,33 @@ fn the_drop_buttons_light_up_while_a_row_is_dragged() { assert_eq!(drop_buttons_on(&ctx, &mut app, vec![]), [false, false], "dark again once it is released"); assert_eq!(stack_items(&app).len(), 2, "released elsewhere: nothing removed"); } + +/// #6429: a dash length typed into the Stroke panel that the Appearance panel's "Stroke:" row or +/// the Properties panel's Stroke link opens sticks when a click outside closes the popup, as it +/// does when Enter ends the edit. The popup used to close without drawing again, so the field +/// never saw itself lose the keyboard and the value went back. +#[test] +fn a_dash_typed_in_a_stroke_popup_sticks_when_a_click_outside_closes_it() { + type Panel = fn(&mut VectorcraftApp, &mut egui::Ui); + let hosts: [(&str, Panel); 2] = [("Stroke:", appearance::show), ("Stroke", super::properties::show)]; + for (link, show) in hosts { + let mut app = app_with_rect(); + run(&mut app, "stroke.set", json!({"dash": [10.0]})); + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, link).center(), show); + // The popup's first dash field: a click gives it the keyboard with its text selected. + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "10").center(), show); + frame_events(&ctx, &mut app, vec![egui::Event::Text("4".into())], show); + let dash = |app: &VectorcraftApp| current_stroke(app).and_then(|s| s.dash).map(|d| d.pattern); + assert_eq!(dash(&app), Some(vec![10.0]), "{link}: nothing set while typing"); + click(&ctx, &mut app, egui::pos2(5.0, 3000.0), show); + let surrender = ctx.options(|o| o.input_options.surrender_focus_on); + assert_eq!(surrender, egui::SurrenderFocusOn::Clicks, "{link}: fields let go of the keyboard on a click again"); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(dash(&app).and_then(|p| p.first().copied()), Some(4.0), "{link}: the typed dash stuck: {:?}", dash(&app)); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(!texts.iter().any(|(t, _)| t == "Dashed Line"), "{link}: the popup closed"); + } +} diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 09ed75af7..6918e9118 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -127,7 +127,17 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Brush Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.brushLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("art-ribbons"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes")]); + assert_eq!( + opened, + [ + json!("art-ribbons"), + json!("calligraphic-nibs"), + json!("pattern-borders"), + json!("scatter-shapes"), + json!("game-trails"), + json!("energy-beams") + ] + ); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); diff --git a/crates/ui-egui/src/panels/tests_long_popups.rs b/crates/ui-egui/src/panels/tests_long_popups.rs index 8597ea995..c562d3540 100644 --- a/crates/ui-egui/src/panels/tests_long_popups.rs +++ b/crates/ui-egui/src/panels/tests_long_popups.rs @@ -1,4 +1,5 @@ -//! A headless window for checking that a panel's popup with no fixed length stays inside the window. +//! A headless window for checking that a panel's popup with no fixed length stays inside the window, +//! and the tests of menus built on `widgets::menu_scroll` that no panel draws. use egui::{Event, PointerButton, Pos2, Rect, Ui, vec2}; @@ -20,6 +21,7 @@ type Draw<'a> = Box; pub(super) struct PopupWindow<'a> { ctx: egui::Context, time: f64, + width: f32, height: f32, draw: Draw<'a>, texts: Vec<(String, Rect)>, @@ -29,7 +31,7 @@ impl<'a> PopupWindow<'a> { pub(super) fn new(app: &mut VectorcraftApp, height: f32, draw: impl FnMut(&mut VectorcraftApp, &mut Ui) + 'a) -> Self { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - let mut window = Self { ctx, time: 0.0, height, draw: Box::new(draw), texts: vec![] }; + let mut window = Self { ctx, time: 0.0, width: WIDTH, height, draw: Box::new(draw), texts: vec![] }; window.frames(app, 2); window } @@ -37,7 +39,7 @@ impl<'a> PopupWindow<'a> { /// One frame with `events`. fn frame(&mut self, app: &mut VectorcraftApp, events: Vec) { self.time += 1.0 / 60.0; - let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(WIDTH, self.height))); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, self.height))); let input = egui::RawInput { time: Some(self.time), screen_rect, events, ..Default::default() }; let draw = &mut self.draw; let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); @@ -45,6 +47,12 @@ impl<'a> PopupWindow<'a> { self.texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); } + /// Makes the window `width` pt wide and draws the frames that takes. + pub(super) fn resize(&mut self, app: &mut VectorcraftApp, width: f32) { + self.width = width; + self.frames(app, OPEN_FRAMES); + } + fn frames(&mut self, app: &mut VectorcraftApp, count: usize) { for _ in 0..count { self.frame(app, vec![]); @@ -57,6 +65,14 @@ impl<'a> PopupWindow<'a> { self.texts.iter().rfind(|(text, _)| text.trim() == wanted).map(|(_, rect)| *rect) } + /// The rect of the popup drawn on top: the last shown area in the foreground. + pub(super) fn popup(&self) -> Option { + self.ctx.memory(|m| { + let top = m.layer_ids().filter(|layer| layer.order == egui::Order::Foreground && m.areas().is_visible(layer)).last()?; + m.area_rect(top.id) + }) + } + /// The rects of the last frame's clickable widgets `size` across, in the order they were drawn. pub(super) fn clickable(&self, size: egui::Vec2) -> Vec { self.ctx.viewport(|vp| { @@ -80,9 +96,14 @@ impl<'a> PopupWindow<'a> { /// Turns the wheel far down with the pointer at `over`, and lets the scrolling finish. pub(super) fn wheel_down(&mut self, app: &mut VectorcraftApp, over: Pos2) { + self.wheel(app, over, 5000.0); + } + + /// Turns the wheel `down` pt down with the pointer at `over`, and lets the scrolling finish. + pub(super) fn wheel(&mut self, app: &mut VectorcraftApp, over: Pos2, down: f32) { let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, -5000.0), + delta: vec2(0.0, -down), phase: egui::TouchPhase::Move, modifiers: Default::default(), source: egui::MouseWheelSource::Unknown, @@ -92,6 +113,70 @@ impl<'a> PopupWindow<'a> { } } +/// The frames a popup is watched for after it opens. +const STEADY_FRAMES: usize = 12; + +/// In a window `height` pt tall, the popup of the button `pick` finds draws its row `first` at the +/// same place on every frame it is shown, from the click on, inside the window. With the pointer on +/// that row, the row's submenu opens and every text of both stays in place too. Returns where the +/// row is drawn. +pub(super) fn assert_popup_stays_put( + app: &mut VectorcraftApp, + height: f32, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + first: &str, +) -> Rect { + let opened = Opened { with: PointerButton::Primary, first, hover: first }; + assert_popup_opened_stays_put(app, height, draw, pick, opened).0 +} + +/// How a popup is opened and checked: the mouse button `with` opens it, `first` is its first row, +/// and the pointer then moves onto the row `hover` to open its submenu. +struct Opened<'s> { + with: PointerButton, + first: &'s str, + hover: &'s str, +} + +/// [`assert_popup_stays_put`] for a popup opened as `opened` says. Returns where the first row is +/// drawn and where every text is drawn with the submenu open. +fn assert_popup_opened_stays_put( + app: &mut VectorcraftApp, + height: f32, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + opened: Opened, +) -> (Rect, Vec<(String, Rect)>) { + let Opened { with, first, hover } = opened; + let mut window = PopupWindow::new(app, height, draw); + let at = pick(&window); + let button = |pressed| Event::PointerButton { pos: at, button: with, pressed, modifiers: Default::default() }; + window.frame(app, vec![Event::PointerMoved(at), button(true)]); + window.frame(app, vec![button(false)]); + let mut seen = vec![]; + for _ in 0..STEADY_FRAMES { + seen.extend(window.text(first).map(|row| row.min)); + window.frame(app, vec![]); + } + let row = window.text(first).unwrap_or_else(|| panic!("the popup's first row, {first}")); + seen.push(row.min); + assert!(seen.iter().all(|seen| *seen == row.min), "{first} is drawn at one place on every frame: {seen:?}"); + assert!(row.top() >= 0.0 && row.bottom() <= height, "{first} at {row:?} is inside the {height} pt window"); + let popup_texts = window.texts.clone(); + let over = window.text(hover).unwrap_or_else(|| panic!("the popup's row {hover}")); + window.frame(app, vec![Event::PointerMoved(over.center())]); + window.frames(app, OPEN_FRAMES); + let places = |window: &PopupWindow| window.texts.iter().map(|(text, rect)| (text.clone(), rect.min)).collect::>(); + let shown = places(&window); + assert!(shown.iter().any(|(text, _)| !popup_texts.iter().any(|(old, _)| old == text)), "the pointer on {hover} opens its submenu"); + for _ in 0..STEADY_FRAMES { + window.frame(app, vec![]); + assert_eq!(places(&window), shown, "the popup and its submenu stay in place"); + } + (row, window.texts.clone()) +} + /// The Add Effect popup's first and last rows with no plug-ins: the effect catalog's groups, in order. pub(super) fn fx_menu_ends() -> (String, String) { let mut groups: Vec<&str> = vec![]; @@ -124,6 +209,80 @@ pub(super) fn assert_last_row_scrolls_into_view( assert!(row.bottom() <= height, "{last} at {row:?} is inside the {height} pt window"); } +/// In windows 200, 400 and 900 pt tall, the popup of the button `pick` finds draws each of its rows +/// `rows` that are in view on one line, as tall as the row with the shortest name, and left of the +/// scroll bar's column, whether or not its list scrolls. The row `widest` is drawn the widest; the +/// wheel brings it into view when the popup opens without it. The popup and that row keep their +/// place and size while the popup stays open. After the 200 pt window becomes `narrow` pt wide, +/// the popup and its rows in view stay inside the window. +pub(super) fn assert_rows_stay_on_one_line( + app: &mut VectorcraftApp, + mut draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl Fn(&PopupWindow) -> Pos2, + rows: &[String], + widest: &str, + narrow: f32, +) { + let bar = crate::widgets::list_scroll_style().allocated_width(); + let in_view = + |window: &PopupWindow| -> Vec<(String, Rect)> { rows.iter().filter_map(|row| window.text(row).map(|rect| (row.clone(), rect))).collect() }; + let assert_inside = |window: &PopupWindow, drawn: &[(String, Rect)], name: &str| { + let popup = window.popup().unwrap_or_else(|| panic!("the popup is open in the {name} window")); + for (row, rect) in drawn { + assert!( + rect.left() >= popup.left() && rect.right() <= popup.right() - bar, + "{row} at {rect:?} is drawn left of the scroll bar's column in the popup at {popup:?} in the {name} window" + ); + } + popup + }; + for height in [200.0, 400.0, TALL] { + let mut window = PopupWindow::new(app, height, |app, ui| draw(app, ui)); + let button = pick(&window); + window.click(app, button); + for _ in 0..rows.len() { + if window.text(widest).is_some() { + break; + } + let drawn = in_view(&window); + let Some((_, middle)) = drawn.get(drawn.len() / 2) else { break }; + window.wheel(app, middle.center(), 20.0); + } + let opened = window.text(widest).unwrap_or_else(|| panic!("the popup's row {widest} is in view in the {height} pt window")); + let drawn = in_view(&window); + assert!(drawn.len() >= 3, "the popup's rows are in view in the {height} pt window: {drawn:?}"); + let Some((shortest, line)) = drawn.iter().min_by_key(|(row, _)| row.chars().count()) else { panic!("no rows in view") }; + let line = line.height(); + for (row, rect) in &drawn { + assert!( + rect.height() <= line + 0.5, + "{row} at {rect:?} is drawn on one line, as tall as {shortest} ({line} pt), in the {height} pt window" + ); + } + for (row, rect) in &drawn { + assert!(rect.width() <= opened.width() + 0.5, "{widest} is drawn as wide as {row} at {rect:?} or wider in the {height} pt window"); + } + let popup = assert_inside(&window, &drawn, &format!("{height} pt")); + // The popup's size settles within a frame of opening; after that it stays the same. + window.frames(app, 30); + let settled = window.popup(); + assert!( + settled.is_some_and(|settled| (settled.width() - popup.width()).abs() <= 0.5 && settled.min == popup.min), + "the popup at {settled:?} stays where it opened, at {popup:?}, in the {height} pt window" + ); + window.frames(app, 300); + assert_eq!(window.text(widest), Some(opened), "{widest} keeps its place and size while the popup stays open in the {height} pt window"); + assert_eq!(window.popup(), settled, "the popup keeps its place and size while it stays open in the {height} pt window"); + if height == 200.0 { + window.resize(app, narrow); + let drawn = in_view(&window); + assert!(!drawn.is_empty(), "the popup's rows are in view in the {narrow} pt wide window"); + let popup = assert_inside(&window, &drawn, &format!("{narrow} pt wide")); + assert!(popup.left() >= 0.0, "the popup at {popup:?} starts inside the {narrow} pt wide window"); + } + } +} + /// In a tall window the popup of the button `pick` finds shows its rows `first` to `last` without /// scrolling, and the wheel moves neither. pub(super) fn assert_short_list_does_not_scroll( @@ -145,3 +304,111 @@ pub(super) fn assert_short_list_does_not_scroll( window.wheel_down(app, before.0.center()); assert_eq!(shown(&window), before, "a list that fits does not scroll"); } + +/// Rows in each list of [`long_menu`]: more than any window in these tests holds. +const LONG: usize = 40; + +/// A menu of rows `Row 0`, `Row 1`…, each a submenu of as many items. +fn long_menu(ui: &mut Ui) { + menu_of(ui, LONG); +} + +/// A menu of `rows` rows `Row 0`, `Row 1`…, each a submenu of [`LONG`] items. +fn menu_of(ui: &mut Ui, rows: usize) { + crate::widgets::menu_scroll(ui, |ui| { + for row in 0..rows { + ui.menu_button(format!("Row {row}"), |ui| { + crate::widgets::menu_scroll(ui, |ui| { + for item in 0..LONG { + let _ = ui.button(format!("Item {row}.{item}")); + } + }) + }); + } + }); +} + +/// A blank document, for windows that draw no panel. +fn app() -> VectorcraftApp { + VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()) +} + +/// A long menu opened from a button `y` pt down a window stays at one place on every frame, and so +/// does its submenu, whether it opens below the button, above it, or in a window shorter than it. +#[test] +fn a_long_menu_stays_put_wherever_its_button_is() { + for height in [160.0, 300.0, 600.0] { + for y in [4.0, height / 2.0 - 12.0, height - 28.0] { + let draw = move |_: &mut VectorcraftApp, ui: &mut Ui| { + ui.add_space(y); + let button = ui.button("Open"); + egui::Popup::menu(&button).show(long_menu); + }; + let open = |window: &PopupWindow| window.text("Open").expect("the button").center(); + assert_popup_stays_put(&mut app(), height, draw, open, "Row 0"); + } + } +} + +/// A long context menu opened near the top, the middle or the bottom of a short window stays at +/// one place on every frame, and so does its submenu. +#[test] +fn a_long_context_menu_stays_put_wherever_it_opens() { + for height in [160.0, 300.0, 600.0] { + for y in [4.0, height / 2.0, height - 4.0] { + let draw = |_: &mut VectorcraftApp, ui: &mut Ui| { + let area = ui.interact(ui.max_rect(), egui::Id::unique("context-area"), egui::Sense::click()); + area.context_menu(long_menu); + }; + let at = move |_: &PopupWindow| Pos2::new(20.0, y); + let opened = Opened { with: PointerButton::Secondary, first: "Row 0", hover: "Row 0" }; + assert_popup_opened_stays_put(&mut app(), height, draw, at, opened); + } + } +} + +/// The long submenu of a row near the top of a window opens down from the row's top, and its list +/// reaches down to the window's bottom less the menu gap. +#[test] +fn a_long_submenu_of_a_high_row_reaches_down_to_the_window_bottom() { + for height in [300.0, 600.0] { + let draw = |_: &mut VectorcraftApp, ui: &mut Ui| { + ui.add_space(4.0); + let button = ui.button("Open"); + egui::Popup::menu(&button).show(|ui| menu_of(ui, 6)); + }; + let open = |window: &PopupWindow| window.text("Open").expect("the button").center(); + let mut app = app(); + let mut window = PopupWindow::new(&mut app, height, draw); + let at = open(&window); + window.click(&mut app, at); + let row = window.text("Row 0").expect("the menu's first row"); + window.frame(&mut app, vec![Event::PointerMoved(row.center())]); + window.frames(&mut app, OPEN_FRAMES); + let item = window.text("Item 0.0").expect("the submenu's first item"); + let areas = window.ctx.memory(|m| m.layer_ids().filter_map(|layer| m.area_rect(layer.id)).collect::>()); + let submenu = areas.into_iter().filter(|rect| rect.contains(item.center())).min_by(|a, b| a.area().total_cmp(&b.area())); + let submenu = submenu.expect("the submenu's area"); + assert!(item.top() < row.bottom(), "the submenu opens down from {row:?}: {item:?}"); + assert!((height - 12.0..=height).contains(&submenu.bottom()), "in a {height} pt window the submenu reaches its bottom: {submenu:?}"); + } +} + +/// The submenu of a row near the bottom of a window opens up from the row and stays at one place +/// on every frame. +#[test] +fn a_long_submenu_of_a_low_row_opens_up_and_stays_put() { + for height in [300.0, 600.0] { + let draw = move |_: &mut VectorcraftApp, ui: &mut Ui| { + ui.add_space(height - 28.0); + let button = ui.button("Open"); + egui::Popup::menu(&button).show(|ui| menu_of(ui, 6)); + }; + let open = |window: &PopupWindow| window.text("Open").expect("the button").center(); + let opened = Opened { with: PointerButton::Primary, first: "Row 0", hover: "Row 5" }; + let (_, texts) = assert_popup_opened_stays_put(&mut app(), height, draw, open, opened); + let at = |wanted: &str| texts.iter().rfind(|(text, _)| text.trim() == wanted).map(|(_, rect)| *rect); + let (row, item) = (at("Row 5").expect("the row"), at("Item 5.0").expect("the submenu's first item")); + assert!(item.top() < row.top() - 100.0, "in a {height} pt window the submenu opens up from {row:?}: {item:?}"); + } +} diff --git a/crates/ui-egui/src/panels/tests_script_console.rs b/crates/ui-egui/src/panels/tests_script_console.rs new file mode 100644 index 000000000..c1fbcc4da --- /dev/null +++ b/crates/ui-egui/src/panels/tests_script_console.rs @@ -0,0 +1,143 @@ +//! The Script Console panel drawn headless: a full console lays out only the rows in view, output +//! goes on while the panel is hidden, Clear works during a run, and Copy Selected and Copy All +//! through `ui.copyScriptConsole`. + +use serde_json::json; +use vectorcraft_engine::Session; + +use super::pstate; +use super::script_console::{DRAWN, ID, show, shown}; +use crate::tests_labels::painted_text; +use crate::{Services, VectorcraftApp, script_runs}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + app +} + +/// Runs `source` to its end, a frame at a time, as the app does. +fn run(app: &mut VectorcraftApp, source: &str) { + app.session.script_frames = true; + app.run("script.run", json!({"source": source, "name": "c.js"})).unwrap(); + for _ in 0..10_000 { + if !app.session.script_running() { + return; + } + script_runs::poll(app); + } + panic!("the script did not end"); +} + +#[test] +fn a_full_console_lays_out_only_the_rows_in_view() { + let mut app = app(); + run(&mut app, "for (var i = 0; i < 3000; i++) $.writeln('line ' + i)"); + assert_eq!(app.session.script_console().lines().len(), vectorcraft_engine::script::console::MAX_CONSOLE_LINES); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut drawn = 0; + for _ in 0..2 { + ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui); + drawn = pstate::(ui.ctx(), DRAWN); + }) + .textures_delta + .clear(); + } + assert!(drawn > 0 && drawn < 40, "{drawn} rows laid out"); + let text = painted_text(&mut app, show); + assert!(text.contains("#1 c.js: line 2999"), "the newest line is in view: {text}"); + assert!(text.contains("lines dropped"), "the panel says lines were dropped: {text}"); +} + +#[test] +fn output_goes_on_while_the_panel_is_hidden_and_shows_when_it_opens() { + let mut app = app(); + assert!(crate::state::rail_panel(ID).is_some()); + run(&mut app, "$.writeln('hidden'); vc.log({n: 1}); throw new Error('late')"); + app.run("window.panel", json!({"panel": ID})).unwrap(); + let text = painted_text(&mut app, show); + for want in ["#1 c.js: ▶", "#1 c.js: hidden", "#1 c.js: {\"n\":1}", "#1 c.js: c.js:1:", "#1 c.js: ■ Ended with an error", "host calls"] { + assert!(text.contains(want), "{want} in {text}"); + } +} + +#[test] +fn clear_works_during_a_run_and_the_run_goes_on_writing() { + let mut app = app(); + app.session.script_frames = true; + app.run("script.run", json!({"source": "$.writeln('before'); while (true) {}", "name": "c.js"})).unwrap(); + script_runs::poll(&mut app); + assert!(app.session.script_running()); + assert_eq!(app.run("script.console.clear", json!({})).unwrap()["cleared"], 2); + app.run("script.stop", json!({})).unwrap(); + script_runs::poll(&mut app); + assert!(!app.session.script_running()); + let text = painted_text(&mut app, show); + assert!(!text.contains("before") && text.contains("■ Stopped"), "{text}"); +} + +#[test] +fn copy_all_and_copy_selected_put_the_lines_on_the_clipboard() { + let mut app = app(); + run(&mut app, "$.writeln('one'); $.writeln('two'); $.writeln('three')"); + assert_eq!(app.run("ui.copyScriptConsole", json!({})).unwrap()["lines"], 6); + let all = app.clipboard_out.take().unwrap(); + assert!(all.starts_with("#1 c.js: ▶\n#1 c.js: one\n#1 c.js: two\n"), "{all}"); + assert_eq!(app.run("ui.copyScriptConsole", json!({"first": 2, "last": 3})).unwrap()["lines"], 2); + assert_eq!(app.clipboard_out.take().as_deref(), Some("#1 c.js: two\n#1 c.js: three")); + for bad in [json!({"first": 3, "last": 2}), json!({"first": 1}), json!({"first": 0, "last": 99}), json!({"first": "1", "last": 2})] { + assert!(app.run("ui.copyScriptConsole", bad.clone()).is_err(), "{bad}"); + } + assert!(app.clipboard_out.is_none(), "a refused copy copies nothing"); + run(&mut app, "for (var i = 0; i < 2100; i++) $.writeln(i)"); + app.run("ui.copyScriptConsole", json!({})).unwrap(); + let all = app.clipboard_out.take().unwrap(); + assert!(all.lines().next().is_some_and(|l| l.contains("lines dropped")), "Copy All starts with the notice: {}", &all[..80]); +} + +/// A report line reads in English as `script.console` gives it: "time unavailable" for a run +/// without a clock, one host call in the singular, and the slowest commands. +#[test] +fn a_report_line_reads_as_script_console_gives_it() { + use vectorcraft_engine::script::console::{ConsoleLine, Entry, Slow}; + let slow = vec![Slow { id: "a.b".into(), calls: 2, total_ms: 7, max_ms: 4 }, Slow { id: "c".into(), calls: 1, total_ms: 1, max_ms: 1 }]; + for ms in [None, Some(0), Some(12)] { + for host_calls in [0, 1, 2] { + for slowest in [vec![], slow.clone()] { + let entry = Entry::Report { ms, host_calls, slowest }; + let want = format!("#3 c.js: {}", entry.text()); + assert_eq!(shown(&ConsoleLine { run: 3, name: "c.js".into(), entry }), want); + } + } + } + let line = |ms, host_calls| shown(&ConsoleLine { run: 1, name: "c.js".into(), entry: Entry::Report { ms, host_calls, slowest: vec![] } }); + assert_eq!(line(None, 1), "#1 c.js: time unavailable, 1 host call"); + assert_eq!(line(Some(0), 2), "#1 c.js: 0 ms, 2 host calls"); +} + +/// The notice counts the lines dropped and the lines cut each in its own form, a count of one in +/// the singular; Copy All starts with it. +#[test] +fn the_notice_counts_one_line_in_the_singular() { + for (dropped, cut, want) in [ + (1, 0, "1 earlier line dropped, 0 lines cut"), + (0, 1, "0 earlier lines dropped, 1 line cut"), + (1, 2, "1 earlier line dropped, 2 lines cut"), + (2, 1, "2 earlier lines dropped, 1 line cut"), + (2, 2, "2 earlier lines dropped, 2 lines cut"), + ] { + let mut app = app(); + // A run adds a start, an end and a report line to its output lines. + let lines = if dropped > 0 { vectorcraft_engine::script::console::MAX_CONSOLE_LINES - 3 + dropped } else { cut + 1 }; + let source = format!("var big = 'x'; while (big.length < 5000) big += big; for (var i = 0; i < {lines}; i++) $.writeln(i < {cut} ? big : i)"); + run(&mut app, &source); + let c = app.session.script_console(); + assert_eq!((c.dropped_lines(), c.truncated_lines()), (dropped as u64, cut as u64)); + app.run("ui.copyScriptConsole", json!({})).unwrap(); + let all = app.clipboard_out.take().unwrap(); + assert_eq!(all.lines().next(), Some(want)); + assert!(painted_text(&mut app, show).contains(want)); + } +} diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 4ab96f35a..2c7203270 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -243,3 +243,72 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { } assert_eq!(app.session.shown_stroke().unwrap().width, 1.0); } + +/// The screen rect of the Weight spinner in the Stroke panel, found by its accessibility label, so +/// the tests do not depend on where the panel puts the field. +fn weight_spinner_rect(ctx: &egui::Context, app: &mut VectorcraftApp) -> egui::Rect { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| stroke::show(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("accessibility is on").nodes; + let b = nodes.iter().find(|(_, n)| n.label() == Some("Step up or down")).expect("the weight spinner").1.bounds().expect("its bounds"); + egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)) +} + +/// With objects of different weights selected the Weight field is blank, and its spinner steps +/// from 0.25 pt (#5349): up gives 0.5 pt and down gives 0 pt, for all of them, whatever the +/// Units > Stroke. +#[test] +fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { + use super::tests_appearance::frame_raw; + + for (unit, up, after) in [("points", true, 0.5), ("points", false, 0.0), ("millimeters", true, 0.5), ("millimeters", false, 0.0)] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + app.session.prefs.units_stroke = unit.into(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + texts(&ctx, &mut app, stroke::show); + let sr = weight_spinner_rect(&ctx, &mut app); + // The up arrow is the upper half of the spinner, the down arrow the lower half. + let pos = egui::pos2(sr.center().x, sr.center().y + if up { -sr.height() / 4.0 } else { sr.height() / 4.0 }); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_raw(&ctx, &mut app, egui::RawInput { events: vec![event], ..Default::default() }, stroke::show); + } + for id in [a.0, b] { + run(&mut app, "select.set", json!({ "ids": [id] })); + assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, up: {up}, {unit}"); + } + } +} + +/// ↑/↓ in the focused blank Weight field step from 0.25 pt too, for every selected object. +#[test] +fn the_arrow_keys_in_a_blank_weight_step_from_a_quarter_point() { + for (key, after) in [(egui::Key::ArrowUp, 0.5), (egui::Key::ArrowDown, 0.0)] { + // A new context each time: the field keeps its text in the context, and the 0.5 pt left by ArrowUp + // would make the blank field of the next run start from 0.5 pt. + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + texts(&ctx, &mut app, stroke::show); + // The blank field, right of the spinner. + let sr = weight_spinner_rect(&ctx, &mut app); + click(&ctx, &mut app, egui::pos2(sr.right() + 30.0, sr.center().y), stroke::show); + let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![press], stroke::show); + for id in [a.0, b] { + run(&mut app, "select.set", json!({ "ids": [id] })); + assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, {key:?}"); + } + } +} diff --git a/crates/ui-egui/src/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index 6482bf312..1e71583a4 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -113,7 +113,7 @@ fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Symbol Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.symbolLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames")]); + assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons")]); let has = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(has("window.symbolLibrary.other"), Some(true)); assert_eq!(has("ui.saveSymbolLibrary"), Some(false), "no symbols to save yet"); diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 2596b218b..25ddd4076 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -223,7 +223,7 @@ pub(crate) fn selected_guides(app: &VectorcraftApp) -> Option<(Option, Opti return None; } let least = |vertical: bool| { - st.selection.guides.iter().filter_map(|i| st.doc.guides.get(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) + st.selection.guides.iter().filter_map(|i| st.doc.ruler_guide(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) }; Some((least(true), least(false))) } diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 87aa5cd13..a8987855e 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -97,6 +97,7 @@ pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) { app.run("dataset.delete", json!({})).ok(); } +/// Draw document variables, data sets and their two-row footer of capture and binding controls. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { widgets::dim_label(ui, tl!("No document")); @@ -209,46 +210,49 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The bottom bar: make the selection dynamic, define and delete what the lists show, and // lock the variables. - ui.separator(); - ui.horizontal(|ui| { - if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type && !locked, 24.0).clicked() { - app.run("variable.makeTextDynamic", json!({})).ok(); - } - if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection && !locked, 24.0).clicked() { - app.run("variable.makeVisibilityDynamic", json!({})).ok(); - } - if widgets::icon_button_enabled(ui, "image", tl!("Make Image Dynamic"), false, single_image && !locked, 24.0).clicked() { - app.run("variable.makeImageDynamic", json!({})).ok(); - } - if widgets::icon_button_enabled(ui, "chart-column", tl!("Make Graph Dynamic"), false, selection_graph && !locked, 24.0).clicked() { - app.run("variable.makeGraphDynamic", json!({})).ok(); - } - if widgets::icon_button_enabled(ui, "plus", tl!("New Variable…"), false, !locked, 24.0).clicked() { - crate::menus::invoke(app, "variable.define", json!({})); - } - if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() { - app.run("variable.unbind", json!({"ids": selected})).ok(); - } - // The bin deletes the highlighted variable only, as the reference panel's does. - if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some() && !locked, 24.0).clicked() { - delete_highlighted(app); - } - // The lock button is drawn pressed while the variables are locked. - let tip = if locked { tl!("Unlock Variables") } else { tl!("Lock Variables") }; - if widgets::icon_button(ui, "lock", tip, locked, 24.0).clicked() { - app.run("variable.lock", json!({})).ok(); - } - }); - ui.horizontal(|ui| { - // Capture records what the art holds now as a new row (paste: take it in); Update - // writes the art back into the row that is active (save: write it out). - if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() { - app.run("dataset.capture", json!({})).ok(); - } - let can_update = active.is_some(); - if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() { - app.run("dataset.update", json!({})).ok(); - } + let footer_height = 48.0 + 2.0 * ui.spacing().item_spacing.y + 1.0; + widgets::panel_footer(ui, footer_height, |ui| { + ui.add(egui::Separator::default().spacing(1.0)); + ui.horizontal(|ui| { + if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type && !locked, 24.0).clicked() { + app.run("variable.makeTextDynamic", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection && !locked, 24.0).clicked() { + app.run("variable.makeVisibilityDynamic", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "image", tl!("Make Image Dynamic"), false, single_image && !locked, 24.0).clicked() { + app.run("variable.makeImageDynamic", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "chart-column", tl!("Make Graph Dynamic"), false, selection_graph && !locked, 24.0).clicked() { + app.run("variable.makeGraphDynamic", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "plus", tl!("New Variable…"), false, !locked, 24.0).clicked() { + crate::menus::invoke(app, "variable.define", json!({})); + } + if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() { + app.run("variable.unbind", json!({"ids": selected})).ok(); + } + // The bin deletes the highlighted variable only, as the reference panel's does. + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some() && !locked, 24.0).clicked() { + delete_highlighted(app); + } + // The lock button is drawn pressed while the variables are locked. + let tip = if locked { tl!("Unlock Variables") } else { tl!("Lock Variables") }; + if widgets::icon_button(ui, "lock", tip, locked, 24.0).clicked() { + app.run("variable.lock", json!({})).ok(); + } + }); + ui.horizontal(|ui| { + // Capture records what the art holds now as a new row (paste: take it in); Update + // writes the art back into the row that is active (save: write it out). + if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() { + app.run("dataset.capture", json!({})).ok(); + } + let can_update = active.is_some(); + if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() { + app.run("dataset.update", json!({})).ok(); + } + }); }); } diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index fad2d45e7..c9662109a 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -19,6 +19,9 @@ pub const UI_FIELDS: &[(&str, &str, &str)] = &[ ("__taskBar", "General", "Enable Contextual Task Bar"), ]; +/// The Units ▸ General value Reset Preferences put in the dialog, while it is still the field's value. +const RESET_UNITS: &str = "__resetUnits"; + /// Whether User Interface shows VectorCraft Title Bar on Every Desktop (`appTitleBar`): Linux and the /// BSDs, where the desktop may draw the window's frame. macOS and Windows never read it. const APP_TITLE_BAR_SHOWN: bool = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); @@ -194,12 +197,21 @@ fn found_paint(ui: &mut egui::Ui, d: &mut Dialog, mark: Option<(egui::layers::Sh } } +/// `field` of the Preferences dialog `d` is about to be `value` (a frame's edit or `ui.dialog.set`): +/// choosing another General unit after Reset Preferences makes OK set the document's units again. +pub(crate) fn note_set(d: &mut Dialog, field: &str, value: &Value) { + if field == "unitsGeneral" && d.fields.get(RESET_UNITS).is_some_and(|v| v != value) { + d.fields.remove(RESET_UNITS); + } +} + /// OK: validate and apply every preference in the dialog (nothing changes on error). pub fn confirm(app: &mut VectorcraftApp) -> Result { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; let values: Map = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); let touch_ws = values.get("touchWorkspace").and_then(Value::as_bool); - app.run("prefs.set", json!({ "values": values }))?; + let document_units = d.fields.get(RESET_UNITS).is_none_or(|v| values.get("unitsGeneral") != Some(v)); + app.run("prefs.set", json!({ "values": values, "documentUnits": document_units }))?; if touch_ws.is_some() { crate::workspaces::sync_touch_workspace(app, app.touch_screen); } @@ -388,6 +400,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); ui.add_space(14.0); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, tl!("Export Preferences…")).on_hover_text(tl!("Save the applied preferences to a JSON file")).clicked() + { + export_preferences(app, &mut d); + } + // Not on the web, which has no open dialog that returns a file here. + if crate::picks::can(app, &crate::picks::PickRequest::Open(crate::FilePick::default())) + && widgets::secondary_button(ui, tl!("Import Preferences…")) + .on_hover_text(tl!("Apply the preferences in a file Export Preferences saved")) + .clicked() + { + import_preferences(app, &mut d); + } + }); + ui.add_space(8.0); ui.horizontal(|ui| { if widgets::secondary_button(ui, tl!("Reset Preferences")) .on_hover_text(tl!("Restore every preference to its default (applied on OK)")) @@ -414,10 +441,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); }); - if reset { - for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() { - d.fields.insert(k, v); - } + if let Some(v) = d.fields.get("unitsGeneral").cloned() { + note_set(&mut d, "unitsGeneral", &v); } // The setting a search went to stays highlighted until another category is shown. let found = d.str("__found"); @@ -430,9 +455,23 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.dialog = None; } else if ok && let Err(e) = confirm(app) { app.status(e); + } else if reset && !ok { + crate::dialogs::confirm::ask_reset_prefs(app); } } +/// Reset Preferences, once its question is confirmed: every preference in the Preferences dialog +/// `d` takes its default value. The category, the search and the UI-only rows stay as they were; +/// nothing is applied until OK, which leaves the open document's units alone unless Units › +/// General is changed after the reset. +pub(crate) fn reset_fields(d: &mut Dialog) { + for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() { + d.fields.insert(k, v); + } + // OK resets the Units ▸ General preference and leaves the open document's units alone. + d.fields.insert(RESET_UNITS.into(), d.fields.get("unitsGeneral").cloned().unwrap_or(Value::Null)); +} + fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, cat: &str) { let t = Tokens::get(ui.ctx()); let mut section = ""; @@ -647,6 +686,67 @@ fn choose_folder(app: &mut VectorcraftApp, d: &mut Dialog, key: &'static str) { } } +/// The file name Export Preferences… suggests. +const PREFS_FILE_NAME: &str = "VectorCraft Preferences.json"; + +/// Export Preferences…: the applied preferences (`prefs.export`) go to a file picked in a save +/// dialog (the web downloads it). Changes in the dialog not yet applied with OK are not in it. +fn export_preferences(app: &mut VectorcraftApp, d: &mut Dialog) { + let r = + crate::picks::in_app_dialog(app, d, |app, _| crate::io::save_command_output_named(app, "prefs.export", PREFS_FILE_NAME, json!({})).map(drop)); + if let Err(e) = r + && e != "cancelled" + { + app.status(e); + } +} + +/// Import Preferences…: the preferences in a file picked in an open dialog are applied +/// (`prefs.import`), and the dialog shows them. On an error nothing changes and the status bar +/// says why. +fn import_preferences(app: &mut VectorcraftApp, d: &mut Dialog) { + let r = crate::picks::in_app_dialog(app, d, |app, d| { + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Preferences", &["json"])], ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) }; + app.run("prefs.import", json!({ "path": path }))?; + show_prefs(d, &app.session.prefs.to_json()); + app.status("Preferences imported."); + Ok(()) + }); + if let Err(e) = r { + app.status(e); + } +} + +/// After `prefs.import`: the preferences are saved with the UI state, Enable Touch Workspace takes +/// effect when the import changed it from `touch_workspace`, and an open Preferences dialog shows +/// the imported values. +pub(crate) fn imported(app: &mut VectorcraftApp, touch_workspace: bool) { + snapshot(app); + app.prefs_changed = true; + if app.session.prefs.touch_workspace != touch_workspace { + crate::workspaces::sync_touch_workspace(app, app.touch_screen); + } + let prefs = app.session.prefs.to_json(); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "preferences") { + show_prefs(d, &prefs); + } +} + +/// The Preferences dialog `d` shows the preferences `prefs` in place of its working copy. Units › +/// General shows the preference, and OK then leaves the open document's units alone unless the +/// field is changed. +fn show_prefs(d: &mut Dialog, prefs: &Value) { + for (k, v) in prefs.as_object().into_iter().flatten() { + if d.fields.contains_key(k) { + d.fields.insert(k.clone(), v.clone()); + } + } + if let Some(v) = d.fields.get("unitsGeneral").cloned() { + d.fields.insert(RESET_UNITS.into(), v); + } +} + /// Is one of the dialog's Units (General, Stroke, Type, East Asian Type) Picas? fn picas_in_use(d: &Dialog) -> bool { ["unitsGeneral", "unitsStroke", "unitsType", "unitsAsianType"].iter().any(|k| d.str(k) == vectorcraft_doc::Unit::Picas.key()) diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 93b84f488..95fa8085f 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -273,6 +273,110 @@ fn preferences_ok_changes_the_guide_lock_only_with_the_option() { assert!(!a.session.guides_locked(), "the option went off"); } +/// Click the Preferences dialog's button `label` with the pointer. +fn click_button(app: &mut VectorcraftApp, label: &str) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + // A window takes a sizing frame and fades in: each frame is a quarter second on. + let time = ctx.input(|i| i.time) + 0.25; + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))); + let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, v: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + }; + frame(app, vec![]); + let texts = frame(app, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, vec![egui::Event::PointerMoved(at)]); + frame(app, vec![press(true)]); + frame(app, vec![press(false)]); +} + +/// Click Reset Preferences and answer its question with OK: Preferences again, its working copy +/// at the defaults. +fn reset_preferences(app: &mut VectorcraftApp) { + click_button(app, "Reset Preferences"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::confirm::RESET_PREFS)); + crate::dialogs::confirm(app).unwrap(); +} + +/// Reset Preferences then OK resets the preferences, General included, and leaves the open +/// document's units, with no undo step in the document. +#[test] +fn reset_preferences_then_ok_leaves_the_documents_units() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + a.run("prefs.set", json!({"key": "keyboardIncrement", "value": 5})).unwrap(); + let undo = a.session.active().unwrap().history.undo.len(); + open(&mut a, Some("General")); + reset_preferences(&mut a); + let d = a.ui.dialog.as_ref().expect("Reset leaves the dialog open"); + assert_eq!(d.fields["keyboardIncrement"], Prefs::default().to_json()["keyboardIncrement"], "the working copy holds the defaults"); + assert_eq!(d.fields["unitsGeneral"], json!("points")); + confirm(&mut a).unwrap(); + assert_eq!(a.session.prefs.keyboard_increment, Prefs::default().keyboard_increment); + assert_eq!(a.session.prefs.units_general, Prefs::default().units_general); + let st = a.session.active().unwrap(); + assert_eq!(st.doc.units, vectorcraft_doc::Unit::Millimeters); + assert_eq!(st.history.undo.len(), undo, "no undo step"); +} + +/// After Reset Preferences, choosing a General unit in the dialog still sets the document's units +/// on OK, Points included once another unit was chosen in between. With no document open, Reset +/// Preferences resets General to its default. +#[test] +fn reset_preferences_keeps_an_edit_of_general_units() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + open(&mut a, Some("General")); + reset_preferences(&mut a); + a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("inches")); + confirm(&mut a).unwrap(); + assert_eq!(a.session.active().unwrap().doc.units, vectorcraft_doc::Unit::Inches); + assert_eq!(a.session.prefs.units_general, "inches"); + + // Between frames, as `ui.dialog.set` sets fields. + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + open(&mut a, Some("General")); + reset_preferences(&mut a); + for u in ["inches", "points"] { + crate::dialogs::set_field(a.ui.dialog.as_mut().unwrap(), "unitsGeneral".into(), json!(u)); + } + confirm(&mut a).unwrap(); + assert_eq!(a.session.active().unwrap().doc.units, vectorcraft_doc::Unit::Points); + + // Through a frame of the dialog. + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + open(&mut a, Some("General")); + reset_preferences(&mut a); + a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("inches")); + click_button(&mut a, "General"); + a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("points")); + confirm(&mut a).unwrap(); + assert_eq!(a.session.active().unwrap().doc.units, vectorcraft_doc::Unit::Points); + + let mut b = app(); + b.run("prefs.set", json!({"key": "unitsGeneral", "value": "Millimeters"})).unwrap(); + open(&mut b, Some("General")); + reset_preferences(&mut b); + confirm(&mut b).unwrap(); + assert_eq!(b.session.prefs.units_general, Prefs::default().units_general); +} + #[test] fn old_pref_files_keep_their_brightness() { let mut a = app(); @@ -690,6 +794,111 @@ fn escape_clears_the_search_then_closes_the_dialog() { assert!(a.ui.dialog.is_none(), "Escape in the empty field closes the dialog"); } +/// One frame of the open dialog as the app draws it, keys first, a quarter second after the last +/// (a window takes a sizing frame and fades in). → The texts it painted, with their rectangles. +fn dialog_frame(a: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let time = ctx.input(|i| i.time) + 0.25; + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))); + let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(a, ui.ctx()); + crate::dialogs::show(a, ui.ctx()); + }); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, v: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v +} + +/// Click the text `label` in the open dialog with the pointer. +fn click_text(a: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + dialog_frame(a, ctx, vec![]); + let texts = dialog_frame(a, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + dialog_frame(a, ctx, vec![egui::Event::PointerMoved(at)]); + dialog_frame(a, ctx, vec![press(true)]); + dialog_frame(a, ctx, vec![press(false)]); +} + +/// Preferences with an edited Keyboard Increment, the Units category and a search, over a stored +/// Keyboard Increment of 5. +fn edited_preferences() -> VectorcraftApp { + let mut a = app(); + a.run("prefs.set", json!({"key": "keyboardIncrement", "value": 5})).unwrap(); + open(&mut a, Some("Units")); + let d = a.ui.dialog.as_mut().unwrap(); + d.fields.insert("keyboardIncrement".into(), json!(7)); + d.fields.insert("__search".into(), json!("grid")); + a +} + +/// Reset Preferences asks first, over Preferences. Escape, or Cancel, shows Preferences again as it +/// was: its edits, category and search kept, and nothing applied. +#[test] +fn reset_preferences_asks_first_and_cancel_returns_to_preferences() { + let mut a = edited_preferences(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for way_back in ["Escape", "Cancel"] { + click_text(&mut a, &ctx, "Reset Preferences"); + let d = a.ui.dialog.clone().expect("a question is open"); + assert_eq!(d.kind, crate::dialogs::confirm::RESET_PREFS, "Reset Preferences asks first"); + let under = crate::dialogs::parent(&d).expect("the question is over Preferences"); + assert_eq!((under.kind.as_str(), under.fields["keyboardIncrement"].clone()), ("preferences", json!(7)), "Preferences is unchanged"); + dialog_frame(&mut a, &ctx, vec![]); + let shown: Vec = dialog_frame(&mut a, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(shown.iter().any(|t| t == "Reset all preferences to their defaults?"), "{shown:?}"); + if way_back == "Escape" { + dialog_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE)); + } else { + click_text(&mut a, &ctx, "Cancel"); + } + let d = a.ui.dialog.as_ref().unwrap_or_else(|| panic!("{way_back}: Preferences is open again")); + assert_eq!(d.kind, "preferences", "{way_back}"); + assert_eq!(d.fields["keyboardIncrement"], json!(7), "{way_back}: the edit is kept"); + assert_eq!((d.str("__category").as_str(), d.str("__search").as_str()), ("Units", "grid"), "{way_back}: category and search kept"); + assert_eq!(a.session.prefs.keyboard_increment, 5.0, "{way_back}: nothing applied"); + } +} + +/// OK on the question shows Preferences again with every preference at its default, its category +/// and search kept; nothing is applied until OK in Preferences, and Preferences' Cancel keeps the +/// stored preferences. +#[test] +fn confirming_reset_fills_preferences_and_applies_nothing_until_ok() { + let mut a = edited_preferences(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let default = Prefs::default().to_json()["keyboardIncrement"].clone(); + click_text(&mut a, &ctx, "Reset Preferences"); + click_text(&mut a, &ctx, "OK"); + let d = a.ui.dialog.as_ref().expect("Preferences is open again"); + assert_eq!(d.kind, "preferences"); + assert_eq!(d.fields["keyboardIncrement"], default, "the working copy holds the defaults"); + assert_eq!((d.str("__category").as_str(), d.str("__search").as_str()), ("Units", "grid")); + assert_eq!(a.session.prefs.keyboard_increment, 5.0, "nothing applied yet"); + click_text(&mut a, &ctx, "Cancel"); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.prefs.keyboard_increment, 5.0, "Cancel in Preferences keeps the preferences"); + + // `ui.dialog.confirm` answers the question as OK does; OK in Preferences then applies the defaults. + let mut a = edited_preferences(); + click_text(&mut a, &ctx, "Reset Preferences"); + crate::dialogs::confirm(&mut a).unwrap(); + assert_eq!(a.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("preferences")); + crate::dialogs::confirm(&mut a).unwrap(); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.prefs.keyboard_increment, Prefs::default().keyboard_increment); +} + /// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it /// changes, and its checkbox is dimmed unless a unit is Picas. #[test] @@ -834,6 +1043,105 @@ fn temp_folder(name: &str) -> std::path::PathBuf { dir } +/// An app whose save and open dialogs answer `path`, and that writes and reads real files. +fn app_with_file_dialogs(path: &str) -> VectorcraftApp { + let mut a = app(); + let (save, open) = (path.to_string(), path.to_string()); + a.services.pick_save = Some(Box::new(move |_: &crate::FilePick| Some(save.clone()))); + a.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(open.clone()))); + a.services.write = Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))); + a.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + a +} + +/// Export Preferences… saves the applied preferences to a file, and Import Preferences… applies +/// them again: the dialog shows them, OK keeps them, and the open document's units stay as they +/// are. +#[test] +fn export_and_import_preferences_buttons_round_trip() { + let dir = temp_folder("prefs-file"); + let path = dir.join("VectorCraft Preferences.json").to_string_lossy().into_owned(); + let mut a = app_with_file_dialogs(&path); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + let values = json!({"keyboardIncrement": 5, "uiAccent": "green", "unitsGeneral": "inches"}); + a.run("prefs.set", json!({"values": values, "documentUnits": false})).unwrap(); + let saved = a.session.prefs.to_json(); + open(&mut a, Some("General")); + click_button(&mut a, "Export Preferences…"); + let file: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(file["format"], "vectorcraft-preferences"); + assert_eq!(file["prefs"]["uiAccent"], "green"); + + a.ui.dialog = None; + a.run("prefs.reset", json!({})).unwrap(); + let undo = a.session.active().unwrap().history.undo.len(); + open(&mut a, Some("General")); + a.ui.dialog.as_mut().unwrap().fields.insert("keyboardIncrement".into(), json!(9)); + click_button(&mut a, "Import Preferences…"); + assert_eq!(a.ui.status, "Preferences imported."); + assert_eq!(a.session.prefs.to_json(), saved, "the exported preferences are applied"); + assert_eq!(a.ui.engine_prefs, saved, "and saved with the UI state"); + let d = a.ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.fields["keyboardIncrement"].clone(), d.fields["unitsGeneral"].clone()), (json!(5.0), json!("inches")), "the dialog shows them"); + confirm(&mut a).unwrap(); + assert_eq!(a.session.prefs.to_json(), saved); + let st = a.session.active().unwrap(); + assert_eq!(st.doc.units, vectorcraft_doc::Unit::Millimeters, "the document's units stay"); + assert_eq!(st.history.undo.len(), undo, "no undo step"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file Import Preferences… can't use changes nothing, the dialog's edits included, and the +/// status bar says why. +#[test] +fn import_preferences_of_a_bad_file_changes_nothing() { + let dir = temp_folder("prefs-bad-file"); + let path = dir.join("newer.json"); + std::fs::write(&path, r#"{"format": "vectorcraft-preferences", "version": 2, "prefs": {"uiAccent": "pink"}}"#).unwrap(); + let mut a = app_with_file_dialogs(&path.to_string_lossy()); + let before = a.session.prefs.to_json(); + open(&mut a, Some("General")); + a.ui.dialog.as_mut().unwrap().fields.insert("keyboardIncrement".into(), json!(9)); + click_button(&mut a, "Import Preferences…"); + assert!(a.ui.status.contains("newer version of VectorCraft"), "{}", a.ui.status); + assert_eq!(a.session.prefs.to_json(), before); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields["keyboardIncrement"], json!(9), "the dialog keeps its edits"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `prefs.import` from a script or MCP while Preferences is open: the dialog shows the imported +/// values. Import Preferences… shows only where the host has an open dialog (not on the web). +#[test] +fn prefs_import_refreshes_an_open_preferences_dialog() { + let mut a = app(); + open(&mut a, Some("General")); + let data = json!({"format": "vectorcraft-preferences", "version": 1, "prefs": {"keyboardIncrement": 7}}).to_string(); + a.run("prefs.import", json!({ "data": data })).unwrap(); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields["keyboardIncrement"], json!(7.0)); + assert_eq!(a.ui.engine_prefs["keyboardIncrement"], json!(7.0)); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Export Preferences…") && !text.contains("Import Preferences…"), "{text}"); + a.services.pick_open = Some(Box::new(|_: &crate::FilePick| None)); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Import Preferences…"), "{text}"); +} + +/// An import that turns Enable Touch Workspace on or off switches the workspace on a touch screen, +/// as OK in Preferences does. +#[test] +fn prefs_import_of_touch_workspace_switches_the_workspace() { + let mut a = app(); + a.run("prefs.set", json!({"key": "touchWorkspace", "value": false})).unwrap(); + a.touch_screen = true; + a.ui.workspace = "Typography".into(); + let file = |on: bool| json!({"format": "vectorcraft-preferences", "version": 1, "prefs": {"touchWorkspace": on}}).to_string(); + a.run("prefs.import", json!({ "data": file(true) })).unwrap(); + assert_eq!(a.ui.workspace, crate::workspaces::TOUCH); + a.run("prefs.import", json!({ "data": file(false) })).unwrap(); + assert_eq!(a.ui.workspace, "Typography"); +} + /// File Handling › Data Recovery › Folder has a Choose… button where the host has a folder /// picker, and none where it has not (the web). #[test] diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index bf764e0e4..40478e36e 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -24,6 +24,9 @@ pub struct Job { /// The automation roots in force where the job was made: the render reads linked files under /// them, as it would on that thread. pub scope: ScopeHandle, + /// The document's deferred live-effect plug-in stages and first-view budget: the render runs + /// inside their view ([`vectorcraft_plugins::deferred::Deferrals::view`]). + pub effects: vectorcraft_plugins::deferred::Deferrals, /// The render may copy pixels from the worker's last frame ([`crate::canvas::render_job`]). /// Off: it renders the whole frame. pub may_copy: bool, @@ -71,7 +74,7 @@ impl Worker { job = newer; } let t0 = crate::now_ms(); - let frame = job.scope.clone().run(|| crate::canvas::render_job(&mut r, &mut kept, &job)); + let frame = job.scope.clone().run(|| job.effects.view(|| crate::canvas::render_job(&mut r, &mut kept, &job))); let ms = crate::now_ms() - t0; let done = Done { key: job.key, doc: job.doc, opts: job.opts, img: frame.img, ms, moved: frame.moved, reused: frame.reused }; if done_tx.send(done).is_err() { diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs index 042e24cd3..02c222974 100644 --- a/crates/ui-egui/src/script_runs.rs +++ b/crates/ui-egui/src/script_runs.rs @@ -1,6 +1,7 @@ //! A running script, advanced one slice per frame: `script.run` starts it and [`poll`] takes it to //! its end, while the app goes on drawing the documents as they were before the run. The status bar -//! shows it with a Stop button; a run that has added a great deal waits for the person's answer. +//! shows it with a Stop button; a run that has added a great deal, or that opened a dialog with +//! `alert`, `confirm` or `prompt`, waits for the person's answer. use serde_json::json; @@ -14,21 +15,24 @@ pub fn poll(app: &mut VectorcraftApp) { if !app.session.script_running() { return; } - if app.session.script_waiting() { - if app.ui.dialog.is_none() { - app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({}))); - } + script_run::settle_close(app); + if ask(app) { return; } let name = app.session.script_name().unwrap_or_default(); let cursor_settings = (app.session.caps_lock, app.session.prefs.use_precise_cursors); - let Some(result) = app.session.script_pump() else { return }; + let Some(result) = app.session.script_pump() else { + // The app draws no more frames while the run waits: its question opens in this one. + ask(app); + return; + }; if cursor_settings != (app.session.caps_lock, app.session.prefs.use_precise_cursors) { app.canvas.hover_cursor = None; } if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::GROWTH_KIND) { app.ui.dialog = None; } + script_run::settle_close(app); match result { Ok(_) => app.status(format!("Ran {name}")), Err(e) => failed(app, e.to_string()), @@ -36,6 +40,29 @@ pub fn poll(app: &mut VectorcraftApp) { app.sync_views(); } +/// Opens what the run waits at, once no other dialog is open: the script's own dialog, or the +/// question about its growth. Closes the script's dialog when the run no longer waits at it. +/// → true while the run waits. +fn ask(app: &mut VectorcraftApp) -> bool { + if let Some(dialog) = app.session.script_dialog() { + if app.ui.dialog.is_none() { + script_run::open_notice(app, &dialog); + } + return true; + } + // A run asked to stop no longer waits at its dialog. + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::NOTICE_KIND) { + app.ui.dialog = None; + } + if app.session.script_waiting() { + if app.ui.dialog.is_none() { + app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({}))); + } + return true; + } + false +} + /// Shows why a run ended without its changes. A stopped run that wrote no file says only that it /// was stopped; any other failure shows its error, which names the files left written. pub(crate) fn failed(app: &mut VectorcraftApp, error: String) { diff --git a/crates/ui-egui/src/scripts_menu.rs b/crates/ui-egui/src/scripts_menu.rs new file mode 100644 index 000000000..9c890d5c3 --- /dev/null +++ b/crates/ui-egui/src/scripts_menu.rs @@ -0,0 +1,255 @@ +//! File › Scripts: the scripts of the Scripts folder next to the settings +//! ([`vectorcraft_engine::Session::attach_scripts_dir`]), each `.js` or `.jsx` file by its name +//! without the extension and each subfolder as a submenu, above Check Script… and Other Script…. +//! +//! The folder is read on another thread ([`vectorcraft_engine::script::folder::list`]: names only, +//! within its limits), one read at a time: when the app starts, each time its window becomes the +//! active one, and each time the File menu of the window's menu bar opens ([`refresh`]). The menus show the last finished +//! listing ([`items`]), so a script added to the folder is listed without a restart. +//! +//! `ui.runInstalledScript {path}` runs a listed script (`script.run`) without the warning Other +//! Script… shows: the file was put in the folder to be run from the menu. The path is one a +//! listing gave; a file that is no longer a plain script under the folder is not run. + +use std::path::Path; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use serde_json::{Value, json}; +use vectorcraft_engine::script::folder::{Entry, Listing}; + +use crate::VectorcraftApp; +use crate::menus::Item; + +/// The command a listed script's menu item runs. +pub const COMMAND: &str = "ui.runInstalledScript"; +/// The menu's note under a listing that may lack scripts. +pub const TRUNCATED: &str = "Some scripts were not listed"; +/// The menu's note when the folder is there and cannot be read. +pub const UNREADABLE: &str = "The Scripts folder cannot be read"; +/// The notes, for the catalogs ([`crate::menus::menu_strings`]). +pub const LABELS: &[&str] = &[TRUNCATED, UNREADABLE]; + +#[derive(Default)] +struct State { + /// The last finished listing. + listing: Arc

, + /// How many finished listings differed from the one before. + revision: u64, + /// The listing being read on another thread. + reading: Option>, + /// Another read was asked for while one was under way; [`poll`] starts it when that one ends. + pending: bool, + /// A first read was asked for. + started: bool, + /// The window was the active one at the last [`poll`]. + focused: bool, +} + +/// The listing the menus show. One per process in the app; one per thread in unit tests, which run +/// in parallel threads, each with its own folder. +fn state() -> MutexGuard<'static, State> { + #[cfg(not(test))] + let state: &'static Mutex = { + static S: std::sync::OnceLock> = std::sync::OnceLock::new(); + S.get_or_init(Default::default) + }; + #[cfg(test)] + let state: &'static Mutex = { + thread_local!(static S: &'static Mutex = Box::leak(Box::default())); + S.with(|s| *s) + }; + state.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The most different names the menu shows in one session. Each is kept for the rest of the +/// session (menu items hold `&'static str` labels), at most 128 characters each. +pub const MAX_NAMES: usize = 16_384; + +/// Leak-once interning of the names the menu shows; `None` for a new name once [`MAX_NAMES`] are +/// kept, so renaming scripts over and over keeps the memory bounded. +pub(crate) fn intern(s: &str) -> Option<&'static str> { + #[cfg(not(test))] + let set: &'static Mutex> = { + static SET: std::sync::OnceLock>> = std::sync::OnceLock::new(); + SET.get_or_init(Default::default) + }; + #[cfg(test)] + let set: &'static Mutex> = { + thread_local!(static SET: &'static Mutex> = Box::leak(Box::default())); + SET.with(|s| *s) + }; + let mut set = set.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(v) = set.get(s) { + return Some(v); + } + if set.len() >= MAX_NAMES { + return None; + } + let v: &'static str = Box::leak(s.to_string().into_boxed_str()); + set.insert(v); + Some(v) +} + +/// Changes whenever the menus' listing changes (the macOS menu bar follows it). +pub fn revision() -> u64 { + state().revision +} + +/// Read the Scripts folder again on another thread, unless the session has no folder (the web, and +/// a run without preferences). While a read is under way, another read starts when it ends +/// ([`poll`]). `ctx` is asked to draw when the read ends. +pub fn refresh(app: &VectorcraftApp, ctx: Option<&egui::Context>) { + let Some(root) = app.session.scripts_dir().map(Path::to_path_buf) else { return }; + let mut state = state(); + if state.reading.is_some() { + state.pending = true; + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = ctx.cloned(); + let spawned = std::thread::Builder::new().name("vectorcraft-scripts".into()).spawn(move || { + // The app's own folder, read for the person at the keyboard. + let listing = vectorcraft_engine::file_access::unconfined(|| vectorcraft_engine::script::folder::list(&root)); + if tx.send(listing).is_ok() + && let Some(ctx) = ctx + { + ctx.request_repaint(); + } + }); + if spawned.is_ok() { + state.reading = Some(rx); + } +} + +/// Once per iteration: start a read on the first one and when the window becomes the active one, +/// take in a read that ended, and then start a read asked for while it was under way. +pub fn poll(app: &VectorcraftApp, ctx: &egui::Context) { + let focused = ctx.input(|i| i.focused); + let start = { + let mut state = state(); + let start = !state.started || (focused && !state.focused); + (state.started, state.focused) = (true, focused); + start + }; + if start { + refresh(app, Some(ctx)); + } + take(false); + let again = { + let mut state = state(); + let again = state.pending && state.reading.is_none(); + if again { + state.pending = false; + } + again + }; + if again { + refresh(app, Some(ctx)); + } +} + +/// Take in the read under way: when it has ended, or with `wait` once it ends. +fn take(wait: bool) { + let mut state = state(); + let Some(reading) = &state.reading else { return }; + let listing = if wait { reading.recv().map_err(|_| TryRecvError::Disconnected) } else { reading.try_recv() }; + match listing { + Ok(listing) => { + if *state.listing != listing { + state.listing = Arc::new(listing); + state.revision += 1; + } + state.reading = None; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => state.reading = None, + } +} + +/// Wait for the read under way and take it in. +#[cfg(test)] +pub(crate) fn wait() { + take(true); +} + +/// Wait for the read under way to end and leave its listing to be taken in, so the read is under +/// way until the next [`poll`]. +#[cfg(test)] +pub(crate) fn end_untaken() { + let mut state = state(); + let Some(reading) = &state.reading else { return }; + let (tx, rx) = std::sync::mpsc::channel(); + if let Ok(listing) = reading.recv() { + let _ = tx.send(listing); + } + state.reading = Some(rx); +} + +/// File › Scripts' items: the listed scripts and subfolders, a note when some may be missing or +/// the folder cannot be read, then Check Script… and Other Script…. +pub fn items() -> Vec { + // A row whose name is past [`MAX_NAMES`] is left out (`dropped`). + fn level(entries: &[Entry], dropped: &mut bool) -> Vec { + let mut items = Vec::with_capacity(entries.len()); + for e in entries { + let item = match e { + Entry::Script { label, path } => intern(label).map(|l| Item::Cmd(l, COMMAND, json!({ "path": path.to_string_lossy() }))), + Entry::Folder { label, entries } => { + let inner = level(entries, dropped); + intern(label).filter(|_| !inner.is_empty()).map(|l| Item::Sub(l, inner)) + } + }; + match item { + Some(item) => items.push(item), + None => *dropped = true, + } + } + items + } + let listing = state().listing.clone(); + let mut dropped = false; + let mut items = level(&listing.entries, &mut dropped); + if listing.truncated || dropped { + items.push(Item::Header(TRUNCATED)); + } + if listing.unreadable { + items.push(Item::Header(UNREADABLE)); + } + if !items.is_empty() { + items.push(Item::Sep); + } + items.extend([Item::Cmd("Check Script…", "ui.checkScript", Value::Null), Item::Cmd("Other Script…", "ui.runScript", Value::Null)]); + items +} + +/// Is `children` a subfolder's submenu, named after the folder? Its first item is a listed script +/// or another subfolder, and it has no Other Script…, which only File › Scripts itself has. +pub fn is_folder(children: &[Item]) -> bool { + let listed = match children.first() { + Some(Item::Cmd(_, id, _)) => *id == COMMAND, + Some(Item::Sub(_, inner)) => is_folder(inner), + _ => false, + }; + listed && !children.iter().any(|c| matches!(c, Item::Cmd(_, "ui.runScript", _))) +} + +/// May the request being served read the listed script `p` names? Under an automation read root +/// (a control client's request), a script outside the root is left out of `ui.menu.list`, as +/// `script.listInstalled` refuses to list the folder there. +pub fn readable(p: &Value) -> bool { + p.get("path").and_then(Value::as_str).is_some_and(|path| vectorcraft_engine::file_access::check_read(path).is_ok()) +} + +/// `ui.runInstalledScript {path}`: run the listed script at `path`. A script that is no longer in +/// the folder is not run, and the folder is read again. The script runs from its file, so its +/// `#include` lines and `$.evalFile` read beside it. +pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).ok_or("missing `path`")?; + app.session.installed_script(path).map_err(|e| { + let ctx = app.ui_ctx.clone(); + refresh(app, ctx.as_ref()); + e.to_string() + })?; + app.run("script.run", json!({ "path": path })) +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ce03daca5..59553120c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -208,6 +208,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::css_properties::ID, "CSS Properties", "globe"), (crate::panels::three_d::ID, "3D and Materials", "dc-cube"), (crate::panels::variables::ID, "Variables", "chart-column"), + (crate::panels::script_console::ID, "Script Console", "message-circle"), (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), (crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"), @@ -392,6 +393,11 @@ pub struct UiState { /// That popup's first text field is still to take the keyboard. #[serde(skip)] pub transient_focus: bool, + /// The Separations Preview panel is still to scroll its Color Settings section into view: + /// Edit › Color Settings… and Assign Profile… show the panel for it, and the section sits below + /// the plates and Proof Setup, out of sight in a short window (#6419). + #[serde(skip)] + pub color_settings_scroll: bool, /// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs. #[serde(skip)] pub find_font_in: String, @@ -1009,6 +1015,7 @@ impl Default for UiState { open_panel: None, transient_panel: None, transient_focus: false, + color_settings_scroll: false, find_font_in: String::new(), control_bar: false, control_bar_bottom: false, diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 261a25054..07d88595d 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -39,7 +39,9 @@ mod saveext; mod screenmode; mod screenshots; mod script_check; +mod script_dialogs; mod script_run; +mod scripts_menu; mod scrub; mod selectall; mod selectescape; diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index c66cc7cd5..f725a0819 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -304,6 +304,237 @@ fn frames_with_injected_input_are_confined() { let _ = std::fs::remove_dir_all(base); } +/// Every File › Open Recent Files row `ui.menu.list` returns, disabled ones included: (id, label), +/// the label of a disabled row starting "(disabled) ". +fn recent_rows(entries: &Value) -> Vec<(String, String)> { + let rows = entries.as_array().map(Vec::as_slice).unwrap_or_default(); + rows.iter() + .filter(|e| e["command"].as_str().is_some_and(|c| c.starts_with("file.openRecent"))) + .map(|e| { + let label = e["label"].as_str().unwrap_or_default(); + let label = if e["enabled"] == true { label.to_string() } else { format!("(disabled) {label}") }; + (e["command"].as_str().unwrap_or_default().to_string(), label) + }) + .collect() +} + +#[test] +fn recent_files_outside_the_read_root_are_not_listed_or_opened() { + let (mut app, tx, base) = confined("recent"); + std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap(); + let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg"))); + app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()]; + let rows = |pairs: &[(&str, &str)]| pairs.iter().map(|(id, l)| (id.to_string(), l.to_string())).collect::>(); + + // The person's menu lists all three. + let person = serde_json::to_value(crate::menus::menu_entries(&app)).unwrap(); + assert_eq!(recent_rows(&person), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent2", "b.svg"), ("file.openRecent3", "c.svg")])); + assert_eq!(app.run("file.recentFiles", json!({})).unwrap(), json!([a, b, c])); + + // The client's listing leaves out the second, and the third keeps its id. + let r = request(&mut app, &tx, "ui.menu.list", json!({})); + assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent3", "c.svg")]), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.recentFiles"})); + assert_eq!(r["result"], json!([a, c]), "{r}"); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent2"})); + assert_eq!(r["error"], "no such recent file", "the reply names no path: {r}"); + assert!(app.session.documents().is_empty()); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent3"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.recent_files, [c.clone(), a.clone(), b.clone()]); + + // A file outside the root that the person has open is still not brought forward. + app.run("file.openRecent3", json!({})).unwrap(); + assert_eq!((app.session.documents().len(), &app.ui.recent_files[0]), (2, &b)); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent1"})); + assert_eq!(r["error"], "no such recent file", "{r}"); + + // Rows past Number of Recent Files to Display stay out, and a write root alone lists none. + app.session.prefs.recent_files_count = 2; + let r = request(&mut app, &tx, "ui.menu.list", json!({})); + assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent2", "c.svg")]), "{r}"); + let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap(); + let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || serde_json::to_value(crate::menus::menu_entries(&app)).unwrap()); + assert!(recent_rows(&listed).is_empty(), "{listed}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out the paths outside the read root that the person's work put in it: files +/// still opening, recent files, a library panel's file and folder preferences, saved and in the +/// Preferences dialog. +#[test] +fn ui_inspect_leaves_out_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect"); + std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap(); + let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg"))); + let svg = std::fs::read(&a).unwrap(); + for path in [&a, &b, &c] { + assert!(crate::opening::start(&mut app, path, svg.clone()).is_ok(), "{path} opens off the UI thread"); + } + app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()]; + let panel = |id: &str, path: &str| -> crate::panels::library_panel::LibraryPanel { + serde_json::from_value(json!({"kind": "swatches", "id": id, "path": path})).unwrap() + }; + let theirs = format!("loaded/{b}"); + app.ui.library_panels = vec![panel("mine", &a), panel(&theirs, &b)]; + app.ui.open_panel = Some(format!("library:swatches:{theirs}")); + let (inside, outside) = (s(base.join("in")), s(base.join("outside"))); + app.ui.engine_prefs = json!({ + "fontsFolder": inside, "templatesFolder": outside, "recoveryFolder": outside, "scratchPrimary": "Startup", + "scratchSecondary": "../private", "pluginsFolder": "", + }); + app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"fontsFolder": inside, "templatesFolder": outside}))); + + // The person sees every path. + let ctx = egui::Context::default(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["opening"], json!([a, b, c])); + assert_eq!(person["ui"]["recent_files"], json!([a, b, c])); + assert_eq!(person["ui"]["library_panels"][1]["path"], b); + assert_eq!(person["ui"]["engine_prefs"]["templatesFolder"], outside); + + // The client sees the ones inside the read root, in the same order. + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!(r["result"]["opening"], json!([a, c]), "{r}"); + assert_eq!(ui["recent_files"], json!([a, c])); + assert_eq!(ui["library_panels"][0]["path"], a); + assert_eq!((ui["library_panels"][1]["id"].as_str(), ui["library_panels"][1].get("path")), (Some("loaded/b.svg"), None), "the panel stays"); + assert_eq!(ui["open_panel"], "library:swatches:loaded/b.svg"); + let prefs = ui["engine_prefs"].as_object().unwrap(); + assert_eq!( + (prefs["fontsFolder"].as_str(), prefs["scratchPrimary"].as_str(), prefs["pluginsFolder"].as_str()), + (Some(inside.as_str()), Some("Startup"), Some("")) + ); + assert!(["templatesFolder", "recoveryFolder", "scratchSecondary"].iter().all(|k| !prefs.contains_key(*k)), "{prefs:?}"); + let fields = ui["dialog"]["fields"].as_object().unwrap(); + assert_eq!((fields["fontsFolder"].as_str(), fields.get("templatesFolder")), (Some(inside.as_str()), None), "the Preferences dialog's copy too"); + let hidden = json!(outside).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.docking", json!({})); + assert_eq!(r["result"]["flyout"], "library:swatches:loaded/b.svg", "{r}"); + assert_eq!(crate::panel_docking::inspect(&app, &ctx)["flyout"], json!(format!("library:swatches:{theirs}")), "the person's"); + assert_eq!(app.ui.recent_files, [a.clone(), b.clone(), c.clone()], "the UI state is not changed"); + assert_eq!(app.opening.pending.len(), 3, "and no open is canceled"); + + // A write root alone lets the client read none of them. + let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap(); + let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || crate::control::inspect(&app, &ctx)); + assert_eq!((&listed["opening"], &listed["ui"]["recent_files"]), (&json!([]), &json!([])), "{listed}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A closed panel's former place, as `ui.docking_hidden` keeps it. +fn hidden_place() -> craft_ui::docking::Location { + serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() +} + +/// `ui.inspect` hides the path of a library loaded from outside the read root wherever the reply holds +/// its id, also when its panel is closed: here only the Color Guide's limit and a closed panel's place +/// name it. +#[test] +fn ui_inspect_hides_a_closed_librarys_path_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-closed-lib"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!(ui["color_guide_limit"], "loaded/b.svg", "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key("library:swatches:loaded/b.svg"), "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_guide_limit"], json!(theirs), "the person's"); + let _ = std::fs::remove_dir_all(base); +} + +/// Two libraries loaded from outside the read root with the same file name stay two in `ui.inspect`: +/// the second one shows as `loaded/ (2)`, the same in every place the reply names it. +#[test] +fn ui_inspect_keeps_two_hidden_libraries_with_one_file_name_apart() { + let (mut app, tx, base) = confined("inspect-same-name"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&x), panel(&y)]; + for id in [&x, &y] { + app.ui.docking_hidden.insert(format!("library:swatches:{id}"), hidden_place()); + } + app.ui.color_guide_limit = y.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + let ids: Vec<_> = ui["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/b.svg"), json!("loaded/b.svg (2)")], "{r}"); + let keys: Vec<_> = ui["docking_hidden"].as_object().unwrap().keys().cloned().collect(); + assert_eq!(keys, ["library:swatches:loaded/b.svg", "library:swatches:loaded/b.svg (2)"], "no entry is dropped: {r}"); + assert_eq!(ui["color_guide_limit"], "loaded/b.svg (2)", "the same id everywhere"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` and `ui.docking` show a hidden library under the same id, also when another hidden +/// library with the same file name is in only one of the replies (here as the Color Guide's limit). +#[test] +fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { + let (mut app, tx, base) = confined("inspect-docking-same-id"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": y})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.open_panel = Some(format!("library:swatches:{y}")); + app.ui.color_guide_limit = x.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!( + (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), + (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + "{r}" + ); + let d = request(&mut app, &tx, "ui.docking", json!({})); + assert_eq!(d["result"]["flyout"], ui["open_panel"], "{d}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites only the ids of libraries the UI state refers to: a script's report line and +/// a command palette query that look like a library id, with no such library, stay as written, and +/// the query does not take the file name of a real library outside the root. +#[test] +fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { + let (mut app, tx, base) = confined("inspect-lookalike"); + let real = format!("loaded/{}", s(base.join("outside/x/d.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": real})).unwrap(); + app.ui.library_panels = vec![panel]; + let query = format!("library:swatches:loaded/{}", s(base.join("outside/d.svg"))); + app.ui.palette_query = query.clone(); + let text = format!("loaded/{}", s(base.join("outside/c.svg"))); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/d.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client without automation roots sees every loaded library's full id in `ui.inspect`. +#[test] +fn ui_inspect_shows_full_library_ids_without_a_read_root() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let theirs = format!("loaded/{}", s(std::env::temp_dir().join("vc-ui-roots-unconfined").join("b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": theirs})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!((&ui["library_panels"][0]["id"], &ui["color_guide_limit"]), (&json!(theirs), &json!(theirs)), "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key(&format!("library:swatches:{theirs}")), "{r}"); +} + /// Hidden logic-only ticks between the injection and its UI pass must not drain another step: /// one step per settled pass, not per hidden tick. #[test] diff --git a/crates/ui-egui/src/tests/guide_escape.rs b/crates/ui-egui/src/tests/guide_escape.rs index 90c77f47d..113d2b1e2 100644 --- a/crates/ui-egui/src/tests/guide_escape.rs +++ b/crates/ui-egui/src/tests/guide_escape.rs @@ -90,7 +90,7 @@ impl GuideDrag { let point = |at| if vertical { Point::new(at, 60.0) } else { Point::new(60.0, at) }; (f.from, f.to, f.end) = (xf.to_screen(point(100.0)), xf.to_screen(point(140.0)), xf.to_screen(point(160.0))); assert!([f.from, f.to, f.end].into_iter().all(|p| rect.contains(p))); - assert_eq!(f.selection.guides, vec![0, 1]); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&f.before, &f.selection.guides), vec![0, 1]); f.app.session.active_mut().unwrap().mark_saved(); f.unchanged(); f @@ -140,17 +140,30 @@ impl GuideDrag { } fn guides_at_target(&self) { - let mut moved = self.before.guides.clone(); + let line = |g: &vectorcraft_doc::Guide| (g.vertical, g.pos, g.artboard, g.layer); + let before: Vec<_> = self.before.ruler_guides().iter().map(line).collect(); + let mut moved = before.clone(); for guide in &mut moved { - if guide.vertical == self.vertical { - guide.pos += 40.0; + if guide.0 == self.vertical { + guide.1 += 40.0; } } - let expected = if self.copy { self.before.guides.iter().cloned().chain(moved).collect::>() } else { moved }; + let expected = if self.copy { before.into_iter().chain(moved).collect::>() } else { moved }; let st = self.app.session.active().unwrap(); - assert_eq!(st.doc.guides, expected); - assert_eq!(st.doc.layers, self.before.layers); - assert_eq!(st.selection.guides, if self.copy { vec![2, 3] } else { vec![0, 1] }); + assert_eq!(st.doc.ruler_guides().iter().map(line).collect::>(), expected); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&self.before)); + let selected = vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides); + assert_eq!(selected, if self.copy { vec![2, 3] } else { vec![0, 1] }); } fn unchanged(&self) { diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index 017d0175b..dbdb0283f 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -30,6 +30,13 @@ fn with_document() -> VectorcraftApp { app } +/// Send a control request and keep its success/error value for protocol assertions. +fn control_response(app: &mut VectorcraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (request, _) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(reply) = crate::control::handle(app, ctx, &request) else { panic!("not done") }; + reply +} + /// Returns the labels of the View › Rotate View items that are checked. fn checked(app: &VectorcraftApp) -> Vec<&'static str> { ANGLES.iter().filter(|(_, a)| menus::checked(app, "view.rotate", &json!({ "angle": a })) == Some(true)).map(|(l, _)| *l).collect() @@ -55,6 +62,157 @@ fn view_rotate_turns_the_view_to_any_angle() { assert_eq!(empty.run("view.rotate", json!({"angle": 30})).unwrap_err(), "no document"); } +/// MCP's `pointer_gesture` reaches `ui.pointer`: view tools must turn the canvas there too, +/// rather than silently sending the gesture to the engine's no-op tool. +#[test] +fn control_rotate_view_gesture_turns_the_canvas_and_reset_restores_it() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + let revision = app.session.active().unwrap().revision; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let mut frame = ctx.run_ui(Default::default(), |_| {}); + frame.textures_delta.clear(); + send( + &mut app, + &ctx, + "ui.pointer", + json!({"events": [ + {"kind": "drag", "x": 200, "y": 250}, + {"kind": "up", "x": 200, "y": 250} + ]}), + ); + let view = app.view().unwrap(); + assert!((view.rotation - 90.0).abs() < 1e-3, "{}", view.rotation); + assert_eq!(view.center, vectorcraft_geom::Point::new(200.0, 150.0)); + assert_eq!(view.zoom, 1.0); + assert_eq!(app.session.active().unwrap().revision, revision, "navigation does not edit the document"); + let reset = control_response(&mut app, &ctx, "engine.execute", json!({"command": "view.rotateReset", "params": {}})); + assert_eq!(reset["ok"], true); + assert!(reset["result"].is_null()); + assert_eq!(app.view().unwrap().rotation, 0.0); +} + +/// Repeated document points stay fixed across requests, including Shift snapping from a turned view. +#[test] +fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_requests() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + view.rotation = 30.0; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let angle = 20_f64.to_radians(); + let (x, y) = (200.0 + 100.0 * angle.cos(), 150.0 + 100.0 * angle.sin()); + for _ in 0..2 { + send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]})); + assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes"); + } + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]})); + assert_eq!(app.view().unwrap().rotation, 45.0); +} + +/// Invalid coordinates and document changes cannot turn a different view. +#[test] +fn control_rotate_view_rejects_nonfinite_positions_and_a_changed_document() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": 1e300, "y": 0}]})); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.view().unwrap().rotation, 0.0); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + app.run("file.new", json!({})).unwrap(); + let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "drag", "x": 200, "y": 250}]})); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.view().unwrap().rotation, 0.0); +} + +/// An invalid packet leaves the angle unchanged and requires a fresh press, including errors +/// after an otherwise valid drag and malformed releases or hovers. +#[test] +fn control_rotate_view_rejects_malformed_packets_without_leaving_a_press() { + let drag = json!({"kind": "drag", "x": 200, "y": 250}); + for packet in [ + json!({"events": [drag.clone(), {"kind": "drag", "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": "200", "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"shift": "yes"}}]}), + json!({"mods": "shift", "events": [drag.clone()]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"unknown": true}}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "up", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "move", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "doubleclick", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "wiggle", "x": 200, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "space": "window"}]}), + json!({"events": null}), + ] { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + super::synthetic::control(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + super::synthetic::control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let reply = control_response(&mut app, &ctx, "ui.pointer", packet.clone()); + assert_eq!(reply["ok"], false, "{packet}: {reply}"); + assert_eq!(app.view().unwrap().rotation, 0.0, "{packet}"); + let resume = control_response(&mut app, &ctx, "ui.pointer", json!({"events": [drag.clone()]})); + assert_eq!(resume["ok"], false, "a failed packet cancels its press: {packet}"); + assert_eq!(app.view().unwrap().rotation, 0.0); + } +} + +/// Screen-space gestures survive frames, stop on release or a tool change, and start afresh +/// from the current angle. A press at the pivot uses the same finite convention as the mouse. +#[test] +fn control_rotate_view_screen_events_require_a_live_press() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(1200.0, 900.0); + view.zoom = 2.0; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + let down = json!({"events": [{"kind": "down", "x": 500, "y": 300, "space": "screen"}]}); + let drag = json!({"events": [{"kind": "drag", "x": 400, "y": 400, "space": "screen"}]}); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false); + send(&mut app, &ctx, "ui.pointer", down.clone()); + let mut frame = ctx.run_ui(Default::default(), |_| {}); + frame.textures_delta.clear(); + send(&mut app, &ctx, "ui.pointer", drag.clone()); + assert!((app.view().unwrap().rotation - 90.0).abs() < 1e-3); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": 400, "y": 400, "space": "screen"}]})); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "release ends the press"); + send(&mut app, &ctx, "ui.pointer", down.clone()); + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "selection"})); + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "a tool change ends the press"); + send(&mut app, &ctx, "ui.pointer", down); + send(&mut app, &ctx, "ui.pointer", drag); + assert!((app.view().unwrap().rotation.abs() - 180.0).abs() < 1e-3, "a new press starts at the current angle"); + let before = app.view().unwrap().rotation; + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 400, "y": 300, "space": "screen"}]})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "drag", "x": 500, "y": 300, "space": "screen"}]})); + assert!(app.view().unwrap().rotation.is_finite()); + assert!((app.view().unwrap().rotation - before).abs() < 1e-3); +} + #[test] fn the_view_menu_lists_the_status_bar_angles_and_checks_the_current_one() { let mut app = with_document(); @@ -377,6 +535,23 @@ fn escape_that_ends_a_path_being_drawn_keeps_the_rotation() { } } +/// A path that is no longer selected is not being drawn: the next Escape resets the view. +#[test] +fn escape_after_deselecting_the_path_resets_the_view() { + for tool in ["pen", "smartPen", "curvature"] { + let mut w = Window::new(); + w.app.select_tool(tool); + let c = w.app.canvas_rect.unwrap().center(); + w.click(0.0, egui::PointerButton::Primary, c); + w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0)); + assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: a path is being drawn"); + w.app.run("select.none", json!({})).unwrap(); + assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: the path is not selected"); + w.escape(0.2); + assert_eq!(w.rotation(), 0.0, "{tool}: one Escape resets the view"); + } +} + /// In isolation mode, an Escape that ends the Pen's path ends only the path: the next Escape leaves /// isolation mode, and the one after it resets the view. #[test] diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index de4a0501e..365cc92ef 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -80,7 +80,7 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert!(st.doc.ruler_origin_for(0).distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin_for(0)); assert_eq!(st.history.undo.len(), undo + 1); assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag"); - assert!(st.doc.guides.is_empty()); + assert!(st.doc.ruler_guides().is_empty()); let origin = st.doc.ruler_origin_for(0); app.run("edit.undo", json!({})).unwrap(); assert_eq!(&*app.session.doc().unwrap().doc, &*before); diff --git a/crates/ui-egui/src/tests/script_dialogs.rs b/crates/ui-egui/src/tests/script_dialogs.rs new file mode 100644 index 000000000..b3750001a --- /dev/null +++ b/crates/ui-egui/src/tests/script_dialogs.rs @@ -0,0 +1,282 @@ +//! A script's `alert`, `confirm` and `prompt` in the app: the dialog the run waits at, its buttons, +//! Enter and Escape, Stop, another dialog in the way, quitting while it waits, and the same answers +//! given by a run with nobody to ask. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::dialogs::script_run::{CLOSE_KIND, NOTICE_KIND}; +use crate::{VectorcraftApp, dialogs, menus, script_runs}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.session.prefs.show_external_script_warning = false; + app +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10});"; + +fn start(app: &mut VectorcraftApp, source: &str) { + menus::run_ui_command(app, "ui.runScript", &json!({"source": source, "name": "d.js"})).unwrap().unwrap(); +} + +/// Polls the run until it ends, or until its dialog is open (true). +fn until_dialog(app: &mut VectorcraftApp) -> bool { + for _ in 0..10_000 { + if !app.session.script_running() { + return false; + } + script_runs::poll(app); + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == NOTICE_KIND) { + return true; + } + } + panic!("the script neither ended nor opened a dialog"); +} + +fn answer(app: &mut VectorcraftApp, answer: &str) { + app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!(answer)); + dialogs::confirm(app).unwrap(); +} + +thread_local! { + // One context per test, so frames follow one another as in the app. + static CTX: egui::Context = { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + }; +} + +/// One headless frame of the dialog layer and the shortcuts, in a window of `size`. Each key pressed +/// in it is let go in a second frame, as a person lets go of it. +fn frame(app: &mut VectorcraftApp, events: Vec, size: egui::Vec2) { + let ctx = CTX.with(Clone::clone); + let released: Vec = events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, physical_key, modifiers, pressed: true, .. } => { + Some(egui::Event::Key { key: *key, physical_key: *physical_key, pressed: false, repeat: false, modifiers: *modifiers }) + } + _ => None, + }) + .collect(); + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + let pressed = !released.is_empty(); + for events in std::iter::once(events).chain(pressed.then_some(released)) { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + dialogs::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + } +} + +fn key(k: egui::Key) -> egui::Event { + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() } +} + +const WINDOW: egui::Vec2 = egui::vec2(1200.0, 800.0); + +fn log(app: &VectorcraftApp) -> Value { + app.session.script_report().unwrap()["log"].clone() +} + +#[test] +fn alert_waits_and_stop_rolls_back_first_dialog() { + for stop_with_escape in [false, true] { + let mut app = app(); + start(&mut app, &format!("{DRAW} while (1) {{ alert('x'); }}")); + assert!(until_dialog(&mut app)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["kind"].as_str(), d.fields["message"].as_str(), d.fields["title"].clone()), (Some("alert"), Some("x"), Value::Null)); + for _ in 0..5 { + script_runs::poll(&mut app); + } + assert!(app.session.script_running(), "the run waits at the dialog"); + assert_eq!(objects(&app), 0); + if stop_with_escape { + frame(&mut app, vec![key(egui::Key::Escape)], WINDOW); + } else { + answer(&mut app, "stop"); + } + assert!(app.ui.dialog.is_none()); + assert!(!until_dialog(&mut app)); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); + assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["answer"], "stop"); + } +} + +#[test] +fn confirm_enter_uses_no_as_default() { + for (no_as_default, want) in [(true, false), (false, true)] { + let mut app = app(); + start(&mut app, &format!("vc.log(confirm('Go?', {no_as_default}));")); + assert!(until_dialog(&mut app)); + frame(&mut app, vec![], WINDOW); + frame(&mut app, vec![key(egui::Key::Enter)], WINDOW); + assert!(app.ui.dialog.is_none(), "Enter answers the dialog: noAsDflt {no_as_default}"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([want]), "noAsDflt {no_as_default}"); + } + let mut app = app(); + start(&mut app, "vc.log(confirm('Go?'));"); + assert!(until_dialog(&mut app)); + frame(&mut app, vec![key(egui::Key::Escape)], WINDOW); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([false]), "Escape is No"); +} + +#[test] +fn prompt_ok_and_cancel_return_values() { + let mut app = app(); + start(&mut app, "vc.log(prompt('Name', 'pre', 'Ask')); vc.log(prompt('Again', 'x'));"); + assert!(until_dialog(&mut app)); + assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "Ask"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["value"], "pre"); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("typed")); + dialogs::confirm(&mut app).unwrap(); + assert!(until_dialog(&mut app)); + dialogs::cancel(&mut app); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!(["typed", null])); + let mut app = self::app(); + start(&mut app, "vc.log(prompt('Name', ''));"); + assert!(until_dialog(&mut app)); + let e = { + app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!("yes")); + dialogs::confirm(&mut app).unwrap_err() + }; + assert!(e.contains("ok, cancel, stop"), "{e}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "a wrong answer leaves the dialog open"); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([""]), "OK with an empty field gives an empty string"); +} + +#[test] +fn the_dialog_opens_in_the_frame_the_run_waits() { + let mut app = app(); + start(&mut app, "alert('x');"); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(NOTICE_KIND), "no later frame is drawn while the run waits"); +} + +#[test] +fn a_prompt_text_past_the_limit_keeps_the_dialog_open() { + let mut app = app(); + start(&mut app, "vc.log(prompt('Name', ''));"); + assert!(until_dialog(&mut app)); + let long = "x".repeat(vectorcraft_engine::script::dialogs::MAX_PROMPT_BYTES + 1); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!(long)); + assert!(dialogs::confirm(&mut app).is_err()); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "the refused answer leaves the dialog open"); + assert!(app.session.script_dialog().is_some(), "the run still waits"); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("ok")); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!(["ok"])); +} + +#[test] +fn dialog_wait_survives_another_modal() { + let mut app = app(); + start(&mut app, "alert('x');"); + script_runs::poll(&mut app); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({}))); + let other = app.ui.dialog.clone(); + for _ in 0..3 { + script_runs::poll(&mut app); + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.clone()), other.map(|d| d.kind), "the open dialog stays"); + assert!(app.session.script_running()); + app.ui.dialog = None; + assert!(until_dialog(&mut app), "the script's dialog opens once the other one closes"); +} + +#[test] +fn close_while_notification_waits_abandons_or_resumes() { + let mut app = app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("half")); + dialogs::script_run::ask_to_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run goes on waiting"); + dialogs::cancel(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["value"].as_str()), (NOTICE_KIND, Some("half")), "Cancel shows the dialog again as it was"); + dialogs::script_run::ask_to_close(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.script_running()); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn close_question_ends_with_a_run_stopped_another_way() { + let mut app = app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + dialogs::script_run::ask_to_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND); + assert_eq!(app.run("script.stop", json!({})).unwrap()["stopped"], true); + script_runs::poll(&mut app); + assert!(!app.session.script_running()); + assert!(app.ui.dialog.is_none(), "the question about a run that has ended closes"); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel does not show the stopped script's dialog again"); + assert_eq!(objects(&app), 0); + // Asked to stop, the run no longer waits at its dialog before its next slice ends it. + let mut app = self::app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + dialogs::script_run::ask_to_close(&mut app); + app.run("script.stop", json!({})).unwrap(); + dialogs::script_run::settle_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run has not ended yet"); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel does not show a dialog the run no longer waits at"); +} + +#[test] +fn notification_layout_with_long_text_and_small_window() { + let mut app = app(); + let long = "word ".repeat(3_000); + start(&mut app, &format!("alert({long:?} + '\\u0007\\r\\n', 'T\\nitle', true);")); + assert!(until_dialog(&mut app)); + for _ in 0..3 { + frame(&mut app, vec![], egui::vec2(320.0, 240.0)); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND); + assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "T\u{240a}itle"); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["errorIcon"], true); +} + +#[test] +fn the_app_and_a_run_with_nobody_to_ask_give_the_same_results() { + let source = "vc.log(confirm('a', true, 'T')); vc.log(prompt('b', 'p')); alert('c', undefined, false); vc.log('done');"; + let mut app = app(); + start(&mut app, source); + for a in ["yes", "ok", "ok"] { + assert!(until_dialog(&mut app)); + answer(&mut app, a); + } + assert!(!until_dialog(&mut app)); + let framed = app.session.script_report().unwrap().clone(); + let mut s = Session::new(); + let given = json!([{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "p"}, {"kind": "alert", "value": "ok"}]); + let headless = s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": given})).unwrap(); + assert_eq!(framed["log"], headless["log"]); + assert_eq!(framed["dialogs"], headless["dialogs"]); + assert_eq!(framed["log"], json!([true, "p", "done"])); +} diff --git a/crates/ui-egui/src/tests/scripts_menu.rs b/crates/ui-egui/src/tests/scripts_menu.rs new file mode 100644 index 000000000..751bd528a --- /dev/null +++ b/crates/ui-egui/src/tests/scripts_menu.rs @@ -0,0 +1,388 @@ +//! File › Scripts lists the Scripts folder: its scripts and subfolders above Check Script… and +//! Other Script…, names shown as they are, a listed script run without the warning, a script that +//! is gone, the notes, the macOS menu bar and the reads of the folder. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::menus::{self, Entry, Item}; +use crate::native_menu::{self, Node}; +use crate::{VectorcraftApp, script_runs, scripts_menu}; + +/// A fresh folder for test `name`, holding an empty `Scripts` folder. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vc-scripts-menu-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Scripts")).unwrap(); + dir +} + +/// An app with a document whose Scripts folder is `dir`'s. +fn app(dir: &std::path::Path) -> VectorcraftApp { + let mut session = Session::new(); + session.attach_scripts_dir(dir.join("Scripts")); + let mut app = VectorcraftApp::new(session, crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app +} + +/// Reads the folder again and waits for the listing. +fn read(app: &VectorcraftApp) { + scripts_menu::refresh(app, None); + scripts_menu::wait(); +} + +/// Writes the script `name` (`/` between its folders) in `dir`'s Scripts folder; returns its path +/// as a listing gives it. +fn write(dir: &std::path::Path, name: &str, text: &str) -> String { + let path = name.split('/').fold(dir.join("Scripts"), |path, part| path.join(part)); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, text).unwrap(); + path.to_string_lossy().into_owned() +} + +/// File › Scripts' items. +fn scripts() -> Vec { + let (_, file) = menus::menu_tree().into_iter().find(|(title, _)| *title == "File").unwrap(); + file.into_iter() + .find_map(|item| match item { + Item::Sub("Scripts", items) => Some(items), + _ => None, + }) + .unwrap() +} + +/// Menu items as text: a command's label, `label/[…]` for a submenu, `(text)` for a note and `-` +/// for a separator. +fn shape(items: &[Item]) -> String { + let one = |item: &Item| match item { + Item::Cmd(label, ..) | Item::Todo(label, _) => label.to_string(), + Item::Sub(label, items) => format!("{label}/[{}]", shape(items)), + Item::Header(text) => format!("({text})"), + Item::Sep => "-".to_string(), + }; + items.iter().map(one).collect::>().join(", ") +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +fn finish(app: &mut VectorcraftApp) { + for _ in 0..10_000 { + if !app.session.script_running() { + return; + } + script_runs::poll(app); + } + panic!("the script did not end"); +} + +const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10}); vc.run('shape.rectangle', {x:20,y:0,width:10,height:10});"; + +#[test] +fn the_folders_scripts_are_listed_above_check_script_and_other_script() { + let dir = folder("listed"); + let app = app(&dir); + assert_eq!(shape(&scripts()), "Check Script…, Other Script…", "before the first listing"); + read(&app); + assert_eq!(shape(&scripts()), "Check Script…, Other Script…", "an empty folder"); + let draw = write(&dir, "Draw Two.jsx", DRAW); + write(&dir, "Export/All Artboards.js", ""); + write(&dir, "notes.txt", ""); + read(&app); + let items = scripts(); + assert_eq!(shape(&items), "Draw Two, Export/[All Artboards], -, Check Script…, Other Script…"); + assert!(matches!(&items[0], Item::Cmd(_, scripts_menu::COMMAND, p) if *p == json!({ "path": draw })), "{:?}", items[0]); + // A script added to the folder is listed by the next read, with no restart. + write(&dir, "Added.js", ""); + assert!(shape(&scripts()).starts_with("Draw Two"), "the menus keep the last listing"); + read(&app); + assert!(shape(&scripts()).starts_with("Added, Draw Two")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_listed_script_runs_without_the_warning_as_one_undo_step() { + let dir = folder("run"); + let mut app = app(&dir); + write(&dir, "Dessin (été) #1.JSX", DRAW); + read(&app); + let items = scripts(); + let Item::Cmd(label, id, p) = &items[0] else { panic!("{:?}", items[0]) }; + let undo = app.session.doc().unwrap().history.undo.len(); + assert!(app.session.prefs.show_external_script_warning); + let (id, p) = menus::click_target(label, id, p); + menus::invoke(&mut app, &id, p); + assert!(app.ui.dialog.is_none(), "no warning for a script of the folder"); + assert!(app.session.script_running()); + finish(&mut app); + assert_eq!(objects(&app), 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(app.ui.status, "Ran Dessin (été) #1.JSX"); + assert!(app.session.prefs.show_external_script_warning, "Other Script… still warns"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_script_runs_its_own_file_whatever_its_name_ends_with() { + let dir = folder("names"); + let mut app = app(&dir); + // A label that ends like a menu item that asks for its parameters first. + let dots = write(&dir, "Draw….js", DRAW); + write(&dir, "Draw.js", "vc.run('shape.rectangle', {x:0,y:0,width:1,height:1});"); + read(&app); + let items = scripts(); + assert_eq!(shape(&items[..2]), "Draw, Draw…"); + let Item::Cmd(label, id, p) = &items[1] else { panic!("{:?}", items[1]) }; + assert_eq!(menus::click_target(label, id, p), (scripts_menu::COMMAND.to_string(), json!({ "path": dots }))); + menus::run_ui_command(&mut app, id, p).unwrap().unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 2, "the file the item names ran, not its neighbor"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_listed_script_reads_its_included_files_and_runs_files_beside_it() { + let dir = folder("include"); + let mut app = app(&dir); + write(&dir, "lib/two.jsxinc", DRAW); + write(&dir, "lib/one.js", "vc.run('shape.rectangle', {x:40,y:0,width:10,height:10});"); + let main = write(&dir, "Main.jsx", "#include \"lib/two.jsxinc\"\n$.evalFile('lib/one.js');\n"); + read(&app); + menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": main })).unwrap().unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 3, "{}", app.ui.status); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn script_and_folder_names_are_shown_as_they_are_in_every_language() { + let dir = folder("language"); + let app = app(&dir); + write(&dir, "Layers.js", ""); + write(&dir, "Export/Layers.jsx", ""); + read(&app); + let de = crate::i18n::Lang::from_code("de").unwrap(); + assert!(crate::i18n::tr(de, "Layers") != "Layers" && crate::i18n::tr(de, "Export") != "Export", "German translates both words"); + crate::i18n::set_current(de); + let items = scripts(); + let labels: Vec = items + .iter() + .filter_map(|item| match menus::entry(&app, item)? { + Entry::Sub(label, _) => Some(label.to_string()), + Entry::Item(row) => Some(row.label.to_string()), + _ => None, + }) + .collect(); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(labels[..2], ["Export", "Layers"]); + assert_ne!(labels[2], "Check Script…", "the fixed items are translated"); + // The catalogs hold the notes, and no file or folder name. + let strings = menus::menu_strings(); + assert!(scripts_menu::LABELS.iter().all(|note| strings.contains(*note))); + assert!(strings.contains("Layers") && !strings.contains("All Artboards")); + write(&dir, "Zed/All Artboards.js", ""); + read(&app); + assert!(!menus::menu_strings().contains("All Artboards") && !menus::menu_strings().contains("Zed")); + assert!(!crate::palette::items().iter().any(|(_, id, _)| id == scripts_menu::COMMAND), "a script runs from its menu item"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_script_that_left_the_folder_is_not_run_and_the_folder_is_read_again() { + let dir = folder("gone"); + let mut app = app(&dir); + let gone = write(&dir, "gone.js", DRAW); + write(&dir, "kept.js", ""); + read(&app); + std::fs::remove_file(&gone).unwrap(); + let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": gone })).unwrap().unwrap_err(); + assert_eq!(e, "the script is no longer in the Scripts folder"); + assert!(!app.session.script_running()); + scripts_menu::wait(); + assert_eq!(shape(&scripts()), "kept, -, Check Script…, Other Script…"); + // A file outside the folder is not one of its scripts, and a call needs a path. + let outside = dir.join("outside.js"); + std::fs::write(&outside, DRAW).unwrap(); + let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": outside.to_string_lossy() })).unwrap().unwrap_err(); + assert_eq!(e, "the script is no longer in the Scripts folder"); + assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({})).unwrap().is_err()); + assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": 3 })).unwrap().is_err()); + finish(&mut app); + assert_eq!(objects(&app), 0); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_read_after_a_script_that_left_the_folder_asks_for_a_frame() { + let dir = folder("gone-frame"); + let mut app = app(&dir); + let gone = write(&dir, "gone.js", DRAW); + read(&app); + std::fs::remove_file(&gone).unwrap(); + let ctx = egui::Context::default(); + let (tx, frames) = std::sync::mpsc::channel(); + ctx.set_request_repaint_callback(move |_| { + let _ = tx.send(()); + }); + app.ui_ctx = Some(ctx); + assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": gone })).unwrap().is_err()); + // With the app's copy of the context dropped, only the read's thread can still ask for a + // frame; the channel closes without one when that thread ends without asking. + app.ui_ctx = None; + assert_eq!(frames.recv(), Ok(()), "the finished read asks for a frame so an idle window takes it in"); + scripts_menu::wait(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_menu_notes_a_listing_that_stopped_and_a_folder_it_cannot_read() { + let dir = folder("notes"); + let app = app(&dir); + for n in 0..vectorcraft_engine::script::folder::MAX_SCRIPTS + 1 { + write(&dir, &format!("s{n:04}.js"), ""); + } + read(&app); + let items = scripts(); + assert_eq!(items.len(), vectorcraft_engine::script::folder::MAX_SCRIPTS + 4); + assert_eq!(shape(&items[items.len() - 4..]), format!("({}), -, Check Script…, Other Script…", scripts_menu::TRUNCATED)); + // A file where the folder should be. + std::fs::remove_dir_all(dir.join("Scripts")).unwrap(); + std::fs::write(dir.join("Scripts"), "").unwrap(); + read(&app); + assert_eq!(shape(&scripts()), format!("({}), -, Check Script…, Other Script…", scripts_menu::UNREADABLE)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_menu_listing_under_a_read_root_leaves_out_scripts_outside_it() { + use vectorcraft_engine::file_access::{AutomationRoots, confine}; + let dir = folder("read-root"); + let app = app(&dir); + let path = write(&dir, "Export/All Artboards.js", ""); + read(&app); + let scripts = || menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some(scripts_menu::COMMAND)).collect::>(); + let listed = scripts(); + assert_eq!(listed.len(), 1); + assert_eq!( + (&listed[0].path, &listed[0].label, &listed[0].params), + (&vec!["File".to_string(), "Scripts".into(), "Export".into()], &"All Artboards".to_string(), &json!({ "path": path })) + ); + let work = dir.join("work"); + std::fs::create_dir_all(&work).unwrap(); + let outside = AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); + assert!(confine(Some(&outside), scripts).is_empty()); + let inside = AutomationRoots::new(&[&dir], &[]).unwrap().unwrap(); + assert_eq!(confine(Some(&inside), scripts).len(), 1); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn names_past_the_session_limit_are_left_out_with_the_note() { + let dir = folder("names-limit"); + let app = app(&dir); + write(&dir, "Kept.js", ""); + read(&app); + assert_eq!(shape(&scripts()), "Kept, -, Check Script…, Other Script…"); + // Names other listings showed fill what one session keeps. + for n in 0..scripts_menu::MAX_NAMES { + if scripts_menu::intern(&format!("renamed {n}")).is_none() { + break; + } + } + write(&dir, "New.js", ""); + write(&dir, "Folder/Inside.js", ""); + read(&app); + assert_eq!(shape(&scripts()), format!("Kept, ({}), -, Check Script…, Other Script…", scripts_menu::TRUNCATED)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_macos_menu_bar_lists_the_same_scripts_and_follows_a_new_listing() { + let dir = folder("native"); + let app = app(&dir); + let path = write(&dir, "Export/All Artboards.js", ""); + let before = scripts_menu::revision(); + read(&app); + assert_eq!(scripts_menu::revision(), before + 1); + let bar = native_menu::from_tree(&app, &menus::menu_tree()); + let file = bar.menus.iter().find(|m| m.title == "File").unwrap(); + let scripts = file.children.iter().find_map(|n| match n { + Node::Submenu { label, children } if label == "Scripts" => Some(children), + _ => None, + }); + let Some([Node::Submenu { label, children }, Node::Separator, Node::Item(check), Node::Item(other)]) = scripts.map(Vec::as_slice) else { + panic!("{scripts:?}") + }; + assert_eq!((label.as_str(), check.command, other.command), ("Export", Some("ui.checkScript"), Some("ui.runScript"))); + let [Node::Item(script)] = children.as_slice() else { panic!("{children:?}") }; + assert_eq!((script.label.as_str(), script.command, &script.params), ("All Artboards", Some(scripts_menu::COMMAND), &json!({ "path": path }))); + assert!(script.enabled && script.shortcut.is_none()); + // A read that finds the same scripts changes nothing the menu bar would rebuild for. + read(&app); + assert_eq!(scripts_menu::revision(), before + 1); + write(&dir, "More.jsx", ""); + read(&app); + assert_eq!(scripts_menu::revision(), before + 2); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_folder_is_read_at_the_first_frame_and_when_the_window_becomes_active() { + let dir = folder("focus"); + let app = app(&dir); + write(&dir, "first.js", ""); + let ctx = egui::Context::default(); + let frame = |focused: bool| { + let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| scripts_menu::poll(&app, ui.ctx())); + out.textures_delta.clear(); + scripts_menu::wait(); + }; + let listed = || shape(&scripts()); + frame(true); + assert!(listed().starts_with("first, -"), "{}", listed()); + // While the window stays active, or stays in the background, the folder is left alone. + write(&dir, "second.js", ""); + frame(true); + frame(false); + frame(false); + assert!(listed().starts_with("first, -"), "{}", listed()); + frame(true); + assert!(listed().starts_with("first, second, -"), "{}", listed()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_read_asked_for_during_another_runs_when_that_one_ends() { + let dir = folder("pending"); + let app = app(&dir); + write(&dir, "first.js", ""); + let ctx = egui::Context::default(); + let frame = || { + let mut out = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |ui| scripts_menu::poll(&app, ui.ctx())); + out.textures_delta.clear(); + }; + frame(); + scripts_menu::wait(); + // A read that has passed the folder when a script is added, and the File menu opening then. + scripts_menu::refresh(&app, None); + scripts_menu::end_untaken(); + write(&dir, "added.js", ""); + scripts_menu::refresh(&app, None); + frame(); + scripts_menu::wait(); + assert!(shape(&scripts()).starts_with("added, first, -"), "{}", shape(&scripts())); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_app_without_a_scripts_folder_keeps_the_two_fixed_items() { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + read(&app); + assert_eq!(shape(&scripts()), "Check Script…, Other Script…"); + let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": "draw.js" })).unwrap().unwrap_err(); + assert_eq!(e, "the script is no longer in the Scripts folder"); + assert_eq!(menus::UI_COMMANDS.iter().filter(|c| c.0 == scripts_menu::COMMAND).count(), 1); + let _: Value = app.run("script.listInstalled", json!({})).unwrap(); +} diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 595d12b51..1da5b9332 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -174,8 +174,9 @@ pub(super) fn pasted(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() } +/// The objects on the first layer (its ruler guides left out). pub(super) fn count(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.layers[0].children().unwrap().len() + app.session.active().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count() } /// An egui `PasteImage` from the host is the signal to read the rich system clipboard: it must diff --git a/crates/ui-egui/src/tests/sysclip_guides.rs b/crates/ui-egui/src/tests/sysclip_guides.rs index a54bd57eb..50c4535c3 100644 --- a/crates/ui-egui/src/tests/sysclip_guides.rs +++ b/crates/ui-egui/src/tests/sysclip_guides.rs @@ -14,7 +14,7 @@ use crate::tests_sysclip::{app, app_with_busy_worker, copy_elsewhere, copy_rect, use crate::{Services, VectorcraftApp, background}; fn guides(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.guides.len() + app.session.active().unwrap().doc.ruler_guides().len() } /// Add a vertical guide at `pos`, select only it and copy it. @@ -35,7 +35,8 @@ fn a_guides_only_copy_pastes_the_guides_not_what_another_app_copied_before() { run(&mut app, "edit.paste", json!({})); assert_eq!(guides(&app), 2, "the copied guide was pasted"); assert_eq!(count(&app), 0, "and not the other app's text"); - assert_eq!(app.session.active().unwrap().selection.guides, [1]); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), [1]); // A second Paste takes the guides again. run(&mut app, "edit.paste", json!({})); assert_eq!((guides(&app), count(&app)), (3, 0)); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 96e453344..53042baa6 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -184,6 +184,30 @@ fn type_on_a_path_offers_its_options() { assert_eq!(d.fields.get("effect"), Some(&json!("skew"))); } +/// Type on a path offers Dynamic Text, which turns it on and off; type in a thread does not. +#[test] +fn type_on_a_path_offers_dynamic_text() { + let mut app = app(); + let path = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()); + let t = id(app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + let dynamic = |app: &VectorcraftApp| matches!(kind(app, t), NodeKind::Text(ref t) if t.dynamic_text); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Dynamic Text"), "{texts:?}"); + click(&mut app, "Dynamic Text"); + assert!(dynamic(&app)); + click(&mut app, "Dynamic Text"); + assert!(!dynamic(&app)); + // Threaded, it is not offered. + let frame = id(app.run("shape.rectangle", json!({"x": 60, "y": 200, "width": 200, "height": 100})).unwrap()); + select(&mut app, &[t, frame]); + app.run("text.thread.create", json!({})).unwrap(); + select(&mut app, &[t]); + let (_, texts) = bar(&mut app); + assert!(!has(&texts, "Dynamic Text"), "{texts:?}"); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c70cca173..2dd361a7d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,13 +45,13 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -#[cfg(test)] -pub(crate) use dropdowns::menu_floor; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, }; +#[cfg(test)] +pub(crate) use dropdowns::{menu_floor, menu_room_either}; pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader}; pub(crate) use labels::{label_field, take_previous_label, text_width}; pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit}; @@ -63,6 +63,7 @@ pub use numeric::{ }; #[cfg(test)] use numeric::{decimal_point, field_number, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth}; +pub(crate) use panel_chrome::{PANEL_FOOTER, panel_footer, panel_footer_bottom, panel_footer_space, panel_list_height}; pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar}; pub(crate) use text::{ arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter, @@ -657,6 +658,27 @@ mod menu_room_tests { assert_eq!(menu_room(100.0, 24.0, 92.0), 92.0); } + /// A popup opens below its button while that leaves the minimum height, even when the list + /// then scrolls. + #[test] + fn a_menu_opens_below_while_that_side_has_the_minimum() { + assert_eq!(menu_room_either(300.0, 800.0, 980.0), 288.0); + assert_eq!(menu_room_either(132.0, 800.0, 980.0), 120.0); + } + + /// With less than the minimum below, the list takes the room above, up to the window's height. + #[test] + fn a_menu_opens_above_when_below_is_too_short() { + assert_eq!(menu_room_either(40.0, 500.0, 980.0), 488.0); + assert_eq!(menu_room_either(40.0, 500.0, 300.0), 300.0); + } + + /// With little room on either side, the list keeps the minimum height. + #[test] + fn a_menu_keeps_the_minimum_with_little_room_either_side() { + assert_eq!(menu_room_either(40.0, 60.0, 980.0), 120.0); + } + /// The floor one window records does not reach a menu in another window of the same context. #[test] fn the_menu_floor_applies_in_the_window_that_recorded_it() { diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 1046e56a7..44903d661 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -234,8 +234,13 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - && click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); was_open && !clicked_elsewhere }; + // Closing with a field in it that has the keyboard: it draws once more, so the field lets go of + // the keyboard to the click and commits what was typed, as in a panel (#6429). + let last = was_open && !open && field_focused_in(ctx, egui::LayerId::new(egui::Order::Foreground, id)); + let mut shown = open || last; let inner = - egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); + egui::Popup::menu(resp).id(id).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); + open = shown && !last; ctx.data_mut(|d| { if open { d.insert_temp(id, frame); @@ -246,6 +251,13 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - inner } +/// Whether a field in the popup on `layer` has the keyboard. A popup closing with one draws once +/// more before it goes: the field lets go of the keyboard to the click away and commits what was +/// typed, as it does in a panel. A popup that closed without drawing again dropped it (#6429). +pub(crate) fn field_focused_in(ctx: &egui::Context, layer: egui::LayerId) -> bool { + ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| r.layer_id == layer) +} + /// Whether the popover for this anchor was open in the latest completed frame. pub(crate) fn popover_is_open(ctx: &egui::Context, anchor: egui::Id) -> bool { let frame = ctx.cumulative_frame_nr(); @@ -272,12 +284,25 @@ pub(crate) fn menu_floor(ctx: &egui::Context) -> f32 { ctx.data(|d| d.get_temp::(id)).unwrap_or_else(|| ctx.content_rect().bottom()) } +/// The space a menu list leaves between its end and the window's edge or bars. +const MENU_GAP: f32 = 12.0; +/// The least height a menu list is given. +const MENU_MIN_HEIGHT: f32 = 120.0; + /// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt, /// and never more than `tallest`, the most that fits in the window. pub fn menu_room(floor: f32, top: f32, tallest: f32) -> f32 { - const BOTTOM_GAP: f32 = 12.0; - const MIN_HEIGHT: f32 = 120.0; - (floor - top - BOTTOM_GAP).max(MIN_HEIGHT).min(tallest) + (floor - top - MENU_GAP).max(MENU_MIN_HEIGHT).min(tallest) +} + +/// The height a menu list may take when its popup can open below or above the rect it opens from: +/// `below` and `above` are the heights each side leaves for the list. The list goes below while that +/// side has room for the minimum height, otherwise on the side with more room. It gets at least +/// 120 pt and never more than `tallest`. +pub(crate) fn menu_room_either(below: f32, above: f32, tallest: f32) -> f32 { + let (below, above) = (below - MENU_GAP, above - MENU_GAP); + let room = if below >= MENU_MIN_HEIGHT { below } else { below.max(above) }; + room.max(MENU_MIN_HEIGHT).min(tallest) } /// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never @@ -286,19 +311,119 @@ pub fn list_scroll_style() -> egui::style::ScrollStyle { egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } } +/// The widget that opened a popup and whether it is a submenu's row (`None` when none was found), +/// the frame the popup was opened in, and the frame it was last drawn in. +type Opener = (Option<(egui::Id, bool)>, u64, u64); + +/// The frames after a popup opens in which a search that found no opener runs again. +const OPENER_RETRY_FRAMES: u64 = 3; + +/// The widget that opened `popup`: a button whose popup has egui's default id, or a submenu's row, +/// found among the widgets of this frame and the previous one. Once found, later frames of the same +/// opening reuse it; a search that finds nothing runs again for the first few frames. +fn popup_opener(ctx: &egui::Context, popup: egui::Id) -> Option<(egui::Id, bool)> { + let key = popup.with("menu-scroll-opener"); + let frame = ctx.cumulative_frame_nr(); + let cached = ctx.data(|d| d.get_temp::(key)).filter(|&(_, _, seen)| seen + 1 >= frame); + let opened = cached.map_or(frame, |(_, opened, _)| opened); + let opener = match cached { + Some((found, _, _)) if found.is_some() || frame >= opened + OPENER_RETRY_FRAMES => found, + _ => { + let found = ctx.viewport(|vp| { + [&vp.this_pass, &vp.prev_pass].into_iter().flat_map(|pass| pass.widgets.layers()).flat_map(|(_, widgets)| widgets.iter()).find_map( + |w| { + let submenu = egui::containers::menu::SubMenu::id_from_widget_id(w.id) == popup; + (submenu || w.id.with("popup") == popup).then_some((w.id, submenu)) + }, + ) + }); + found.filter(|&(widget, submenu)| { + submenu || ctx.read_response(widget).is_some_and(|response| egui::Popup::default_response_id(&response) == popup) + }) + } + }; + ctx.data_mut(|d| d.insert_temp::(key, (opener, opened, frame))); + opener +} + +/// Where the popup or menu that `ui` is drawn in opens from: the rect of the button or row that +/// opened it, or the point a context menu was opened at. The flag is true for a submenu, which +/// opens beside its row: down from the row's top, or up from its bottom. While the opening widget +/// is not drawn, the rect it last had is used. A popup with an id of its own (`Popup::id`) has no +/// anchor. +fn popup_anchor(ui: &Ui) -> Option<(Rect, bool)> { + let ctx = ui.ctx(); + let popup = ui.layer_id().id; + let root = ui.stack().iter().last()?; + if !matches!(root.kind(), Some(egui::UiKind::Menu | egui::UiKind::Popup)) { + return None; + } + if let Some(at) = egui::Popup::position_of_id(ctx, popup) { + return Some((Rect::from_pos(at), false)); + } + let (widget, submenu) = popup_opener(ctx, popup)?; + let key = popup.with("menu-scroll-anchor"); + let drawn = ctx.read_response(widget).and_then(|response| egui::PopupAnchor::from(&response).rect(popup, ctx)); + let rect = match drawn { + Some(rect) => { + ctx.data_mut(|d| d.insert_temp(key, rect)); + rect + } + None => ctx.data(|d| d.get_temp::(key))?, + }; + // A submenu opens from its row grown by half the menu's margin, as egui's `SubMenu` places it. + let grow = if submenu { egui::Frame::menu(ui.style()).total_margin().sum().y * 0.5 } else { 0.0 }; + Some((rect.expand2(vec2(0.0, grow)), submenu)) +} + /// The body of a menu or popup list: as tall as its items up to the bottom bars of the window it /// opens in, and scrolling only past that, so long menus (Window, Effect, a panel's menu) stay /// reachable on small windows and in a floating panel's short OS window. Its scroll bar has a column -/// of its own, so it never covers the shortcuts or the submenu arrows (#2086, #5347). +/// of its own, so it never covers the shortcuts or the submenu arrows (#2086, #5347). When the list +/// scrolls, that column widens the popup instead of narrowing the items, so their labels stay on one +/// line. +/// +/// The list's height comes from the room below and above the button, row or point the popup opens +/// from. A popup with an id of its own measures from its own top instead. pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - let ctx = ui.ctx(); - let tallest = (ctx.content_rect().height() - egui::Frame::popup(ui.style()).total_margin().sum().y).max(0.0); - let room = menu_room(menu_floor(ctx), ui.next_widget_position().y, tallest); + let ctx = ui.ctx().clone(); + let margins = egui::Frame::popup(ui.style()).total_margin().sum(); + let tallest = (ctx.content_rect().height() - margins.y).max(0.0); + let top = ui.next_widget_position().y; + // The rows above the list (the popup's margin, a search field) move with the popup. + let popup_top = ui.stack().iter().last().map_or(top, |root| root.max_rect.top()); + let ahead = top - popup_top; + let (floor, window_top) = (menu_floor(&ctx), ctx.content_rect().top()); + let room = if let Some((anchor, submenu)) = popup_anchor(ui) { + let (down_from, up_to) = if submenu { (anchor.top(), anchor.bottom()) } else { (anchor.bottom(), anchor.top()) }; + menu_room_either(floor - down_from - ahead, up_to - window_top - ahead, tallest) + } else { + menu_room(floor, top, tallest) + }; + // The items' width on the first frame of this showing, when the scroll area does not yet show + // its bar. Later frames lay the items out at least that wide, and the bar's column is added to + // the popup's width. The width is kept until the popup closes. + let key = ui.next_auto_id().with("menu-scroll-width"); + let frame = ctx.cumulative_frame_nr(); + let first = ctx.data(|d| d.get_temp::<(u64, f32)>(key)).filter(|(shown, _)| shown + 1 >= frame).map(|(_, width)| width); + let scroll = list_scroll_style(); + // The widest the items can be with the popup's margins and the bar's column inside the window. + let widest = (ctx.content_rect().width() - margins.x - scroll.allocated_width()).max(0.0); ui.scope(|ui| { - ui.spacing_mut().scroll = list_scroll_style(); + ui.spacing_mut().scroll = scroll; // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to // its items when they need less. - egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner + let area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room); + let out = area.show(ui, |ui| { + // Always the same child, so the items' ids stay the same on the frame the width changes. + let max = ui.max_rect(); + let width = first.map_or(max.width(), |first| first.min(widest).max(max.width())); + let rect = egui::Rect::from_min_size(max.min, egui::vec2(width, max.height())); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(*ui.layout()), add_contents).inner + }); + let width = first.unwrap_or(out.content_size.x); + ctx.data_mut(|d| d.insert_temp(key, (frame, width))); + out.inner }) .inner } diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 3e4f2119f..bf25ce58b 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -121,6 +121,27 @@ fn unit_field( stops: &[f64], live: bool, empty: &str, +) -> Option { + unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty) +} + +/// [`unit_field`] whose blank field (`value` is `None`) steps from `blank_from` (points) with +/// ↑/↓ and the wheel instead of doing nothing. +#[allow(clippy::too_many_arguments)] +fn unit_field_from( + ui: &mut Ui, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + share_of_value: bool, + bounds: RangeInclusive, + stops: &[f64], + live: bool, + blank_from: Option, + empty: &str, ) -> Option { let id = id_with(ui.scope_id(), id); let shown = value.map(|v| unit.format(v)).unwrap_or_else(|| empty.to_string()); @@ -132,8 +153,13 @@ fn unit_field( let read = |b: &str| typed.parse(&decimal_point(b)); let bounds_unit = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); let stops: Vec = stops.iter().map(|&s| unit.from_pt(s)).collect(); - let stepped = - step_value(ui, &resp, &mut buf, &bounds_unit, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None, &stops); + let blank_from = blank_from.filter(|_| value.is_none()); + let read_unit = |b: &str| match read(b) { + Some(v) => Some(unit.from_pt(v)), + None if b.trim().is_empty() => blank_from.map(|v| unit.from_pt(v)), + None => None, + }; + let stepped = step_value(ui, &resp, &mut buf, &bounds_unit, (read_unit, |v| unit.format(unit.to_pt(v))), None, &stops); let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit); let commit = commit && buf != shown; if commit { @@ -821,12 +847,24 @@ pub fn spin_field_stops_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, None, None, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false, "") + let blank_from = BLANK_WEIGHT_STEPS_FROM; + // A blank field steps in points from `blank_from`, whatever the unit's preset ladder (#5349). + let stops = if value.is_none() { &BLANK_WEIGHT_STOPS[..] } else { presets }; + let fmt = |v: f64| unit.format(v); + spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, stops, &fmt, &mut |ui, fw, label| { + unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "") }) .and_then(SpinPick::value) } +/// Where a blank Stroke Weight (the selected objects differ) steps from, in points: up goes to +/// 0.5 pt and down to 0 pt. This is the behaviour #5349 asks for. +const BLANK_WEIGHT_STEPS_FROM: f64 = 0.25; + +/// The stops a blank Stroke Weight steps through, in points, so that up gives 0.5 pt in every +/// Units > Stroke, not the first preset of the unit's own ladder above 0.25 pt. +const BLANK_WEIGHT_STOPS: [f64; 2] = [BLANK_WEIGHT_STEPS_FROM, 0.5]; + /// A distance field with spinner arrows kept within `bounds` (points), as the Drop Shadow and /// Outer Glow dialogs show their offsets and blur: the spinner steps one `unit` at a time /// (Shift ×10, Ctrl/Cmd ÷10), and it, ↑/↓, the wheel, a scrub and a typed value stop at both @@ -842,7 +880,7 @@ pub fn spin_length_within( let label = take_previous_label(ui); let step = Some(unit.to_pt(1.0)).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(1.0); let field = bounds.clone(); - spin_generic(ui, label, value, None, None, None, width, step, *bounds.start(), &[], &[], &|v| unit.format(v), &mut |ui, fw, label| { + spin_generic(ui, label, value, None, None, None, None, width, step, *bounds.start(), &[], &[], &|v| unit.format(v), &mut |ui, fw, label| { unit_field(ui, id, label, None, value, unit, fw, false, field.clone(), &[], false, "") }) .and_then(SpinPick::value) @@ -867,7 +905,7 @@ pub fn spin_range( let places = decimals.min(6) as i32; let value = value.map(|v| within(v, &range)); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, label, value, None, None, None, width, step, *range.start(), &[], &[], &fmt, &mut |ui, fw, label| { + spin_generic(ui, label, value, None, None, None, None, width, step, *range.start(), &[], &[], &fmt, &mut |ui, fw, label| { bounded_field(ui, id, label, None, value, suffix, decimals, fw, &range, None, false, false) }) .and_then(SpinPick::value) @@ -925,7 +963,7 @@ pub fn spin_field_auto_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { + spin_generic(ui, label, value, None, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[], false, "") }) } @@ -964,7 +1002,7 @@ pub fn spin_plain_named( let label = take_previous_label(ui); let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, label, value, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { + spin_generic(ui, label, value, None, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None, false, false) }) .and_then(SpinPick::value) @@ -990,6 +1028,7 @@ pub fn spin_kerning( ui, label, value, + None, automatic, optical, Some(optical_label), @@ -1010,6 +1049,7 @@ fn spin_generic( ui: &mut Ui, label: Option, value: Option, + blank_from: Option, auto: Option, optical: Option, optical_label: Option<&str>, @@ -1049,14 +1089,14 @@ fn spin_generic( ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); - // A blank field (values that differ) has nothing to step from. A click steps once; an + // A blank field (values that differ) steps from `blank_from`, if any. A click steps once; an // arrow held down steps again and again after a pause, as a spin button does (#4267). let is_up = match held_repeat(ui, &sresp) { Some(true) => Some(ui.rect_contains_pointer(up)), None if sresp.clicked() => sresp.interact_pointer_pos().map(|p| up.contains(p)), _ => None, }; - if let (Some(is_up), Some(v)) = (is_up, value) { + if let (Some(is_up), Some(v)) = (is_up, value.or(blank_from)) { let modifiers = ui.input(|i| i.modifiers); out = Some(SpinPick::Value(spinner_step(v, step, min, is_up, modifiers, stops, fmt))); } diff --git a/crates/ui-egui/src/widgets/panel_chrome.rs b/crates/ui-egui/src/widgets/panel_chrome.rs index e07367dab..2c8471628 100644 --- a/crates/ui-egui/src/widgets/panel_chrome.rs +++ b/crates/ui-egui/src/widgets/panel_chrome.rs @@ -1,5 +1,41 @@ use super::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, pos2, theme, vec2}; +pub(crate) const PANEL_FOOTER: &str = "vectorcraft-panel-footer"; +const FOOTER_HEIGHT: f32 = 31.0; + +/// The finite body edge supplied by a sized dock, float or user-resized flyout. +/// Untouched flyouts omit it so their content can still determine their height. +pub(crate) fn panel_footer_bottom(ui: &Ui) -> Option { + ui.stack().iter().find_map(|stack| stack.tags().get_downcast::>(PANEL_FOOTER)).copied().flatten() +} + +/// Space left for a list above the footer and any intervening controls, or its unfitted fallback. +pub(crate) fn panel_list_height(ui: &Ui, fallback: f32, after: f32) -> f32 { + panel_footer_bottom(ui).map_or(fallback, |bottom| (bottom - ui.next_widget_position().y - FOOTER_HEIGHT - after).max(0.0)) +} + +/// Pad short content so a footer of `height` ends at the panel's saved bottom edge. +pub(crate) fn panel_footer_space(ui: &mut Ui, height: f32) { + if let Some(bottom) = panel_footer_bottom(ui) { + ui.add_space((bottom - ui.next_widget_position().y - height).max(0.0)); + } +} + +/// Pin a custom footer (for example the Variables panel's two rows). +pub(crate) fn panel_footer(ui: &mut Ui, height: f32, add: impl FnOnce(&mut Ui)) { + let Some(bottom) = panel_footer_bottom(ui) else { return add(ui) }; + let top = bottom - height; + if ui.next_widget_position().y > top { + ui.add_space(height); + } + let rect = Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(ui.available_width(), height)); + ui.painter().rect_filled(rect, 0.0, Tokens::get(ui.ctx()).panel); + // Own the blank parts too, so a click cannot select a scrolled row behind the footer. + let _background = ui.interact(rect, ui.scope_id().with("panel-footer-background"), Sense::CLICK); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), add); + ui.advance_cursor_after_rect(rect); +} + /// A bordered list box (Swatches tiles, Appearance rows, Artboards list). pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); @@ -9,6 +45,25 @@ pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { /// A bottom button bar separated from the content by a divider (panel footers). pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { let t = Tokens::get(ui.ctx()); + if let Some(bottom) = panel_footer_bottom(ui) { + // Reserve scrollable room after long content so its final row can move above + // the pinned footer instead of remaining hidden underneath its buttons. + let top = bottom - FOOTER_HEIGHT; + if ui.next_widget_position().y > top { + ui.add_space(FOOTER_HEIGHT); + } + let footer = Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(ui.available_width(), FOOTER_HEIGHT)); + ui.painter().rect_filled(footer, 0.0, t.panel); + let _background = ui.interact(footer, ui.scope_id().with("panel-footer-background"), Sense::CLICK); + ui.painter().hline(footer.x_range(), top + 4.0, Stroke::new(1.0, t.divider)); + let row = Rect::from_min_max(pos2(footer.left(), top + 7.0), footer.max); + ui.scope_builder(egui::UiBuilder::new().max_rect(row).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + add(ui); + }); + ui.advance_cursor_after_rect(footer); + return; + } ui.add_space(4.0); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); ui.painter().rect_filled(r, 0.0, t.divider); @@ -202,3 +257,40 @@ pub(super) fn chevron_cell(ui: &mut Ui, h: f32) -> Response { dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); dresp } + +#[cfg(test)] +mod footer_tests { + use super::{PANEL_FOOTER, bottom_bar, panel_footer}; + + /// Blank footer space intercepts clicks instead of selecting list rows underneath it. + #[test] + fn blank_footer_space_does_not_click_a_row_scrolled_behind_it() { + use egui::{Event, PointerButton, Rect, Sense, UiBuilder, UiStackInfo, pos2}; + for custom in [false, true] { + let ctx = egui::Context::default(); + let mut clicked = false; + let at = pos2(150.0, 190.0); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + for events in [vec![], vec![], vec![Event::PointerMoved(at), button(true)], vec![button(false)]] { + let input = + egui::RawInput { screen_rect: Some(Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 200.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + let info = UiStackInfo::default().with_tag_value(PANEL_FOOTER, Some(200.0_f32)); + ui.scope_builder(UiBuilder::new().ui_stack_info(info), |ui| { + clicked |= ui.allocate_rect(Rect::from_min_max(pos2(0.0, 100.0), pos2(200.0, 200.0)), Sense::click()).clicked(); + let contents = |ui: &mut egui::Ui| { + let _button = ui.button("Footer button"); + }; + if custom { + panel_footer(ui, 31.0, contents); + } else { + bottom_bar(ui, contents); + } + }); + }); + output.textures_delta.clear(); + } + assert!(!clicked, "custom={custom}: a hidden row received the footer click"); + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4fa8de6da..04715474d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -92,7 +92,7 @@ the two can still win that race. | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`). A key of `params` that the command's params description doesn't name is an error naming it and the nearest key the command takes, before anything runs (`unknown parameter `widht`; did you mean `width`?`); see [Parameter keys](mcp.md#parameter-keys) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | | `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one; a key ruler guide as `keyGuide`), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | -| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | +| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below), `opening` (the paths of the documents still being read). Under `--automation-read-root` the reply leaves out the paths outside the read roots (see `docs/mcp.md`) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | @@ -102,7 +102,7 @@ the two can still win that race. | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | @@ -691,7 +691,8 @@ In the desktop app a Paste from the menus or the keys (`ui.key`) decodes a bitma `edit.paste*` reads it at once. File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference -lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the +lists (0 hides them all; `file.recentFiles` returns the listed paths; under a read root, `ui.menu.list` and +`file.recentFiles` leave out the files outside it and `file.openRecentN` answers `no such recent file` for one), `file.reveal` shows the document's file in the system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` without params opens a file picker in the Templates folder (preference `templatesFolder`). Older native files open as " [Converted]" (preference `appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved diff --git a/docs/dimensions.md b/docs/dimensions.md index 1f1db9a49..6f11a29c9 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -6,11 +6,37 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers - **Linear:** hover a path segment and drag outwards: releasing places the dimension. Or click two points (anchors snap, Shift constrains to 45°), then click to place. A click within 8 pixels of an anchor of any path, in groups and compound paths too, takes that anchor, and the dimension - stays linked to it. A press on an anchor picks it when the pointer moves less than 8 pixels + stays linked to it. A point on a segment stays at its place along the segment, also when the + segment's anchors or handles are edited. Any other point is linked to the path under it, or, + with no path under it, to the path at the other end, and moves, scales, turns and reflects with + that path. Beside a path whose anchors and handles all lie on one line, such as a straight line, + the point keeps its distance along and across the path: a reflection moves it as a half turn + does, and a scale that is not uniform changes its distance across the path in proportion to the + path's length. A press on an anchor picks it when the pointer moves less than 8 pixels before the release; a longer drag drags out the dimension of the segment under the pointer. A curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects' anchors and centres, and the second onto the construction guides through the first; the guides show while hovering, before each click. + A linear dimension is aligned, horizontal or vertical, as the + [linear dimensions help](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/plot-linear-dimensions-of-objects.html) + describes: after the second click, clicking again places an aligned dimension, and moving the + pointer away gives a horizontal or vertical one. Which one a pointer position gives is + VectorCraft's own rule. When the pointer is beside the two points (the nearest point of the + line through them lies between them) or on that line, the dimension is aligned. When the + pointer is past either point, the line through the points and the perpendicular through that + point divide the area beyond it into two quarters, one on each side of the line. The quarter + that contains the direction straight up or down from the point gives the horizontal distance, + and the quarter that contains the direction straight left or right gives the vertical distance. + A click within 8 pixels of the second point, or of where the pointer was for the second click, + places an aligned dimension. Two points on one horizontal or vertical line always give an + aligned dimension. A dragged segment uses the same rule with its two ends. + A horizontal or vertical dimension keeps its orientation when it or the art it measures turns, + and shows the new horizontal or vertical distance, as the help describes for rotated objects. It + shows 0 when its points lie on one line across its axis. When the line of a linked dimension + lies outside its points, the line stays on the same side of the art, as far past the outermost + point as before. An unlinked dimension that + turns has its line where the turn puts it, or, when that would put it between the points, as + far past the outermost point as before. - **Angular:** hover a corner where two straight segments meet and drag outwards. Or click two straight lines that meet (or would, extended), then click to place. - **Radial:** hover a circle, or a segment that is a perfect arc, and drag: outwards gives its @@ -32,10 +58,15 @@ position and alignment; changing one changes that dimension. **Apply to All** gi dimension in the document its style; **Set as Default** makes it the style of new dimensions. Measurements follow the art they measure through moves, scaling and anchor and handle edits, -undo and redo included. Deleting the art, or changing its number of anchors or whether it is -closed, removes its dimensions in the same undo step rather than attaching them to a different -edge. A linked dimension moved on its own stays where it was dropped (its distance from the art -changes); moved together with its art, it keeps its distance. +undo and redo included. When the art is reflected, the line of a linked linear dimension goes to +the reflected side, as far past the outermost point as before. This does not happen for art whose +anchors and handles all lie on one line, such as a straight line, which a half turn puts in the +same place as a reflection. Deleting the art, changing its number of anchors or whether it is +closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular +leaves its dimensions where they were, no longer linked, in the same undo step, rather than +attaching them to a different edge. Undo links them again. A linked dimension moved on its own +stays where it was dropped (its distance from the art changes); moved together with its art, it +keeps its distance. Native `.vectorcraft` files keep the recipes and links. SVG, PDF and bitmap output contain ordinary paths and text, so exported dimensions look the same. Ungrouping a dimension leaves its @@ -43,20 +74,38 @@ paths and text as ordinary art. Object › Expand, Object › Expand Appearance, and Object › Path › Outline Stroke turn a dimension whose art they change into a plain group of that art, also when they reach only a part of it: moving or scaling the group does not redraw it, it does not follow the art it measured, and undoing the command gives the live dimension back. The menus dim -Object › Expand… while a whole dimension is selected, because its label has appearance items; -Object › Expand Appearance and the `object.expand` command still reach it. A dimension selected with -the art it measures also becomes a plain group when Object › Expand, Object › Expand Appearance or -Object › Path › Outline Stroke changes only that art and the art no longer fits the dimension's link, -for example because its number of anchors changed. When the art keeps its id and anchor count and the -dimension's own art does not change, the dimension stays live and its label shows the new length. Pasted -dimensions keep their geometry and drop their links, so they never bind to unrelated art in another -document. +Object › Expand… while a whole dimension is selected without the art it measures, because its label +has appearance items; Object › Expand Appearance and the `object.expand` command still reach it. + +When a whole dimension is selected together with art it measures, directly or in selected groups +without fills, strokes or effects of their own, Object › Expand, Object › Expand Appearance and +Object › Path › Outline Stroke leave the dimension's lines and label as they are and change only the +other art. The menus do not dim Object › Expand… for the label of such a dimension. If the new art no +longer fits the dimension's link, for example because its number of anchors changed, the dimension +stays where it was, no longer linked, as it does when its art is deleted. Undoing the command links +it again. When the art keeps its id and anchor count, the dimension stays linked and its label shows +the new length. When the dimension is all the command would change, the command changes nothing and +reports the error it gives for a selection with nothing to change: "Expand: nothing to expand", +"Expand Appearance: the selection has a basic appearance" or "Outline Stroke: no stroked paths +selected". A selected group with fills, strokes or effects of its own changes everything it holds +when its appearance expands, its dimensions included: a geometry effect is baked into their lines +and label, which become a plain group, and a blur, feather or inner glow turns them into part of the +image that replaces the group's art. Type › Create Outlines still outlines the label of a dimension +selected with its art, which turns the dimension into a plain group. + +Pasted dimensions keep their geometry and drop their links, so they never bind to unrelated art in +another document. ## Commands - `dimension.create`: two points, or a source (segment, angle, circle, arc, points), the mode, - offset and style; returns the group id and label. -- `dimension.update`: restyle or move existing dimensions. + the orientation of a linear dimension (`aligned`, the default, `horizontal` or `vertical`), + offset and style; returns the group id and label. `offset` is the distance of the dimension + line from the first point: along the left normal of a → b for an aligned dimension, downward + for a horizontal one and leftward for a vertical one. A negative offset goes the other way. +- `dimension.update`: restyle or move existing dimensions, or change the orientation of linear + ones. A new orientation keeps the line on the side of the points it was on. An update that + changes nothing records no undo step. - `dimension.applyToAll`: every dimension takes one dimension's style, in one undo step. - `tool.select {"tool":"dimension"}` and `tool.setOption {"tool":"dimension","values":{"mode":"linear","style":{"units":"mm","scale":"1:10"}}}` diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 029d64cc9..d42e770df 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -54,7 +54,7 @@ private editing data. VectorCraft: own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. Type from the text document keeps auto leading as auto, each line taking the paragraph's share (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), - and the paragraph's hyphenation options and tab stops. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -113,7 +113,10 @@ private editing data. VectorCraft: gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character - and paragraph attributes are left out with a warning. Each run's baseline direction is written in the form the reader + and paragraph attributes are left out with a warning. A font of the text document's font set that is a variable font + also names where in its design space the type is set (each `fvar` axis in the font's order, as 16.16 fixed: the named + instance's setting, or the run's own Variable Font setting) and its version string, so the type doesn't open at the + font's default instance (#6388); two settings of one font are two fonts of the set, and a static font names neither. Each run's baseline direction is written in the form the reader below reads it: 1 when Standard Vertical Roman Alignment is on, 2 when it is off and 3 for Tate-chu-yoko; the document's defaults and the normal character style carry 1. A run with both Tate-chu-yoko and Standard Vertical Roman Alignment is written as Tate-chu-yoko, with the type attributes warning. Type in opacity masks, pattern tiles, symbols and art cut to a @@ -311,6 +314,15 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | | Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) | +## PDF form gradients + +PDF export's linear and radial gradients stay aligned inside transparency groups, opacity masks, +PDF layers and overprinting art. The original synthetic tests in +`crates/pdf/tests/gradient_forms.rs` check the output with macOS Quartz and an import/render +round trip. The three Quartz checks fail on main `65adb7b21` and pass with the form-space fix; +translated layer bounds also have a regression for the current writer's layer-box repair. +Stop opacity on translated artboards remains outside this regression coverage. + ## PDF editable imports - [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder. @@ -327,6 +339,17 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | +| 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | +| 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | +| 2026-11-11 | minor | `.ai` save: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) | +| 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | +| 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) | +| 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | +| 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged | +| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | +| 2026-11-10 | minor | `.ai` save: a variable font's record in the text document's font set names the type's place in the font's design space (`/4`, each `fvar` axis as 16.16 fixed: the named instance's setting, or the run's own Variable Font setting) and the font's version (`/5`). Without them, type in a named instance such as Bold opened at the font's default instance in the app that opens the file. Two settings of one font are now two fonts of the set; a static font's record is unchanged. The record's form comes from the public report in #6388. Test `ai_editing::tests::variable_font_instances_name_their_design_space` (#6388) | +| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type"); nothing said it before, while the other envelopes that keep their drawn art have a note. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` | | 2026-11-10 | minor | `.ai` and EPS import: the check that keeps a live envelope or warped type only where it draws as the art saved with it renders each on its own, over nothing, and compared the pixels seen on white, where white art looks like nothing. White contents that drew somewhere else passed it and opened live in the wrong place (white warped type off its banner, in users' files). Pixels now also differ when they look different seen on black, so such art keeps its saved art, as colored art does. Tests `import::ai::tests::a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art`, `white_warped_type_that_draws_elsewhere_comes_in_as_its_look` | | 2026-11-10 | minor | PDF import: an image in a DeviceN space of one colorant opens as a Separation image does (#6314): a grayscale image of its tints colorized by its ink. One of two or more colorants that are all process plates (Cyan, Magenta, Yellow, Black, and the ink `None`) opens as a CMYK image, each colorant's tint in its plate and its Decode array applied (8 or 16 bits a sample; one that can't be read, such as a JPEG, opens in RGB with the CMYK images' warning). Both used to open as RGB images, losing their inks. A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB: an image holds one ink or the process plates, not both (#6348). Tests `colorize::a_devicen_image_of_one_ink_opens_colorized_by_it`, `colorize::a_devicen_image_of_process_plates_opens_in_cmyk`, `colorize::a_duotone_devicen_image_still_opens_as_rgb` | | 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | diff --git a/docs/gaps.md b/docs/gaps.md index 0b910710f..b722ace84 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,19 +1,17 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence -**Feature evidence last updated:** 2026-11-02. -| 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | +**Feature evidence last updated:** 2026-11-03. + Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged. -**PR #3272 revision — 2026-11-04:** Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged. - Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. @@ -24,12 +22,8 @@ adds one undo step; the list follows saved font filters, ignores search text, an stops at its ends. These interaction details are VectorCraft's choices. Scores and estimates are unchanged. -**Feature evidence last updated:** 2026-11-02. -**Feature evidence last updated:** 2026-11-03. - The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. -- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches - Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged. @@ -40,9 +34,13 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | +| 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | +| 2026-11-04 | minor | PR #3273: Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. | +| 2026-11-04 | minor | PR #3272: Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged. | | 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | @@ -51,6 +49,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | G15: Selection skips building corner and pie widgets when the pointer is well outside a large selection; empty-canvas hover with 20,000 selected paths took 28.72 ms before and 16.41 ms after in local median runs. Other cursor targets remain active; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; after the path hit-test improvement, a 200,000-anchor path's segment cursor took 3.72 ms before and 2.66 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | | 2026-11-05 | minor | G15: large-selection transforms reuse object locations found during command validation; a 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs. Estimate unchanged. | +| 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | G9, G14: `.ai` paths with a Calligraphic brush open brushed (the brush on the stroke, at its weight) where the object's settings are the brush's own, and the brush's Tilt, Bearing and Rotation codes come in as those channels; Windows reads the pen's tilt and barrel rotation from Windows Ink (#372). Checked against a user's file of 6D Art Pen strokes, used locally; not tried with a pen. Scores and estimates unchanged. | | 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | @@ -60,17 +59,11 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. | | 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | -| 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | -| 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | -| 2026-10-22 | minor | Reconcile subsequent upstream progress updates without changing the runtime or repeating completed evidence; retain every dated upstream and feature revision. | -| 2026-10-22 | minor | Resynchronize newer upstream progress documentation using the same owned source/build cache; preserve every feature revision and the original scaleArt runtime evidence. | -| 2026-10-22 | minor | Retain the latest upstream shared assessment and complete revision history after newer documentation landed; scaleArt runtime and its original evidence remain unchanged. | -| 2026-10-22 | minor | Correct the scaleArt follow-up evidence description to match current upstream shared status and revision history; runtime, evidence, scores and estimates unchanged. | +| 2026-10-22 | minor | Documentation reconciled with newer upstream progress docs in six passes (after reviews F-2 and F-3), including a corrected description of the scaleArt follow-up evidence; every dated revision and the original scaleArt runtime evidence kept; runtime, scores and estimates unchanged. | | 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | | 2026-10-21 | minor | Correct current global-all-art and document/revision cache descriptions; mark target-scoped fitting as superseded 2026-10-20 history. Runtime, tests, scores and estimates unchanged. | | 2026-10-21 | minor | Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged. | -| 2026-10-21 | minor | Reconcile feature status and revision evidence in a dated section; retain shared baseline metadata for independent upstream updates. Scores and estimates are not reassessed. | -| 2026-10-21 | minor | Reconcile this feature with current main, preserving both sets of behavior and progress evidence; scores and estimates unchanged. | +| 2026-10-21 | minor | Feature status and revision evidence reconciled with current main in a dated section, keeping both sets of behaviour and progress evidence and the shared baseline metadata for independent upstream updates; scores and estimates unchanged. | | 2026-10-20 | minor | Review follow-up: artwork fitting uses the original target artboard and revision-scoped bounds caches; focused regression evidence and behavior documentation updated; scores and hours unchanged. | | 2026-10-20 | minor | Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; scores and hours unchanged. | @@ -97,7 +90,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) | -| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 11–21 | no | [target-app-parity.md](target-app-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 5–15 | no | [target-app-parity.md](target-app-parity.md) | | 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) | | 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) | | 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) | @@ -197,6 +190,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G9 Illustrator `.ai` and EPS files from real users +- **Done (#6388, 2026-11-10):** a saved `.ai`'s text document names, for type in a variable font, where in the font's design space it is set (each axis, the named instance's or the run's own setting) and the font's version, so Montserrat Bold no longer opens as the font's default instance (Thin); two settings of one font are two fonts of the set. Test `ai_editing::tests::variable_font_instances_name_their_design_space`. The PDF part's font naming (#4548) remains with craft-libs. Estimate unchanged. + - **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate. - **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. @@ -217,19 +212,14 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead - of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a - path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form + of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art + brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and - of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art - brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope - (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As - `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification - instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and images, and type as editable text objects (or outlines), one artboard per file (an artboard picker chooses which); transparency, live effects and object names don't survive it (transparent images are flattened or omitted, hidden objects go to a hidden layer, are omitted or made visible), and type is set again in the @@ -449,7 +439,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Missing:** CJK vertical composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and - `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Check Spelling and Edit Custom Dictionary dialogs remain), + `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Edit Custom Dictionary dialog; the Check Spelling dialog remains), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. @@ -628,8 +618,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi symbol static drops them; stored in the native format. Color eyedropper for scatter, art and pattern brushes (Hue Shift colorization; samples canvas art); symbol libraries ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol - Libraries Menu open each library in a library panel of its own (two original libraries generated in code, - Basic Shapes and UI Frames with default 9-slice guides on Panel, Button and Bar; User Defined; Other + Libraries Menu open each library in a library panel of its own (five original libraries generated in code, + Basic Shapes, UI Frames with default 9-slice guides on Panel, Button and Bar, Map Markers with Pin, + Flag, Crosshair and Checkpoint, Inventory Icons with Sword, Shield, Potion, Key and Gem, and Skill Icons with + Attack, Defend, Heal, Buff and Ult; User Defined; Other Library… reads the symbols of any document VectorCraft opens); a click adds a symbol to the document's Symbols with its Symbol Options and the resources it uses, as one undo step, and dragging one onto the canvas also places it; Save Symbols / Save Symbol Library writes the @@ -638,8 +630,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (four original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a + its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and dragging one onto the Brushes panel adds it; Save Brushes / Save Brush Library writes the document's brushes @@ -651,12 +644,16 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi is on, a Shift-click on a highlighted match leaves it out (`select.globalEditExclude`, #3521 evidence); Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend (`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`, - Eyedropper), for that instance only. -- **Missing:** more built-in brush and graphic-style libraries (and further symbol libraries beyond Basic - Shapes and UI Frames; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; + Eyedropper), for that instance only; + graphic style libraries (Window › Graphic Style Libraries; eight original libraries generated in code, including + Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects + with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other + Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). +- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic + Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). -- **Estimate:** 11–21 h. +- **Estimate:** 5–15 h. ## G3 3D and Materials @@ -672,6 +669,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Selection artwork drag across visible document tabs (400 ms hover); lossless target copy, source/clipboard preservation and cancellation regressions. Cross-window dragging remains separate. +- **Done (#5239):** a double-click on a ruler adds a guide at the pointer (vertical on the top ruler, horizontal on the left one, swapped in a view turned 90°; both clicks on that ruler) through `guide.add`, unsnapped and in one undo step; no scored gap or estimate reassessed. - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged. @@ -690,13 +688,15 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** the document object model for scripts, a script console, the Scripts folder and script - directives (#1183 is open). File › Scripts › Other Script… and `script.run` run JavaScript files over the - command registry. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and +- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is + open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, + File › Scripts lists the scripts of the Scripts folder, and + Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and export, and full agent control over MCP are done. - **Estimate:** 4–8 h. - **Done (2026-10-16):** the MCP server hears the client during a long call: `notifications/cancelled` stops a queued or running export (PDF pages, flattening, baking, drawing, artboards) without writing anything or changing the document, `ping` and the list methods answer at once, any other request that would wait behind a call running for two seconds or more is refused as busy (`-32003`, naming the call), and `_meta.progressToken` gets page progress (#2165, #2171, #2172). Reading the document during an export is not served (refused as busy); estimate unchanged. - **Exposed in MCP:** `add_text {vertical: true}` uses the existing vertical point, area and path type commands. Horizontal writing remains the default; the broader automation gap and estimate are unchanged. +- **Done (#6460):** headless and remote `command_list` list desktop `UI_COMMANDS` (`ui: true`); UI-only runs still need `--connect`; effect ids are not command ids; 33 tools; eight prompts (spelling, variables, path-tools added); `vectorcraft://commands/reference` in the rebuilt CLI; prefer a checkout-built CLI when a release binary lags. Estimate unchanged. ## G15 Performance budgets and a GPU renderer @@ -854,9 +854,19 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | +| 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | +| 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | +| 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | +| 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | +| 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. | +| 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. Scores and estimates unchanged. | +| 2026-11-10 | minor | G5: built-in Game HUD graphic style library (Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine, Boss Alert); Missing trimmed; estimate 10–20 → 9–19 h. | +| 2026-11-10 | minor | G5: built-in Map Markers symbol library (Pin, Flag, Crosshair, Checkpoint; no default 9-slice); Missing trimmed; estimate 11–21 → 10–20 h. | | 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | +| 2026-11-09 | minor | G5: built-in Game Trails brush library (Speed Line, Dash Trail, Burst Ray, Zigzag); Missing trimmed; estimate 11–21 → 10–20 h. | | 2026-11-09 | minor | G4 type: Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, #4936; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. | | 2026-11-09 | minor | G5: Direct Selection of a dynamic instance part takes opacity, blend, gradient and pattern fill/stroke; Art brush Overlap and Pattern auto corners documented Done; estimate 15–25 → 12–22 h. | diff --git a/docs/mcp.md b/docs/mcp.md index a9b1f1cd8..5826c5c8c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -14,6 +14,18 @@ It has two backends: With no flag, the server tries `127.0.0.1:7979` and falls back to headless. Logs go to stderr; stdout carries only protocol messages. +### Command catalogue (headless vs remote) + +`command_list` / `list_commands` / `vectorcraft://commands` return the live catalogue for the backend in use: + +- **Headless:** engine and plug-in commands, plus the desktop menu catalogue (`UI_COMMANDS`) marked `"ui": true` so agents can discover ids such as `view.fitArtboard`, `file.newDialog` and `window.*`. Running a UI-only command that the headless host does not implement returns an error that points at `--connect`. + The catalogue is mirrored in `crates/mcp/src/ui_catalog.rs` (mcp does not depend on ui-egui). Regenerate with `cargo xtask ui-commands`; `cargo xtask ci` runs `ui-commands --check` so the mirror cannot drift. +- **Remote:** the same lists, with each UI entry's `enabled` taken from the live app. Run UI commands with `command_run` / `run_command` or `invoke_menu`. + +Effect ids from `effect.list` / `apply_effect` / `vectorcraft://effect/{id}` (for example `warp.arc`, `blur.gaussian`, `threeD.revolve`, `adjust.hueSaturation`, `addon.path.dashes`, `addon.color.halftone`) are not command ids. Apply one with `effect.apply` or the `apply_effect` tool; do not pass an effect id to `command_run`. Path Effects keep the `addon.path.*` effect ids; the Path Tools add-on's *commands* are `addon.pathtools.*`. + +A release `vectorcraft-cli` on `PATH` can lag the checkout. Prefer `cargo install --path apps/vectorcraft-cli --locked` and put `$CARGO_HOME/bin` first in the MCP client's environment, or set the client's `command` to that binary's absolute path (#6460). + ## Install and register From the repository root, with Rust 1.95+ and the platform's source-build prerequisites installed: @@ -112,6 +124,19 @@ the suite) keep the agent to the folders you give it: export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, library and preset saves to a path, the copies `links.relink {copy}` makes. - `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163). +- `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The + other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and + `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. +- `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the + documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; + and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an + empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the + roots shows as `loaded/` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the + Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is + closed. When another hidden library has the same file name, the libraries take `loaded/`, + `loaded/ (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both + replies. The other entries keep their order, and the app's own state is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice @@ -192,7 +217,7 @@ dropped; the call reports uncertainty like any reply that never arrived whole. ### Prompts -`prompts/list` offers five workflows; `prompts/get` returns one templated user message with its +`prompts/list` offers eight workflows; `prompts/get` returns one templated user message with its arguments filled in. A missing required argument is `-32602`. | Prompt | Arguments (required first) | @@ -202,6 +227,9 @@ arguments filled in. A missing required argument is `-32602`. | `recolor` | `palette`, `method` | | `trace-and-style` | `path`, `preset`, `effect` | | `export-set` | `formats`, `directory`, `scale` | +| `check-spelling` | `scope`, `action` | +| `variables-bind` | `kind`, `source` | +| `path-tools` | `task` | ```sh echo '{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}' \ @@ -371,11 +399,9 @@ The older names below remain listed because existing workflows use them. | Tool | Arguments and result | |---|---| -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array. A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array for the backend in use (see [Command catalogue](#command-catalogue-headless-vs-remote)). A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)). `run_command` runs a command the same way and takes its id as `command` | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array | -| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error | | `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode. `ui_inspect` is the same call as `inspect_ui`, and `ui_screenshot` is `screenshot` with `window: true`. `ui_screenshot` takes an optional `max_side` (1–4096), as `screenshot {window: true}` does: a capture whose longer side is larger is scaled down to `max_side` pixels on that side, and the reply's text gives the returned size as `width`/`height` and the capture's as `capturedWidth`/`capturedHeight`. Without `max_side` the full capture comes back | @@ -456,12 +482,12 @@ opens the picker through `run_command` with the remote backend. | `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | -| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | +| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. Polygons and stars take `rotation` in degrees clockwise (0: a corner or point straight up). `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | -| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields}` (a missing `value` is `null`). The app stores all of them or none. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and `action` is not sent. Then `action` `confirm` (OK, `ui.dialog.confirm`) or `cancel` (`ui.dialog.cancel`). Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | -| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | +| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | +| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | @@ -470,7 +496,7 @@ opens the picker through `run_command` with the remote backend. | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | -| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | +| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | | `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | @@ -588,13 +614,19 @@ the stored fill, or the stored stroke when no fill is stored and that stroke has outline, so these commands do not store a stroke paint or weight for a series that draws a band. Except for `paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph designs keep their own paint. A row that had no -paint before the command and has none after it does not change what the graph stores. `paint.setFill`, +paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and `graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store them. +`text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes +it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that +kind takes them when the graph is drawn again. The graph saves the paragraph attributes as `valuesPara`, +`categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend +leaves room for a legend label's stored indents, spacing and leading. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. @@ -836,11 +868,23 @@ page with hint tables saying where every other page's objects are), which stays `compatibility: "1.3"` writes a PDF 1.3 file, which has no transparency: a copy of the document is flattened first with `flattenerPreset` (empty: High Resolution; `high`, `medium`, `low` or a saved preset, `flattener.presets.list`) and `flattener` options over it (as `object.flattenTransparency` takes them); images with see-through pixels count as -transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the +transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless +`includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than -14,400 units (ISO 32000-1, Annex C.2). In PDF 1.3 to 1.5 files and PDF/X files, such a page is written in points. +14,400 units (ISO 32000-1, Annex C.2). A PDF 1.5 file with such a page is written as PDF 1.6, with a warning, and +keeps PDF 1.5's encryption (128-bit RC4). PDF/X files and PDF 1.3 and 1.4 files write such a page in points, and apps +that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at +full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without +a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can +be at most 32,767, and whole numbers are written as integers: a page longer than 32,767 pt whose sides aren't whole +numbers of points fails the export with an error naming the page before the thumbnails are drawn and the transparency is +flattened, and one whose art needs a real number past 32,767 fails with the same error when the file is written. A page whose short side is 3 pt or longer but would be under 3 units at that +`UserUnit` (Annex C.2 gives 3 as the smallest page side), such as 30 m × 6 mm, gets a smaller `UserUnit`: its short +side divided by 3, rounded down to the next thousandth, so that the short side is at least 3 units and the long side is +longer than 14,400 units. Each such page adds a warning with its size and how it is written. File › Print writes pages in +points, 14,400 pt at most. An Illustrator EPS (version 9 on) or `.ai` file opens from the editing data it carries (after the EPS page; in the `.ai` file's PDF private data): layers and sublayers (name, order, visibility, lock, printing, preview, dimming, @@ -1114,7 +1158,7 @@ before anything runs, with the step's number and its entry (`entry 3`) or line ( ### What a headless session remembers -`vectorcraft-cli run` and the headless `mcp` server keep two things between runs, in folders next to the +`vectorcraft-cli run` and the headless `mcp` server keep these between runs, in folders and files next to the preferences (the folder the desktop app uses, so both see the same ones; `VECTORCRAFT_CONFIG_DIR` moves it): - **Saved PDF presets** (`pdf.preset.save`, `.delete`, `.import`): one `.vcpdfpresets` file per preset in @@ -1125,8 +1169,12 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR command, so `edit.colorSettings {"cmyk": "…"}` or a preset's output intent can name it in a later run. The copy is what counts: the original may move or be deleted. `color.removeProfile {name}` forgets a profile and its copy (not while it is a working space). +- **Color Settings** (`edit.colorSettings`): the RGB and CMYK working spaces, intent and black point + compensation go into `Color Settings.json` when they change, and are the active settings again at the next + start, after the profiles above are registered (#6335). Saved settings that name a profile that is no longer + there, or a damaged file, leave the defaults (logged), and the next change writes over the file. -Not remembered: Color Settings, the document, anything else. Nothing is read or written when +Not remembered: the document, anything else. Nothing is read or written when `VECTORCRAFT_NO_PREFS` is set, and a server confined with `--automation-read-root` or `--automation-write-root` keeps nothing either (the folders are outside its roots). A file that is damaged, too large (a profile over 32 MB, a presets file over 1 MB) or not a valid preset or profile is skipped and logged. When the @@ -1341,7 +1389,7 @@ These commands refuse a string, a number or another value that is not true or fa `clockwise` in `shape.spiral`; `closed` in `shape.arc` and `path.create`; `vertical` and `placeholder` in `text.create` and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko`, `standardVerticalRoman` and `noBreak` in `text.setRangeStyle` and `text.setFormat`, which also checks `hyphenate`, `hyphenateCapitalized` and -`romanHangingPunctuation`; `effective` and `story` in `text.getRange`; `story` in `text.setRangeStyle`; `on` in `type.opticalMarginAlignment`, `type.systemLayout` +`romanHangingPunctuation`; `effective` and `story` in `text.getRange`; `story` in `text.setRangeStyle`; `on` in `type.opticalMarginAlignment`, `type.dynamicText`, `type.systemLayout` and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInstances` in `symbol.delete`; `alt` in `symbol.spray` and `symbol.adjust`; `spot` and `global` in `swatch.new` and `swatch.edit`; `unlink` in `swatch.delete`; `fromArtwork`, `toGlobal` and `includeTints` in `swatch.newGroup`; `global` and `selection` in @@ -1358,7 +1406,7 @@ naming the key, and change nothing: `radius` in `shape.rectangle`; `sides` and ` in `shape.flare`; `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` and `radial` in `shape.polarGrid`; `size` and the `width`, `height` and `fitMinPercent` of `area` in `text.create`; `size` and `fitMinPercent` in `text.createInPath`; `start`, `end`, `size`, `tracking`, -`baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `start` and `end` in +`baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `strokeWidth` in `text.setStyle`; `start` and `end` in `text.editRange` and `text.getRange`; `at` in `text.startList`; `start`, `hScale`, `vScale` and `rotation` in `text.touchType`; `start` in `text.glyphAlternates` and `text.setGlyphAlternate`; `by`, `start` and `end` in `type.step`; `by` in `type.size.increase` and `type.size.decrease`; `size` in `type.ruby`; `width`, `height` @@ -1370,6 +1418,15 @@ and `amount` in `color.harmony`; `angle` in `paint.editGradient` and in the `gra `width` and `tolerance` in `livePaint.strokeEdge`. A null is read as a key left out. A gradient stop that names a swatch refuses a `tint` that is not a number with `` stop 0: `tint` must be a number ``. +`text.setStyle` refuses a `size` that is neither a number nor a length with a unit (`"12 pt"`, `"5 mm"`) with +`` `size` must be a number, not true ``, and a `tracking` that is neither a number nor a number written as a string +(`"20"`) with `` `tracking` must be a number, not false ``. It and `text.setRangeStyle` refuse a `font` or `style` that is not a string +with `` `font` must be a string ``, and a `features` list with an entry that is not a string with +`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `type.ruby` refuses a `text`, +`position` or `align` that is not a string, and `text.setPathHandle` refuses a `which` other than `in` or `out` with +`` `which` must be one of in, out ``. Each leaves the document, the selection and the undo history as they were, also +when the call gives another param it could apply. A null is read as a key left out. + A byte offset must be a whole number of at least 0. `at` in `text.startList`, and `start` and `end` in `text.editRange`, `text.getRange` and `text.setRangeStyle`, and in `type.step` and `addon.type.textTweaker` given an `id`, refuse a negative or fractional number with @@ -1387,8 +1444,9 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd ### Parameter schemas -Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`, -`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference`. The engine's +Some commands have a machine-readable parameter schema: at present a small set including `view.capsLock`, `view.saved.new`, +`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference` +(most of the catalogue still documents params in prose only; schemas grow command by command). The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 (https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and @@ -1430,9 +1488,9 @@ path to a value inside a list or an object, and the document and its undo histor An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests -more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 2 +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 (`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Level 2 added -`engine.apiReference`. Commands that plug-ins add have no schema here. +`engine.apiReference`; level 3 says that `rotation` in `view.saved.new` and `view.saved.list` turns clockwise. Commands that plug-ins add have no schema here. `engine.apiReference {}` → `{markdown}` returns the command API reference, written from these schemas each time it runs; no file of it is kept. It needs no open document and changes nothing. The reference has a `##` section for each @@ -1525,7 +1583,9 @@ File › Scripts › Other Script… (Cmd+F12) picks a `.js` or `.jsx` file and https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html describes that menu item. Pass exactly one of `path` and `source`. A `path` must end in `.js` or `.jsx`, in any case. A `.jsxbin` file or text that starts with `@JSXBIN@` is compiled and ends with an error, as does text that is -not UTF-8. The language is ES5.1. The script reaches the app through one global object: +not UTF-8. The language is ES5.1, except that identifiers take the characters Unicode's `ID_Start` and +`ID_Continue` properties allow, as later editions define them, and may use `\u{...}` escapes. The script reaches the +app through the global object `vc`, and writes to the Script Console and runs other files through `$`: - `vc.run(id, params)` runs a command and returns its result. A command that fails throws an error the script can catch. The documents and the session state the command changed, preferences included, go back, and any file it @@ -1534,10 +1594,105 @@ not UTF-8. The language is ES5.1. The script reaches the app through one global - `vc.document` is a snapshot of the active document, or `null` when none is open. A snapshot too large to cross throws a `RangeError`. - `vc.commands` lists the commands a script may run. -- `vc.log(value)` adds a value to the report. +- `vc.log(value)` adds a value to the report and a line to the Script Console. +- `$.write(...values)` writes the values to the Script Console with no separator, each as `String(value)` gives it, and + `$.writeln(...values)` does the same and ends the line. Each call counts as a host call. +- `$.evalFile(path, timeout?)` runs the file `path` names, relative to the file being run, in the same global + environment, so its functions and variables stay defined, and returns its last value (`undefined` for an empty + file). Its preprocessor lines are read as the script's are. `timeout` is a number of milliseconds from 0; the file + is read at once and `timeout` is not waited on. The run's fuel and limits apply to the file. An empty path, a + missing file and a refused path throw errors the script can catch. +- `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a + script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. +- `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])` + open a dialog, described below. `vc.api.has("dialogs")` is true. - `vc.args` is the `args` object. - `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature. +Before any of the script runs, its preprocessor lines are read. The help page +https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/ describes these lines, and +https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/ describes `$.evalFile`, `$.fileName` +and `$.includePath`. A line counts when it starts with `#` or `//@`, after blanks only, outside strings and comments. +VectorCraft reads them by these rules of its own: + +- `#include "file"` and `//@include "file"` put the text of that file in place of the line. A value with spaces or + punctuation goes in single or double quotes. The file is looked for beside the file that includes it, then in each + `#includepath` folder in turn, then in each folder of the `JSINCLUDE` environment variable (absolute folders + separated by `;`). The included file must be a whole script on its own. A file may be included again after its + earlier inclusion has ended, and a file that includes itself, directly or through others, is an error. +- `#includepath "a;b"` sets the folders `#include` looks in, separated by `;`. A relative folder is relative to the + folder of the file that includes. A later `#includepath` replaces the list. +- `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an + included file does not rename the run. +- `#target` takes any value and does not change how the script runs. +- `#strict on` turns on the checks the dollar-object help page gives for `$.strict`: an assignment to a read-only + property throws a `TypeError`, and so does adding a property to an object that takes no new ones. By VectorCraft's + own rules, an assignment to a property with only a getter is checked as a read-only one, and the objects that take + no new properties are those that are not extensible: objects passed to `Object.preventExtensions`, `Object.seal` or + `Object.freeze`, and the frozen `vc` and `$` objects. Other objects take new properties as before. The rest of ES5 + strict mode (`with`, undeclared variables, `this` in a plain call) is unchanged; a script asks for it with + `'use strict'`. `#strict off` turns the checks off, and they are off in a script without either line. +- The last `#strict` line of the script and the files it includes sets the checks for the whole run. Code that `eval` + and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of + its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the + caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`. +- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an + unknown-directive error. A `//@` line with another name is a comment. +- A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and + the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a + Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as + `source`. A `JSINCLUDE` folder outside the script's folder is passed over. +- A missing or refused included file ends the run before any of it runs. An error in an included file or a file `$.evalFile` + ran is reported at that file's line and column, and the report's `file` names the file. So is an error `$.evalFile` + throws for a file it cannot run, such as a syntax error, when no `catch` takes it. When a `catch` takes that error + and a later `throw` raises it again, the error is reported where that `throw` is, as any other thrown error is. + +`$` also has the other members the Dollar object page lists, besides `$.evalFile`, `$.fileName` and `$.includePath` +above. Each run +starts with its own `$`: what a script changes there lasts until the run ends. The values and rules below are +VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: + +| Member | In VectorCraft | +|--------|----------------| +| `about()` | `"VectorCraft script engine "`; no window opens | +| `appEncoding` | `"UTF-8"`; setting `"UTF-8"` is accepted, any other encoding throws a `RangeError` | +| `bp()`, `colorPicker()` | throw an `Error`: scripts have no debugger and cannot show a dialog | +| `build`, `version` | VectorCraft's version | +| `buildDate` | `null` | +| `decimalPoint` | `","` for a locale whose language writes a decimal comma (such as `de`, `fr`, `ru`), otherwise `"."` | +| `engineName` | `"VectorCraft"` | +| `error` | the last `Error` object the run threw, caught or not, or `null` before one | +| `flags` | `0`, or `512` while localization is on; only `0` and `512` can be set, and setting `512` turns `$.localize` on | +| `gc()` | does nothing; the interpreter collects its own garbage | +| `getenv(name)`, `setenv(name, value)` | only `LANG`, `LC_MESSAGES`, `LC_ALL` and `OS`; `getenv` of any other name returns `null`, and `setenv` of one throws. `setenv` changes the value for the rest of the run only, never the app's environment. A value has at most 64 letters, digits and `_ . - @` | +| `global` | the global object | +| `hiresTimer` | the microseconds since the run last read it, or since the run started; `0` on the web | +| `level`, `memCache` | `0`; setting `memCache` throws | +| `line`, `stack` | the line of the code that reads it; the running code, outermost first, one a line as `name:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name), at most 64 lines | +| `locale` | the interface's language as a locale such as `de` or `pt_BR` (`en` when the interface follows the system); a script can set `ll` or `ll_RR`, and `null` or `""` sets the interface's again | +| `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | +| `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | +| `screens` | an empty array | +| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | +| `strict` | `false` | + +Members that are values (`build`, `level`, `os` and the like) cannot be changed. A wrong value for a member that can +be set throws a `TypeError` or `RangeError`. + +`localize(object, ...values)` gives the text of a localization object, an object whose property names are locales +(https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/). It looks up the +locale with the platform's suffix (`_Win`, `_Mac` or `_Unix`; none on the web), the locale, its language with the +suffix, its language, and then `en`, and returns the object itself when none of them is a string property of it. No +getter runs. `%1` is replaced by the first value, `%2` by the second and so on, each once; a number with no value stays +as it is. A string as the first argument is used as the text. + +`toSource(value)` and `value.toSource()` write `undefined`, `null`, booleans, numbers, strings, arrays and plain +objects as the text of an expression that makes the same value, such as `({"a":[1, "x"]})`. A function, a getter, a +date, a regular expression, an error, a host object such as `vc`, an array with properties besides its elements, an +object whose prototype is not `Object.prototype`, a property that is not enumerable, or an object that contains +itself throws a `TypeError`. A value deeper than 64 levels, with more than 1,048,576 entries or with text longer than 16,777,216 +characters throws a `RangeError`. + The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A run that changes no document adds no undo step. The journal records each command the run finished, as it records commands run one at a time. A preference the run changed takes the run's value, and a preference changed in the app @@ -1564,7 +1719,8 @@ VectorCraft sets these limits itself: | Limit | Value | |-------|-------| -| Source text | 8 MiB | +| Source text | 8 MiB a file, 32 MiB in all | +| Included and evaluated files | 64 in all, 16 levels deep, 1,024 paths looked up | | Heap | 256 MiB (128 MiB on the web) | | Host calls | 2,000,000 | | `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default | @@ -1573,13 +1729,35 @@ VectorCraft sets these limits itself: | A value crossing to or from a command | 16 MiB and 1,048,576 entries | | `args` as JSON | 1 MiB | | Log | 10,000 entries and 4 MiB | +| Script Console | 2,048 lines and 1 MiB of text; 4,096 bytes a line; 256 bytes of a script's name | +| A dialog's message, title and prompt preset | 16 KiB, 256 bytes and 16 KiB | +| `answers` | 10,000 answers and 1 MiB as JSON | +| `dialogs` in the report | 10,000 dialogs and 1 MiB as JSON | +| Files `File.open` reads | 8 MiB a file, 32 MiB in a run, 256 open at once | +| `Folder.getFiles` | 2,048 entries, 65,536 names looked at | | Growth before a person is asked | 100,000 objects and 512 MiB of new document data | In the app, a script past the growth limit stops at a question to continue or to stop and undo. Stop, Escape and `ui.dialog.cancel` all stop the run. A run with nobody to ask, such as a call over MCP or the command line, ends -with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls}`. -In the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. -`answers` takes only an empty object, since scripts have no prompts yet. +with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls, +runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console, dialogs, dialogCount, dialogsTruncated}`. In +the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. The +report of a failed run has the same fields after `kind`, `message`, `line`, `column`, `file` (when the error is in +another file than the script) and `fuelLeft`. + +- `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are + left out. `timingAvailable` is false on the web, where `runTimeMs` is 0. +- `hostCallCounts` is `{vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}`. + `fileCalls` counts the calls `File` and `Folder` make to the app, and `fileExecutes` the `execute` calls, which open + nothing. +- `slowestCommands` lists at most 5 commands as `{id, calls, totalMs, maxMs}`, the longest in all first, then by id. +- `console` is `{lines, droppedLines, truncatedLines}`: the run's own output, `vc.log` lines and uncaught error, as + text. +- `dialogs` lists the run's dialogs in the order they opened, as `{kind, message, title, errorIcon, answer}` for + `alert`, `{kind, message, title, noAsDefault, answer}` for `confirm` and `{kind, message, title, preset, answer}` + for `prompt`. `answer` is `"ok"`, `true`, `false`, the prompt's text, `null` for Cancel, `"stop"` for a dialog the + run ended at, or `"missing"` for a dialog `answers` had no answer for. `dialogCount` counts every dialog, and + `dialogsTruncated` is true when the list stopped at its limit. ```json {"name":"run_command","arguments":{"command":"script.run","params":{"source":"vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10});"}}} @@ -1589,6 +1767,174 @@ In the app the reply is `{running: true, name}`, and the report appears later as vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args": {"margin": 12}}' ``` +The help page https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/ +describes the three dialog functions. A script calls them as globals: + +- `alert(message[, title, errorIcon])` shows the message with OK, and an error icon when `errorIcon` is true. It + returns `undefined`. +- `confirm(message[, noAsDflt, title])` shows the message with Yes and No. Yes is the default button, and No is the + default when `noAsDflt` is true. It returns `true` for Yes and `false` for No. +- `prompt(message, preset[, title])` shows the message and a text field that starts with `preset`, with OK and + Cancel. It returns the field's text for OK, an empty string included, and `null` for Cancel. + +The message, the preset and a given title are read as `String(value)` gives them, and `errorIcon` and `noAsDflt` as +`Boolean(value)` gives them. A title that is left out or `undefined` is Script Alert. A call without a message, or a +`prompt` without a preset, throws a `TypeError`. A message or a prompt's preset longer than 16 KiB, or a title longer than +256 bytes, throws a `RangeError` and opens no dialog. The dialog shows line feeds, carriage returns and the two +together as line breaks, and other control characters as their Unicode Control Pictures. The title stays on one +line, with line breaks shown as Control Pictures too. + +In the app, the run waits at the call until the person answers, and spends no instructions while it waits. Enter +gives the default button's answer. Escape gives Cancel for a prompt, No for a confirm and Stop for an alert. Every +dialog also has Stop, which ends the run as `script.stop` does and puts back every document. A dialog can open only +from the script's own code: called inside a callback of a built-in function such as `Array.prototype.forEach`, a +getter, a `toString` conversion or a file `$.evalFile` runs, it throws an `Error`. The code of an `#include` file +is part of the script's own code, and its dialogs open as the script's do. Quitting while a dialog waits asks whether to stop the +script, and Cancel shows the dialog again with the text typed so far. In the app, `ui.dialog.confirm` answers the +open dialog (kind `scriptDialog`) with its `answer` field (`ok`, `yes`, `no`, `cancel` or `stop`), or with the +default button's answer when `answer` is left out. A prompt's `value` longer than 16 KiB is an error, and the +dialog stays open. + +A run with nobody to ask takes its answers from `answers`, a list of `{kind: "alert", value: "ok"}`, `{kind: +"confirm", value: true}` or `{kind: "confirm", value: false}`, and `{kind: "prompt", value: text}` or `{kind: +"prompt", value: null}` for Cancel. Each dialog takes the next answer in order, callbacks of built-in functions +included. The run ends with an error that the script cannot catch, and every document goes back, when no answer is +left, when the next answer is for another kind of dialog, or when the script ends with answers it did not use. +`script.run` refuses a list that is malformed, longer than 10,000 answers or larger than 1 MiB before the run starts, +and refuses any answers for a run in the app, where the person answers. An omitted `answers`, an empty list and an +empty object are the same. + +```sh +vectorcraft-cli run --cmd script.run --params '{"source": "if (confirm(\"Add a frame?\")) vc.log(prompt(\"Name\", \"Frame\"));", "answers": [{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "Border"}]}' +``` + +The help pages https://extendscript.docsforadobe.dev/file-system-access/file-object/ and +https://extendscript.docsforadobe.dev/file-system-access/folder-object/ describe the `File` and `Folder` objects. A +script reads files with them: + +- `new File(path)` and `new Folder(path)` name a path without looking it up. A path is a platform path or a URI + path: a relative path is taken from `Folder.current`, which starts at the script's folder, `~` is the home folder, + `%xx` escapes are read as UTF-8, and on Windows `/` and `\` both separate names and `/c/` names drive C. `File(path)` + without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a `File` for an existing file. Two + objects for the same path are different objects. +- `name`, `displayName`, `fsName`, `absoluteURI`, `fullName`, `relativeURI`, `path`, `parent` and `toString()` come + from the path. `exists`, `length`, `created`, `modified`, `hidden`, `readonly` and `alias` read the file system. + `File.encode`, `File.decode` and `File.isEncodingAvailable` (and the same on `Folder`), `File.fs`, and the folders + `Folder.temp`, `Folder.desktop`, `Folder.myDocuments`, `Folder.userData` and the others are there too. +- `open('r')` opens a file to read, and `read([chars])`, `readln()`, `readch()`, `seek(pos[, mode])`, `tell()`, `eof` + and `close()` read it. `read`, `readln` and `readch` count characters, and `seek` and `tell` count bytes. `readln` + and `readch` take CR, LF, CRLF and LF CR as one line end. `folder.getFiles([mask])` lists a folder, sorted by name; + the mask matches `*` and `?` without regard to case, and a function as the mask keeps the entries it returns true + for. +- A method that fails returns `false`, `null` or an empty string and sets `error`, such as `Script file access + denied`, `Script file does not exist` or `Script file is not open`. A count or a position that is not a finite + number, or a negative count, throws a `RangeError`. + +VectorCraft reads files by these rules of its own: + +- A script run from a file reads only in that file's folder and the folders below it, as its includes are read, and + a script given as `source` reads no files. When automation read roots are set, as for a run over MCP or the command + line, the files must also be inside them. A path outside, a Windows network path (`\\host\share`) and a device path + (`\\?\`, `\\.\`) are refused before anything is looked up: `exists` is `false`, the dates and `getFiles` give `null`, + and `error` is `Script file access denied`. A path that leads out through a link or a junction is refused too, and + `getFiles` leaves such an entry out. +- `open` reads the whole file. A file larger than 8 MiB is refused from its size before any of it is read, with + `Script file exceeds 8 MiB`, and a run that opens more than 32 MiB in all ends with an error. A run has at most 256 + files open at once; another `open` returns `false` with `Script has 256 files open` until one is closed. +- The encoding is the one the file's byte order mark gives (UTF-8, UTF-16LE or UTF-16BE), otherwise the `encoding` + the script set before `open`, otherwise UTF-8 when the bytes are valid UTF-8, otherwise CP1252. `BINARY` reads each + byte as one character. A byte the encoding cannot read, such as one above 127 in `ASCII`, fails the read with + `Script file cannot be decoded` and leaves the position where it was. +- `getFiles` gives at most 2,048 entries and looks at most 65,536 names; past that it gives `null` with `Script folder + has more than 2,048 entries`. A mask that takes more than 16,777,216 steps to match against the names of one + folder gives `null` with `Script folder mask takes too many steps to match`. +- A path longer than 32,767 characters throws a `RangeError` (`Script file path exceeds 32,767 characters`) when a + `File` or `Folder` is made from it. +- Writing is not available yet: `open('w')`, `open('e')`, `open('a')`, `write`, `writeln`, `copy`, `remove`, + `rename`, `create` and `createAlias` return `false` with `Script file writing is not available`, and setting + `length`, `hidden` or `readonly` sets `error` to that message and changes nothing. The file + and folder dialogs return `null`, and `execute` opens nothing and returns `false`. In the web build every lookup + fails with `Script file access is unavailable on the web`. + +Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and +`$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message` (or `name: file:line:column: message` when the error is in another +file), its end, and a report of +its time, host calls and slowest commands. An uncaught error that a `finally` block passes on unchanged has the line +and column where it was thrown; an error the `finally` block throws has the line and column of that throw. Each line +shows the run's number and the script's name. Line feeds, +carriage returns and the two together break lines, and other control characters show as their Unicode Control +Pictures. The console keeps at most 2,048 lines and 1 MiB of text and drops the oldest lines past that. A line longer +than 4,096 bytes, and a script's name longer than 256 bytes, is cut with `…`. When the console has dropped or cut any lines, a note at the top counts them. The panel lays out only the +rows in view. Click a line to select it and Shift-click to extend the selection. Clear, Copy Selected and Copy All are +at the bottom of the panel and in its menu. Output goes on while the panel is closed. + +- `script.console {}` returns `{lines: [{run, name, kind, text}], droppedLines, truncatedLines}`. `run` is the + run's number in the session, and `kind` is `start`, `output`, `log`, `error`, `end` or `report`. An end line's text + is `ended:`, a space, and then `finished`, `error`, `fuel`, `hostLimit`, `limit`, `stopped` or `internal`. A report + line's text is the run's time (`12 ms`, or `time unavailable` on a platform without a clock), a comma and its host + calls (`1 host call`, `2 host calls`), then `; slowest:` and the slowest commands when any were timed. The panel + shows the same text in the interface language. Every run that starts gets an end line. When a run is no longer + available, its end line is `stopped` if the run was abandoned and `internal` if the next slice found it missing, and + no report line follows. +- `script.console.clear {}` empties the console, sets its counts to zero and returns `{cleared}`. A running script + goes on writing, and `scriptReport` keeps the last run's console. +- `ui.copyScriptConsole {first?, last?}` copies lines `first` to `last` to the clipboard as the panel shows them, or + every line when both are left out. When the console has dropped or cut any lines, a copy of every line starts with a + line that counts them. Pass both or neither. It returns `{lines}`, which does not count that line. + +```sh +vectorcraft-cli run --cmd script.run --params '{"source": "$.writeln(\"layers: \", 3);"}' +``` + +File › Scripts lists the scripts of the `Scripts` folder next to the preferences: `~/Library/Application +Support/VectorCraft/Scripts` on macOS, `%APPDATA%\VectorCraft\Scripts` on Windows, and +`$XDG_CONFIG_HOME/vectorcraft/Scripts` or `~/.config/vectorcraft/Scripts` on Linux and BSD. VectorCraft does not make +the folder: create it, and put `.js` or `.jsx` files in it. Each file is a menu item named after the file without its +extension, and each subfolder is a submenu that holds the scripts in it. The help page +https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html and +the page https://ai-scripting.docsforadobe.dev/introduction/executingScripts/ describe a Scripts folder listed this +way. The listed scripts come first in the menu, above Check Script… and Other Script…. Choosing one runs the file as +`script.run` runs a file, without the warning that Other Script… shows. + +The rest of this section is VectorCraft's own rules: + +- The folder is read when the app starts, each time its window becomes the active one, and, where the menus are in + the window (Windows, Linux and BSD), each time the File menu opens. A script added while VectorCraft runs is listed + after the next of these. A read takes names only, never a script's text, and runs off the UI thread, one read at a + time. +- Each level is sorted by label without regard to case, with scripts and subfolders mixed. A script whose label + another row of its folder has (`Tidy.js` beside `Tidy.jsx`, or beside a subfolder `Tidy`) shows its whole file name. +- A symbolic link or a Windows junction is never followed: a linked file or subfolder is left out, and a `Scripts` + folder that is itself a link is not read. Names that start with a dot, files and folders Windows marks hidden or + system, names that are not Unicode, names longer than 255 bytes and subfolders without a script are left out too, + and a listed script that is marked hidden or system, or lies in a folder marked so, by the time it is chosen does + not run. +- One read looks at 2,048 folder entries at most, lists 512 scripts and reads 128 subfolders, 4 levels deep, for at + most 250 ms. A label longer than 128 characters is cut, and control and bidirectional formatting characters in a + name are shown as `\u{…}` escapes. When a read stops at a limit or cannot read a subfolder, the menu keeps what it + found and shows `Some scripts were not listed`. The menu also shows that note, and leaves the rows out, when a + read finds new names after the menus have shown 16,384 different script and folder names since VectorCraft started. + When the folder is there and cannot be read, the menu shows + `The Scripts folder cannot be read`. +- A listed file is checked again when it is chosen. A file that is gone, is no longer a `.js` or `.jsx` file under + the folder, or is or lies behind a link ends with `the script is no longer in the Scripts folder`, and nothing runs. +- The web build has no Scripts folder, and neither has a run with `VECTORCRAFT_NO_PREFS` set. + +`script.listInstalled {}` returns the same listing: `{folder, scripts: [{path, label, folders}], truncated, +unreadable}`, with the scripts in menu order and `folders` the labels of the subfolders a script is in. `folder` is +`null` without a Scripts folder. `ui.runInstalledScript {path}` runs one of them by the `path` the listing gave, as +its menu item does. Under `--automation-read-root`, `script.listInstalled` lists the folder only when the folder lies +inside a read root, `ui.runInstalledScript` runs a script only when the script lies inside one, and `ui.menu.list` +leaves out the listed scripts outside the read roots. + +```json +{"name":"run_command","arguments":{"command":"script.listInstalled","params":{}}} +``` + +```sh +vectorcraft-cli run --cmd script.listInstalled --params '{}' +``` + ## Transparency and opacity masks Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage @@ -1606,6 +1952,12 @@ the editing layer. With gradient mesh points picked, `opacity` goes to those poi {"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}} ``` +`transparency.makeOpacityMask` rejects a `clip` or `invert` value that is not a boolean, such as `"clip": "false"`, +and `transparency.setOpacityMask` does the same for `clip`, `invert`, `disabled` and `linked`. The toggles +`transparency.toggleNewMasksClipping`, `transparency.toggleNewMasksInverted`, `transparency.togglePageKnockoutGroup` +and `transparency.togglePageIsolatedBlending` reject a `value` that is not a boolean. A rejected call changes nothing. +Omitting a param or giving null keeps its default. + Knockout Group has three states: `transparency.set {knockout: "on"|"off"|"neutral"}` (`true` = on, `false` = neutral, the default). In a knockout group each child hides what it covers of the children below it; neutral groups pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the @@ -1706,10 +2058,18 @@ overrides selected anchors or guides. Every explicit id is validated before any or unknown id leaves the document unchanged. An empty `ids` array does nothing. Layers themselves are kept, and selecting an ancestor together with its descendants deletes that subtree once. Without `ids`, direct-selected anchors or selected ruler guides retain their usual Clear behavior. -Cut still removes the objects it copied, including a whole compound when a member is selected. With `ids`, `guides: true` also deletes the selected ruler guides. The command rejects a `guides` value that is not a boolean. +`edit.copy` takes a compound-path member selected on its own (whole or in part, with Direct or Group Selection) as an +ordinary path with the compound's paint, transparency, opacity mask and fill rule. The path is linked to the compound's +graphic style while it has that style's look, and otherwise to no style. A gradient fitted to the compound keeps the compound's fit. Copy takes the whole compound when the compound itself is selected, or when every member is +selected whole outside isolation mode, which includes the one member of a compound of one. In an isolated compound +path each member is taken as itself. `edit.cut` removes the same objects it copies, so cutting one member leaves the +other members in the compound, a compound of one member included. When Clear deletes every anchor or segment of every +member, it also removes the compound. With a copied member still selected, `edit.pasteInFront` puts the path into the +compound, just above the member. + `object.setProps` with `visible: false` or `locked: true` deselects every selected object that is hidden or locked after the call: the selected objects it hides or locks, selected objects inside them, and selected objects that were already hidden or locked. The key object is cleared when it is one of them, and `edit.undo` selects @@ -1867,7 +2227,7 @@ repeated names, and ids the document doesn't have are dropped. View → New View… keeps a view under a name, in the document: `view.saved.new {name?, center: [x, y], zoom, rotation?}` (the desktop app fills in its current view; default the first free "View N"; at most 25 per document, -names up to 255 characters, each name once) → `{name, index}`, one undo step. `view.saved.list` → +names up to 255 characters, each name once; `rotation` is in degrees clockwise on screen, as `view.rotate` turns the view) → `{name, index}`, one undo step. `view.saved.list` → `{views: [{name, center, zoom, rotation}…]}` in menu order. Edit Views… renames and deletes: `view.saved.edit {name, newName?, delete?}`, or several at once as `{edits: [{name, newName?, delete?}…]}` in one undo step (→ `{views}`), where every `name` is the name before the edit and the names must stay unique; it is @@ -1908,7 +2268,11 @@ kept with the workspace, and built-in workspaces and workspaces saved without it ## Ruler guides -Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a +Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the +document's object ids. Ruler guides are numbered by id, which is the order they were made (a guide read from an `.ai` +file takes its place in the file). An object command given a ruler guide's id (in `ids` or `id`) fails with an error, +and `node.move` cannot put a ruler guide into a group or out of its layers; the guide commands below take guides by +number. `guide.add {vertical, pos, artboard?}` makes one (the x of a vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is @@ -1932,8 +2296,8 @@ with guides unlocked, as the preference is off until `prefs.set` turns it on. `view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With `index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them). Otherwise it releases the selected guide paths and ruler guides, and with no guide selected it releases every guide -path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the guide's layer (or where -new art goes, for a guide on no layer). An artboard guide becomes a line across its artboard, and a canvas guide +path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the art of the guide's +layer. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the bounds of all the artboards. `index` and `ids` are refused while guides are locked. The release is one undo step, and the released paths are selected. With the Selection, Direct Selection or Group Selection tool, a double-click with Cmd/Ctrl and Shift held releases the ruler guide or guide path under the pointer @@ -1948,6 +2312,12 @@ Guides off, View › Snap to Point pulls it onto anchors. In the desktop app a g same way and is made where it is released over the canvas (with the Artboard tool, as an artboard guide of the active artboard), a guide dropped off the canvas onto its ruler is deleted, and hidden guides (View › Guides › Hide Guides) can't be picked. An artboard guide is picked, drawn and snapped to across its artboard only. +Preferences › Guides & Grid › Artboard Guides with Cmd/Ctrl + Ruler Drag (`cmdRulerDragArtboardGuides`, off by +default) is VectorCraft's own option. With it on, Cmd/Ctrl held when a drag leaves a ruler makes the guide an artboard +guide of the active artboard, with any tool but the Artboard tool, and with the Artboard tool a canvas guide. The key +is read when the drag starts, so letting it go during the drag keeps the kind of guide. Such an artboard guide shows, +and is made, only where it lies within the active artboard: a vertical guide between its left and right edges, a +horizontal guide between its top and bottom. Released anywhere else, it makes no guide and no undo step. ```json {"name":"run_command","arguments":{"command":"guide.add","params":{"vertical":true,"pos":100}}} @@ -1970,7 +2340,8 @@ already walks Image Trace. Short agent entry points: a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; dragging a guide on the canvas in symbol editing mode sends `nineSliceGuides` in one undo step; `type` is Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog), - `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask), `symbol.replace`, + `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask; a hidden or locked instance's + art stays hidden or locked and is not selected), `symbol.replace`, `symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard `artboard`, the active one the app passes, or in a document without artboards on `viewCenter`, the view's center the @@ -2490,10 +2861,10 @@ default) white ones knock out instead; `"discard"` writes none. ## Revolve (initial 3D effect) `effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path, -compound path, type, or a group or layer of them. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +compound path, type, clipping group, blend, symbol instance, or a group or layer of them. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. -Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. +Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A clipping group sweeps its art inside the clipping path, a blend its keys and steps, and a symbol instance its symbol's art where the instance places it, alone or in a group ([three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances)). An image, alone or in a group, is refused with "trace it first with Object › Image Trace › Make"; a group holding another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export cuts each sampled piece of a closed contour where it crosses another closed contour, adding a profile point at each cut, and tests the middle of each part against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), @@ -2569,6 +2940,12 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or the other 3D types to a path or compound path, live like Revolve (the source stays editable; SVG/PDF exports and `effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. On a group or layer the 3D effect goes on the group itself: `effect.apply` and `effect.setParams` refuse one on its fill or stroke. +Every 3D type, `threeD.classicExtrude` and `threeD.revolve` included, also applies to a clipping group, a blend or a +symbol instance, alone or in a group, and builds from the art it shows, the same way for the canvas, exports and +`assets.add3D` ([three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances)); like a group, it takes the +effect on the object itself, not on a fill or stroke. An image, alone or in a group, is refused with "trace it first +with Object › Image Trace › Make", and nothing changes. On a group, an opacity mask on a member, or on a group inside +it, masks that member's solids ([three-d.md](three-d.md#groups)). `threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth` @@ -2578,6 +2955,12 @@ solid, false open behind), `volume` (0..100%, default 50), `bothSides`. All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06), `perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets, the scope and what differs from the public documentation. +`threeD.extrude` and `threeD.inflate` take up to 65,536 outline points and build up to 163,840 faces for each solid +(VectorCraft's own limits); with more faces than that, each curve takes fewer pieces, down to 4. A shape past the +points returns "The shape is too complex for a 3D effect", and one whose faces do not fit at 4 pieces a curve returns +"The 3D surface is too complex; simplify the shape". `threeD.inflate` also returns the surface error when its outline +has so many points across so many of its grid's cells that filling the grid would pass its work limit. `threeD.rotate` +takes up to 65,536 points. ## Extrude & Bevel (Classic) @@ -2791,7 +3174,7 @@ as a value left out. ## Graphic style libraries Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and -Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency). `graphicStyle.libraries` lists them +Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? | names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the @@ -2815,7 +3198,8 @@ with the same extension and bytes as one there, loads as that User Defined libra ## Symbol libraries -Symbol libraries are read-only sets of symbols: one generated in code (Basic Shapes), the `.vectorcraft` files in +Symbol libraries are read-only sets of symbols: five generated in code (Basic Shapes, UI Frames, Map Markers, +Inventory Icons, Skill Icons), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the symbols of any document loaded with `symbol.loadLibrary`. `symbol.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `symbol.library {library}` returns one's symbols (`name`, `size`, `registration`, `exportType`; `library` is an @@ -2839,10 +3223,10 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: four generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and -the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default -ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and +Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has +the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). `brush.addFromLibrary {library, name? | names?}` copies brushes into the document's Brushes as one undo step (no names: the whole library), with the resources their art uses: patterns, symbols, swatches (`swatchConflict` as for @@ -3021,9 +3405,18 @@ one undo step. The targets split into groups of overlapping objects; groups with Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). +The image leaves out the part of such art that the clipping path of a clip group or clipping layer hides, so art under +the hidden part of a cropped picture stays as it is. Transparent art that such a clipping path hides entirely is +removed. A group that holds art from inside and outside a clipping layer goes in the place of the topmost art outside +it. When that layer prints, shows or is a template where the clipping layer differs, the clipping layer or a group +around the art has opacity, a blend mode or isolation, or the clipping path has a fill, +the group holds only the art inside the clipping layer, drawn over the fill and over the art below in the same kind of +layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own. `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) -override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups +override it, at the top level or in `options`. An image that would be larger than 64 million pixels or longer than +32,768 pixels a side at `lineArtPpi` or `gradientPpi` is made at the highest resolution that fits, so a long area is +rasterized whole. `balance: 0` rasterizes everything; lower balances rasterize groups that split into many regions. The result reports `{ids, vector, rasterized, options}`. When an image in the art can't be decoded, the image that replaces the art does not show it, and the result also carries `warning`: "an image couldn't be decoded and is missing from the picture", the note raster exports give. @@ -3048,6 +3441,22 @@ the old name. A rename gives the new name to the pattern's swatches that had the old name keeps it. A blank name gives "New Pattern N" in `object.pattern.make` and "`` copy" in `object.pattern.saveCopy`, and `pattern.options` keeps the name. +The pattern commands (`object.pattern.make`, `object.pattern.edit`, `object.pattern.saveCopy`, `pattern.options`, +`pattern.delete`) and the Repeat commands (`object.repeat.radial`, `object.repeat.grid`, `object.repeat.mirror`, +`object.repeat.options`) reject a param of the wrong type, such as `"instances": "8"`, `"edit": "false"` or +`"tileType": 2`, and change nothing. `pattern.options` also rejects an `overlap` that is not an object, and an +`overlap.h` other than `left` or `right` or an `overlap.v` other than `top` or `bottom`. `object.repeat.options` rejects +a `center` that is not `[x, y]`, and `object.repeat.radial` rejects a negative `radius`. In `object.pattern.make`, +`object.pattern.saveCopy`, `object.repeat.radial`, `object.repeat.grid` and `object.repeat.mirror`, omitting a param or +giving null keeps its default. In `pattern.options` and `object.repeat.options`, omitting a param or giving null keeps +the pattern's or the repeat's current value, with two exceptions. A brick `tileType` in `pattern.options` or a brick +`gridType` in `object.repeat.options` with `brickOffset` omitted or null sets the offset to 0.5. While `sizeTileToArt` +is on, each `pattern.options` call sizes the tile to the art plus `hSpacing` and `vSpacing` again and ignores `width` +and `height`, so a new `hSpacing` changes the tile's width. `pattern.options` still rejects a non-null `width` or +`height` that is not a positive number, such as `"width": 0`, and changes nothing. Without a `name`, +`object.pattern.edit` edits the pattern that fills or strokes a selected object, and `pattern.options` changes the +pattern being edited; each returns an error when there is no such pattern. `pattern.delete` requires `name`. + ```json {"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} @@ -3324,11 +3733,13 @@ and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` ## Arrowheads -`stroke.set {startArrow, endArrow}` takes any of 40 generated heads by name (the params doc lists them; `null` for -none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`, +`stroke.set {startArrow, endArrow}` takes any of 41 generated heads by name (the params doc lists them; `null` for +none): arrows (`Arrow`, `LineArrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`, `Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`, the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`, -`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide. +`Plus`). Each head fits a box about four times its weight long and wide. A head's weight is the stroke weight scaled by +`arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of +0.25 pt or more) at right angles and spreads twice as wide. ## Stroke bounds and clicks @@ -3400,7 +3811,12 @@ holds symbol instances that `object` expands, whether only their art holds it, w `object` on), and `object.mesh.create` on a gradient-filled object colours the mesh points as the gradient paints them, with the colors Object › Expand gives them. `object.expand`, `effect.expandAppearance`, `type.createOutlines` and `object.path.outlineStroke` turn a dimension whose -art they change into a plain group of that art, in the same undo step (see [dimensions.md](dimensions.md)). +art they change into a plain group of that art, in the same undo step. `object.expand`, `effect.expandAppearance` and +`object.path.outlineStroke` leave a dimension selected with art it measures, directly or in groups without fills, +strokes or effects of their own, as it is and change only the other art; if the new art no longer fits the dimension's +link, the dimension stays where it was, no longer linked (see [dimensions.md](dimensions.md)). +`object.expand` rejects a param of the wrong type, such as `"fill": "yes"`, `"gradient": 3` or `"steps": "16"`, and +changes nothing. Omitting a param or giving null keeps its default. ```json {"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} @@ -3476,7 +3892,10 @@ step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect ` in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` -empty) until a filter of that name is defined or imported again. Opened from such an effect, Apply SVG Filter shows the +empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's +filters but draws another definition keeps its own filter under a free name (`Soft 2`); pasted back into the document +it was copied from, it takes the document's filter. The free name is one that no filter of the document and no effect in +its art, brushes or graphic styles uses; when no such name keeps the filter within 64 KB, the paste is refused. Opened from such an effect, Apply SVG Filter shows the name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs; @@ -3544,7 +3963,9 @@ Selected End Points) joins the ends again; `path.join {ids: [a, b], ends: ["last (Average & Join, Cmd+Alt+Shift+J) moves two ends to their midpoint and joins them there, the joined anchor a corner or a smooth point, in one undo step: the two direct-selected anchors when they are open path ends, else the nearest ends of the two open paths in `ids` or the selection (both ends of one, closing it); anything else is refused. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and -`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt +`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); `path.split`, +at an anchor or at `segment` and `t`, leaves the first anchor of the piece after the cut selected alone, and a Direct +Selection press on two anchors on top of each other takes the selected one; the Pen with Alt held over a selected path's handle, anchor or segment works as the Anchor Point tool. `press_key` Escape between two `pointer_gesture` calls of an Anchor Point drag cancels it: the path goes back and no undo step is added. `path.reshapeSegment {id, subpath?, segment, t, dx, dy, perpendicular?}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's @@ -3562,6 +3983,44 @@ the canvas (coordinates out of range)". `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded. +`path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those +after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays +selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and +the selection in one step. + +`path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an +anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`. +[Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) +has the Reshape tool stretch part of a path from the dragged point without losing the path's general form, with every +highlighted focal point pulling the path. Which anchors stay, how far the stretch reaches and the share of the drag each +point takes are VectorCraft's own rule. When `focal` is given (an empty list included), the grabbed point and the +anchors that `focal` lists as `[subpath, anchor]` pairs are focal points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is +not an anchor of the path"). Anchors that are not direct-selected stay in place (on a path selected whole, or not +selected, every anchor is selected), and so do the two ends of an open path unless they are focal points. A selected +anchor between two focal points moves by the whole drag. A selected anchor between a focal point and an anchor that +stays takes `u² (3 - 2u)` of the drag, where `u` is its distance from the anchor that stays along the path's control +polygon (anchor, out handle, the next in handle, the next anchor) divided by the focal point's distance from it. A +handle takes the share on the tangent line of that curve at its anchor, so the handles of a smooth anchor stay in line +with it; a handle that is long beside that distance takes a share below 0 or above 1, and moves against the drag or +farther than the focal point. A closed subpath with every anchor selected moves whole, and a subpath with no focal +point does not move. An anchor added on a segment of a partly selected path is selected, and the other selected +anchors stay selected. With `radius`, only the grabbed point's subpath changes, whatever the selection: the anchors +within `radius` of the grabbed point along the path follow with a smooth falloff and the others stay; a negative +`radius` is refused ("`radius` must be 0 or more"), and so is `radius` given with `focal` ("`focal` and `radius` +cannot be used together"). Without `focal` (or with `focal` null) and without `radius`, the earlier rule applies, so +steps recorded before focal points play as they did: the selection does not matter, an open path stretches from the +grabbed point to its two ends as above, and on a closed path the anchors within half the diagonal of the subpath's +bounding box follow with the smooth falloff. The Reshape tool always sends `focal`. A drag of no length on an anchor +(`dx` and `dy` 0 or left out) leaves the path as it is and records no undo step. + +With the Reshape tool, a click on an anchor of a selected path makes it the focal point, and a click on a segment adds +an anchor there (one undo step) that becomes the focal point. Shift-click adds an anchor or a point on a segment to the +focal points. The rest of the tool's behavior is VectorCraft's own rule: Shift-click on a focal point removes it, and +the focal points belong to one path, so a click on another selected path starts again there. Each focal point shows a +filled anchor square in a larger hollow one. A drag from a focal point moves all of them; a drag from another point +moves that point alone as the focal point, with or without Shift (Shift keeps the drag's angle). After a drag the +focal points stay highlighted where it left them; Escape during a drag puts the path back and keeps them, and +switching to another tool clears them. These commands refuse a string or another value that is not a number with `` `dy` must be a number, not "10" ``, naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in @@ -3638,6 +4097,11 @@ dialog `simplifySlider` with field `level`) at the Auto-Simplify level for the s opens the Simplify dialog at the slider's tolerance. When OK confirms the dialog with `retain` ticked, the menu item opens the dialog on those settings next time. +`object.path.simplify`, `object.path.offsetPath`, `object.path.splitIntoGrid`, `object.path.cleanUp`, +`object.pathfinder.trap` and `addon.contour.set` reject a param of the wrong type, such as `"offset": true`, +`"joins": 3`, `"rows": "many"` or `"addGuides": "yes"`, and change nothing. A distance still takes a string with a unit +(`"5 mm"`). `joins` must be `miter`, `round` or `bevel`. Omitting a param or giving null keeps its default. + ## Smooth `object.path.smooth` smooths the selected paths (or the objects in `ids`), compound path members included, as one undo @@ -4081,8 +4545,9 @@ outlines and its underline and strikethrough bars. This applies to each point ty default. A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its -font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt -font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is +font size changes by the vertical scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a +72 pt font. A width typed alone, out of proportion, changes the horizontal scale and keeps the font size, and a height +typed alone keeps the width, so the horizontal scale changes the other way. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that draws nothing (no text, or only spaces without underline or strikethrough), area type, type on a path and type inside a selected group are measured as before. Arrange in Grid and Group Overlapping Objects measure visual bounds as @@ -4189,9 +4654,25 @@ draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets every document they read except a native file and the native document that an SVG, PDF, `.ai` or EPS file carries (`restored: true`), which keep the grid they were saved with. -`view.rotate {angle}` (desktop app) turns the view to `angle` degrees about the center of the canvas; a positive angle +`view.rotate {angle}` (desktop app, `ui: true`) turns the view to `angle` degrees about the center of the canvas; a positive angle turns the art clockwise on screen. View › Rotate View and the status bar's angle menu run it. It returns `{angle}`, the -angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0. +angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0, returning `null` on success. + +Select the Rotate View Tool with `select_tool` and the tool id `rotateView`. Its `pointer_gesture` +drags turn the canvas about its center; Shift snaps to 15°. Document coordinates for a rotation +gesture use the view at the initial down event, including when the gesture spans several calls. +The control channel rejects malformed rotation packets without changing the angle and cancels +the current press. Start a new down event after an error. Menu commands that only the desktop view +implements (`view.rotate`, `view.rotateReset`, `view.rotateToSelection`) are listed with `ui: true` +and need `--connect` when the headless host does not run them. +Reset Rotate View is the command `view.rotateReset`: + +```json +{"name":"select_tool","arguments":{"tool":"rotateView"}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":150},{"kind":"drag","x":200,"y":250},{"kind":"up","x":200,"y":250}]}} +{"name":"command_run","arguments":{"id":"view.rotateReset","params":{}}} +``` + `view.rotateToSelection` (desktop app, View › Rotate View to Selection) turns the view about the center of the canvas to the angle the selected objects share (`selectionRotation` in `document.inspect`, 0 when their angles differ), so their bounding box is square to the window. It returns `{angle}`, and with nothing selected it returns "nothing selected". @@ -4426,7 +4907,8 @@ four: General (rulers, positions and sizes, the Info panel, dialog distances, ca (weights, dashes), Type (font size, leading, baseline shift) and East Asian Type (no field uses it yet). General is the active document's units (`document.inspect` → `units`): `document.setUnits {units}` (Document Setup) sets it for that document, and `prefs.set {key: "unitsGeneral", value}` sets it for the active document too (one undo step) and is the units -`file.new` starts in when it gets no `units`. Stroke, Type and East Asian Type are the preferences `unitsStroke`, +`file.new` starts in when it gets no `units`. With `documentUnits: false`, `prefs.set` sets only the preference and +leaves the document's units as they are; Reset Preferences followed by OK in the Preferences dialog does this. Stroke, Type and East Asian Type are the preferences `unitsStroke`, `unitsType` and `unitsAsianType`. Only `unitsAsianType` also takes `q` (Q, `Q`) and `ha` (Ha, `Ha`), each a quarter of a millimetre (0.709 pt); the other preferences, `document.setUnits` and the rulers do not list them, and it is Points by default. A length typed with a unit @@ -4451,12 +4933,33 @@ a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `C settings whose label, section or category contains it, ignoring case, in the interface language or in English, or by "No settings found". Choosing a setting sets `__category` to its category, sets `__found` to its key (the row is highlighted until another category is shown) and clears `__search`. Cmd/Ctrl+F focuses the field again; Escape in the -field with text clears it, and Escape in the empty field closes the dialog. +field with text clears it, and Escape in the empty field closes the dialog. Its Reset Preferences button opens a +`resetPreferences` question (fields `message`, `detail`) over Preferences: `ui.dialog.cancel` shows Preferences again +as it was, and `ui.dialog.confirm` shows it again with every preference at its default value, applied by OK in +Preferences. `prefs.reset` resets the preferences at once, without the question. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}} ``` +`prefs.export {path?}` writes the preferences as a JSON file, `{"format": "vectorcraft-preferences", "version": 1, +"prefs": {…}}`, and replies `{path, count}`; without `path` it replies `{data, count}` with the file's text. The file +holds every preference `prefs.list` lists and the `eyedropper`, `perspectiveWidget`, `pathfinder` and `glyphSnap` +groups. It leaves out the folder preferences (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) and +saved presets. An export over 1 MB, the most `prefs.import` reads, is an error and writes nothing. +`prefs.import {path | data}` applies such a file and replies `{imported}`, the number of preferences it +set. It checks every value first, as `prefs.set` does: a file over 1 MB, malformed JSON, another `format`, a newer +`version`, an empty `prefs`, an unknown preference, a value of the wrong type or out of range, or a folder preference +is an error, and nothing changes. Preferences the file leaves out keep their values. An import leaves the open +document's units as they are, records no undo step, and is saved with the other preferences. An open Preferences +dialog shows the imported values. `prefs.import` is refused as a `command.batch` step. The dialog's Export Preferences… and Import Preferences… buttons run the two +commands with a save or open dialog; the web build downloads the exported file and has no Import Preferences… button. +Exporting and importing preferences is an optional VectorCraft feature. + +```json +{"name":"run_command","arguments":{"command":"prefs.import","params":{"data":"{\"format\":\"vectorcraft-preferences\",\"version\":1,\"prefs\":{\"uiAccent\":\"green\"}}"}}} +``` + ## Document Setup `document.setup` with no params reports the document setup: units, the bleed ([top, bottom, left, right] in pt, drawn @@ -4491,7 +4994,10 @@ settings and the next untitled name, without opening the dialog, and returns wha open document in tab order the current one, as a click on its tab does, and return `{index}`. After the last document comes the first, and before the first document comes the last. From the Home screen they go to the first and the last document. With no document open they fail with "no document open". `document.activate {index}` makes a given document -the current one. +the current one. `document.move {from?, to}` reorders open documents the way dragging a document tab along the tab strip +does: `from` defaults to the current document, `to` is the destination index, documents between them shift by one, the +current document stays current, and each document keeps its own view (#5663). It is an engine command, so headless and +remote `command_list` both list it. `window.arrange.tile` (Window › Arrange › Tile) shows every open document in its own pane, edge to edge in the document area, in a grid of as many columns as the square root of the number of documents, rounded up, and fits each document to its pane; with fewer than two documents open it leaves the view and `tiled` as they are. It returns `{tiled, panes}`. @@ -4658,7 +5164,7 @@ value: true}`; the Control bar's and Properties' Scale Artwork with Artboard che Artboard) makes its handle drags proportional and passes `scaleArt`, as do Properties' and the Transform panel's artboard size fields while the tool is in use. -`artboard.setProps {artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…], scaleArt?, moveArt?, …}` sets several +`artboard.setProps {artboards: [{index, name?, color?, guideLabel?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…], scaleArt?, moveArt?, …}` sets several artboards in one undo step, each with its own fields; the other params apply to all of them. The art that moves or scales with each artboard is the art inside it before any of them changes, and an object inside several of them goes with the first one listed that moves or scales its art. An empty list and a list that names an artboard twice are @@ -4792,6 +5298,20 @@ is the width of one video pixel over its height, which `view.videoRulers` counts the native format keep both (written only when on or not 1), and `inspect_document` adds `showVideoSafeAreas: true` and `videoRulerPixelAspectRatio` to an artboard that has them. +Artboard Options' Guide Label: `artboard.setProps {index, guideLabel?: name | "#rrggbb" | null}` (or each entry of +`artboards`) gives the artboard a guide label, and `artboard.new` takes one too. The canvas draws the artboard's own +guides (`guide.add {artboard}`) in the label's color instead of the guide color preference, in the preference's style; +canvas guides, guides of an artboard without a label and guides whose artboard is gone keep the preference's color, and +selected guides are still drawn in the selection color. The value is a layer color's name, spelled exactly as Layer +Options lists it (`"Light Blue"`, `"Red"`, …), or `#rrggbb`; null clears it, and new artboards and older files have none. +The label is VectorCraft's own (no public documentation describes one): it is screen-only, so exports and print leave +it out, the paper and the artboard's background color stay as they are, and a locked artboard's label can change. +Anything else (a preset's index, another letter case, `#rrggbbaa`) is refused with "`guideLabel` is null, a layer +color's name or #rrggbb", and a list with a bad value anywhere changes nothing. Duplicates and copies keep it, the +native format writes it only when set (a preset index past the layer colors opens as none), and `inspect_document` adds +`guideLabel` (the name or `#rrggbb`) to an artboard that has one. `artboard.setProps` adds no undo step when nothing it +is given differs from the artboard. + Artboard Options' Global checkboxes are application preferences, not artboard properties: `fadeRegionOutsideArtboard` (Fade Region Outside Artboard) and `artboardUpdateWhileDragging` (Update While Dragging), both on by default (`prefs.set {values: {fadeRegionOutsideArtboard: false}}`; also Edit › Preferences › General › Artboard Options). With @@ -4869,7 +5389,8 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see -Threading text from the in and out ports), and the other frames keep the text they hold. +Threading text from the in and out ports), and the other frames keep the text they hold. `object.rasterize` +refuses an image larger than 64 million pixels or longer than 32,768 pixels a side, and the art stays as it was. ```json {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} @@ -5822,7 +6343,9 @@ as Overprint Preview shows them (darkened); either way the file has no overprint overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset, `flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium). -`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution. +`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution, +or at the highest resolution that fits when the image would be larger than 64 million pixels or longer than 32,768 +pixels a side. `color.profile` names an RGB or CMYK profile: composite PDF colours are converted to it with `color.intent` (CMYK colours keep their numbers with `preserveNumbers`), so a CMYK profile writes device CMYK; separations separate with a CMYK one. Settings saved before these options load with their defaults. @@ -5902,6 +6425,9 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane `text.reshapePath {id, anchors: [[subpath, anchor]…], dx, dy}` does the same for the path of type on a path (Direct Selection dragging one of its anchors): the start and end brackets keep their fractions of the path and the text reflows along it. +`text.setPathHandle {id, subpath?, anchor, which: "in"|"out", x, y, independent?}` moves a direction handle of one +of its anchors, as `path.setHandle` moves a path's (Direct Selection dragging a handle of the path, #3565), in one +undo step. ```json {"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}} @@ -6115,6 +6641,11 @@ preferences, one word per line; without preferences (`VECTORCRAFT_NO_PREFS`, the closes. `text.spelling.ignoreAll {word}` is Ignore All: the active document's checks skip the word, in any case, while the document is open. It is not saved and not an undo step. +In the app, Edit › Edit Custom Dictionary… (`text.spelling.dictionary` with no params, from the menus) opens a dialog of +the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.editDictionary {remove, add}` for +the words that differ, which deletes and adds them and saves once, all or none (a save that fails keeps none of them), +and Cancel changes nothing. + ```json {"name":"run_command","arguments":{"command":"text.spelling.addWord","params":{"word":"VectorCraft"}}} {"name":"run_command","arguments":{"command":"text.spelling.ignoreAll","params":{"word":"Zqvwkx"}}} @@ -6230,8 +6761,9 @@ with binding punctuation (`Tg`) on, which opens as `forced`. `none` isn't saved. `text.setFormat` and `text.setRangeStyle {tcyUpDown?, tcyLeftRight?}` set the Character panel menu's Tate-chu-yoko Settings of the characters, in points from -1296 to 1296 (document points, as `baselineShift`). In vertical type a block of tate-chu-yoko characters (`tateChuYoko`) moves up by a positive Up/Down and down by a negative one, and right -by a positive Left/Right and left by a negative one. The block moves as one piece with the values of its first -character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set +by a positive Left/Right and left by a negative one. On a vertical path, a positive Up/Down moves the block toward the +path's start and a positive Left/Right moves it to the path's left, with every path effect. The block moves as one piece +with the values of its first character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set across the column without the mark do not move, and horizontal type keeps the values without moving the characters. Neither value turns `tateChuYoko` on. Both are 0 by default and saved only when not 0, and character styles carry them. A value that is not a number is refused with `` `tcyUpDown` must be a number ``, and one outside the range with @@ -6550,6 +7082,9 @@ With Preferences › General › Anti-aliased Artwork off, the canvas draws all so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's method, and opening the file restores it. +A graph label given a method keeps it for every label of its kind (value axis numbers, category labels or legend +labels) when the graph is drawn again; the graph saves it as `valuesAntiAlias`, `categoriesAntiAlias` or +`legendAntiAlias` in its label styles. Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. ```json @@ -6766,6 +7301,33 @@ begins or ends, dragging the centre bracket slides the type along its path and, {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` +## Dynamic Text + +`type.dynamicText {on?: bool, ids?}` (Type › Dynamic Text, and the Contextual Task Bar for type on a path) turns +Dynamic Text on or off for the selected type on a path; without `on` it sets the opposite of the first object's +setting. With Dynamic Text on, the text spans the stretch between its start and end brackets and its size follows +the text, growing or shrinking to fill it, and it fits again after each edit to the text and each move of a bracket +([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), +[start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). +The rest of this entry is VectorCraft's own rules. The layout scales every character's size, leading, baseline shift +and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on a Path Options › Spacing is not +scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that +still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored +sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps +its look, in one undo step. Point type and area type in the selection are left as they are. Type on a path in a +thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; +`text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and +list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step +is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked +while the first selected type on a path has it on. `document.inspect` reports `dynamicText` and `fitScale` for type +on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the +editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says +so in a warning. + +```json +{"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}} +``` + ## Paragraph attributes per paragraph Each paragraph (text split at `\n`) has its own alignment, indents, space before and after, hyphenation, @@ -6915,12 +7477,37 @@ fit makes `plugin.run` return a memory-budget error naming the object and its si memory-budget error `the selection is too large to hand over`, and more than 100,000 of these objects make it return an object-budget error. Its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the -field, and the document and undo history are unchanged. A live-effect plug-in is an effect with +field, and the document and undo history are unchanged. A filter at plug-in API level 2 whose manifest lists the +`commands` permission and a `commandAllowlist` (both in its `plugin.list` entry) runs the commands that list names +while it runs, through `vc_call`: the calls and its output are one undo step, and a run that fails or makes a call that +fails leaves the documents, selections and history as they were. Without `doc.read` every call fails. A command that +reads or writes files, uses the clipboard, changes preferences or the view, runs a script, an action or a plug-in, +switches documents or undoes is never called; `plugin.install` refuses a manifest that lists one, and `plugin.inspect` lists such ids as +`refusedCommands` and ids no command has as `unknownCommands` (`plugin.install` lists those too). A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and `effect.list` lists it. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api` and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them as ordinary paths with those paints; a result it refuses is drawn as if the effect weren't there, and `plugin.info`'s -`lastError` names the plug-in and the refused field. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): +`lastError` names the plug-in and the refused field. While the app draws a document on screen, the live-effect plug-ins +of its first view share a 2,000 ms budget (VectorCraft's own value), and each run also keeps to its own 5,000 ms limit: +once the budget is spent no run starts, and a result that comes back after it is not used. A stage left out for want +of time, or one that times out, fails or has no plug-in, draws its input art unchanged and is deferred; the status bar +then says how many (`Opened with 3 plug-in effects deferred. Effect › Refresh Deferred Effects runs them.`). Later +drawings leave a deferred stage as it is until its input or parameters change, which runs it under the per-call limit. +`effect.deferredStatus {}` returns `{budgetMs: 2000, effects: [{objectId, effectIndex, item?, pluginId, reason}]}` for +the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke +whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list +when none is deferred). A stage whose object is deleted, or whose plug-in effect is +removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh +Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them) +again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left +out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn, +and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that +timed out, failed or have no plug-in. `id` names one object in place of +`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand +Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only +deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call +limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: @@ -7399,8 +7986,12 @@ bounding-box handles scale it in plane space (`perspective.transform`; Shift kee centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch back). Holding Shift during an in-plane move constrains each object's displacement to an axis or 45° diagonal of its own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained -direction on the first object's plane and depth. With Snap to Grid, every object on the first object's plane moves -by the same plane offset, whatever its depth, and the edge of their joint bounds nearer a gridline lands on it. +direction on the first object's plane and depth. With Snap to Grid, an in-plane move moves every object on the first +object's plane by the same plane offset, whatever its depth. Without `constrain`, on each axis, the edge of their joint +bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With `constrain`, an +edge also lands only when it ends within a quarter cell of a gridline: a horizontal or vertical move can land only an +edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. When no edge on +those axes is that close, the move does not snap. A perpendicular move does not snap. `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. @@ -7985,10 +8576,11 @@ plain path from then on. ### Path Effects (live) Effect › Path Effects holds the add-on's live geometry effects, applied with `effect.apply {effect, params}` like any -other effect, with no shortcuts. Each draws, measures, exports and stacks as the other geometry effects do; the object's -own path stays as it is until Expand Appearance (`effect.expandAppearance`) writes the result. `effect.list` documents -every parameter. Out-of-range values are clamped, and an effect whose result would pass the geometry effects' budget -(2 million anchors) leaves the path as it is. +other effect, with no shortcuts. The ids below (`addon.path.*`) are **effect** ids, not command ids; the Path Tools +add-on's commands are `addon.pathtools.*` (see above). Each effect draws, measures, exports and stacks as the other +geometry effects do; the object's own path stays as it is until Expand Appearance (`effect.expandAppearance`) writes +the result. `effect.list` documents every parameter. Out-of-range values are clamped, and an effect whose result would +pass the geometry effects' budget (2 million anchors) leaves the path as it is. | Id | Menu item | Parameters | | --- | --- | --- | diff --git a/docs/plugins.md b/docs/plugins.md index 890183c7f..06fd89f28 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -27,11 +27,11 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, api, paintOutput?, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, commandAllowlist?, size, source, api, paintOutput?, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `commandAllowlist` lists the commands it may [call](#command-calls); `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a grant is already recorded for a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval) and `needsReplace` (true when `plugin.install` needs `replace: true`). | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a grant is already recorded for a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. A module whose `commandAllowlist` lists a command no plug-in may call is refused; ids no command has are listed as `unknownCommands` ([command calls](#command-calls)). | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval), `needsReplace` (true when `plugin.install` needs `replace: true`), and the `commandAllowlist` ids no command has (`unknownCommands`) and those no plug-in may call (`refusedCommands`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | | `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | @@ -128,7 +128,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | | Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` | -| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (checked every 20 M instructions; the web build reads the page's `performance.now()`) | `Limit("time budget")` | | Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that | | Call depth | 1,024 frames | trap | | Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | @@ -137,6 +137,19 @@ An export runs where the export runs: with Background Export on, on the export's plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the open it runs in is cancelled, and nothing opens then. +### Live effects while a document opens + +While the app draws a document on screen, the live-effect runs of its first view share a 2,000 ms budget +(`vectorcraft_plugins::deferred::OPENING_BUDGET_MS`, VectorCraft's own value), measured from the first run. Each run +takes the lesser of its 5 s limit and what is left of the budget; once the budget is spent no run starts, and a result +that comes back after it is not used. A stage left out this way, or one that times out, traps, returns output the host +refuses or has no plug-in, draws its input art unchanged and is deferred. The status bar then says how many stages +are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh +Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists +them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget. +Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has the same budget, +measured with the page's `performance.now()`. + ### Heavy filters Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a @@ -157,7 +170,7 @@ much there is only once the run has ended. Cancel (also Esc) and Continue in Background (`plugin.cancelRuns`, `plugin.hideRuns`). When it is done its result is applied as `plugin.run` with the same parameters and objects, as one undo step, unless the document or the plug-in changed meanwhile: then nothing changes, and the status bar says to run it again. On the web, which has no threads, - it runs at once; the instruction budget alone bounds it there. + it runs at once, under the same instruction budget and 300 s limit. - `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and its `notifications/cancelled` stops it. @@ -178,6 +191,7 @@ capability model proposed for PhotoCraft plug-ins | `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | | `settings` | all | Keep settings of its own between runs and sessions ([settings and cache](#settings-and-cache)). Needs `api` 2. | | `cache` | all | Keep a cache of its own, which the user can clear ([settings and cache](#settings-and-cache)). Needs `api` 2. | +| `commands` | filter | Run the commands its `commandAllowlist` lists on the document ([command calls](#command-calls)). Needs `api` 2. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`, and also `doc.write` and `state` @@ -186,9 +200,10 @@ capability model proposed for PhotoCraft plug-ins these, and also installs without a prompt. - An effect, an exporter or an importer that lists permissions lists all of its implied ones (`doc.read` and `effect.render`; `doc.read` and `export.write`; `import.read`). An effect with commands may add `doc.write` and - `state`, which only its commands use. A filter may list any of its three. + `state`, which only its commands use. A filter may list any of its three, and `commands` with a `commandAllowlist`. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a - name that doesn't apply to the kind, more than 16 names, `progress`, `settings` or `cache` with an `api` below 2, or + name that doesn't apply to the kind, more than 16 names, `progress`, `settings`, `cache` or `commands` with an `api` + below 2, or one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of @@ -308,7 +323,8 @@ module's memory. A plug-in whose manifest gives `"api": 2` and lists the [permission](#permissions) an import needs may import these functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; the `vc_settings_*` imports need -`settings` and the `vc_cache_*` imports need `cache` ([settings and cache](#settings-and-cache)). +`settings` and the `vc_cache_*` imports need `cache` ([settings and cache](#settings-and-cache)); `vc_call` and +`vc_call_result` need `commands` ([command calls](#command-calls)). | Import | Signature | Meaning | |---|---|---| @@ -389,6 +405,51 @@ A live effect runs while the art draws, so keep the entries it uses small. `plugin.info` give the cache's size as `cacheBytes`. - Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. +### Command calls + +A filter whose manifest lists the `commands` permission and names commands in `commandAllowlist` runs those commands +on the document while it runs (#2186). It names each command by its id, as `command_list` lists it, and passes its +arguments as a JSON object, the params the command takes from any request: + +| Import | Signature | Meaning | +|---|---|---| +| `vc_call` | `(id, id_len, args, args_len) -> i32` | Runs the command whose UTF-8 id is at `id` with the JSON object at `args` (`args_len` 0 means `{}`). Returns `0` when it ran, else a negative code. | +| `vc_call_result` | `(out, out_cap) -> i32` | The JSON result of the last call that ran: its length in bytes. When it fits in the `out_cap` bytes at `out`, its bytes are copied there; otherwise nothing is copied. `-10` when no call has a result, `-1` when the buffer doesn't lie inside the module's memory. | + +`vc_call` returns `-1` when the id or the arguments don't lie inside the module's memory, `-2` when they are not UTF-8, +`-3` when the arguments are not a JSON object, `-4` when `commandAllowlist` doesn't list the id, `-5` when no command +has the id, `-6` when a plug-in may not call the command, `-7` when the command can't run now (nothing is selected), +`-8` when the command ran and failed, `-9` when a limit below is passed, and `-11` when nothing can be called (from +the module's start function, in a run that isn't a filter's run on a document, or after a call in the run failed). + +- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, + traps, runs out of budget or is canceled leaves every document, its selection and its history as they were. +- Each call runs on the document the run is for, with its params checked as any request's are, and sees what the calls + before it changed: a `select.all` followed by `object.move` moves everything. The filter's own input is the one it + was given when the run started; an edit still in progress (typing, or a shape being drawn) is kept as its own undo + step before that input is made. +- A plug-in never calls a command that reads or writes a file or folder, uses the clipboard, changes preferences or + the view, runs a script, an action or a plug-in (a `plugin.*` command, or arguments naming one, such as an + `effect.apply` of `plugin.`), opens, closes or switches documents, or undoes or redoes, whatever its manifest + lists. The same goes for a command that changes a tool's options, keeps a drag's state or adds font names for the + whole app, and for a command VectorCraft adds later until it has been checked for plug-in calls. Installing a + manifest that lists such a command is refused, and the error names it; `plugin.inspect` lists them as + `refusedCommands`. +- A call whose arguments hold a file or folder key (`path`, `paths`, `file`, `files`, `folder`, `folders`, `dir`, + `directory`, `destinationFolder`, `sourceFolder` or `url`) at any depth, such as an effect's `textureImage.path`, + returns `-6`; only a key that names part of the document in that command, as its own param, is let through. A + command a plug-in calls can't read or write any file or folder. +- An id in `commandAllowlist` that no command has installs, so a plug-in can list a command a later version adds; + `plugin.install` and `plugin.inspect` list such ids as `unknownCommands`, and calling one returns `-5` (`-6` without + `doc.read`). +- Without `doc.read`, every call of a listed id fails with `-6`, since a command's result describes the document. + Without `doc.write`, a call that changes the document fails with `-6`. +- These limits are VectorCraft's own values: at most 1,000 calls a run, 8 MiB of arguments or result a call and 64 MiB + of arguments and results a run. Each call costs 10,000 instructions of the run's budget, plus one for every 64 bytes + of its arguments and its result, and the run checks Cancel and its time budget before and after each call. +- A filter that calls commands runs on the app's main thread, so it can't ask for a heavy budget: a manifest with + `commandAllowlist` and `budget: "heavy"` is refused. + ### Manifest ```json @@ -437,6 +498,10 @@ A live effect runs while the art draws, so keep the entries it uses small. `a-z 0-9 _` that starts with a letter, and appears once. A `label` is 1–64 printable characters; menus show it with control and bidirectional characters escaped. `params` are declared as the plug-in's own `params` are. A malformed entry is refused when the module is installed, and the error names the command by its id (or its place, `[0]`). +- `commandAllowlist` (filters at `api` 2 with the usual budget, and only with the `commands` permission, which needs + it): the ids of the commands the filter may [call](#command-calls), at most 256, each once (`"commandAllowlist": + ["select.all", "object.move"]`). An id is 1–128 characters from `A-Z a-z 0-9 . _ -` that starts with a letter, with + no empty part between dots. A malformed list is refused when the module is installed, and the error names the id. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults diff --git a/docs/revolve.md b/docs/revolve.md index 6ecd4957e..e52770fd6 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -2,13 +2,14 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). -Select a path, compound path, type, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. +Select a path, compound path, type, clipping group, blend, symbol instance, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. -The faces of all members draw in one back-to-front order. The opacity and blending mode of a member, or of a group -inside the group, apply once to all its faces. Where a face of another member lies between its faces in that order -and overlaps them on the page, its faces are split there, and the opacity and blending mode apply once to each part. +The faces of all members draw in one back-to-front order. The opacity, blending mode and opacity mask of a member, +or of a group inside the group, apply once to all its faces. Where a face of another member lies between its faces +in that order and overlaps them on the page, its faces are split there, and the opacity, blending mode and opacity +mask apply once to each part. The mask stays where it is on the page; it does not turn with the faces. Two faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. After 4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right @@ -96,7 +97,7 @@ with the standard command. - Lighting offers Standard, Diffuse, Top Left and Right, using the [shared original rigs](three-d.md#presets). Other effective settings show Custom; changing the view does not change the recognized lighting preset. -- Paths, compound paths, type, and groups or layers of them only. Images and other objects must be converted to paths first. +- Paths, compound paths, type, clipping groups, blends, symbol instances, and groups or layers of them. A clipping group, a blend or a symbol instance sweeps the art it shows about one axis, as described in [three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances). Images must be traced first with Object › Image Trace › Make, and other live objects expanded. - Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. diff --git a/docs/roadmap.md b/docs/roadmap.md index 115ba760d..1b5574061 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,11 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) - +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> +> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). +> +> **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged. +> > Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). ## Feature submission evidence @@ -10,6 +14,8 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. + **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged. @@ -46,10 +52,12 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | +| 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | | 2026-11-05 | minor | Pencil and Paintbrush: a new stroke with Keep selected off leaves nothing selected, the earlier selection included, while Keep selected on selects only the new path (#5752); scores and estimates unchanged. | | 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. | @@ -136,26 +144,16 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | -| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files; the script document model and console and batch work remain | 18–27 | -| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | +| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -192,6 +190,13 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | +| 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | +| 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | +| 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged | +| 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. | +| 2026-11-10 | minor | M10/G5: Game HUD built-in graphic style library; estimates unchanged | +| 2026-11-10 | minor | M10/G5: Map Markers built-in symbol library (Pin, Flag, Crosshair, Checkpoint); estimates unchanged | | 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. | @@ -205,6 +210,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | +| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 34503877c..55d9bceba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,10 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> +> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). +> +> **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged. ## Feature submission evidence @@ -10,6 +14,8 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars stay at the bottom after enlargement. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. + **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged. @@ -41,6 +47,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | +| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | @@ -58,6 +66,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | | 2026-11-05 | minor | A nearby hit on a plain long stroke or outline avoids assembling its whole path. A hit near the middle of a 200,000-anchor path took about 11–13 ms before and 1.0–1.4 ms after in paired runs on a busy Windows machine; a miss inside the path's box fell from 18.7 ms to 1.6 ms, while a distant miss was unchanged. Other stroke styles and fill hits keep their existing checks. Scores and estimates unchanged; idle performance budgets remain unverified. | +| 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | Translating a type object without changing its text or formatting reuses glyph geometry and expanded character-stroke outlines between frames. The text appears at its new position with unchanged pixels. Scaling the type, editing its content or formatting, or changing installed fonts rebuilds the geometry; scores and estimates unchanged. | | 2026-11-05 | minor | Moving a large selection reuses the object locations found during command validation. A 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs; object positions, selection, and undo behavior stay the same. Scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: an image drawn larger than 1,024 pixels at the size it shows, with its colours made again (separated into ink planes, colorized or shown through Separations Preview), has them made only for the 1,024-pixel tiles in view, each kept for later frames (#4155). Zooming in to 50%, 100% and 200% on the file of #4155 went from 8.6–9.0 s to 0.78 s, 0.05 s and 0.05 s (measured, M3 Pro, one run each), with the same pixels. Scores and estimates unchanged. | @@ -209,21 +218,21 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | @@ -236,7 +245,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged | -| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged ; interim macOS cursor delivery (#2292), headless coverage only; native validation pending, score and hours unchanged | +| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; icon-strip flyouts retain user sizes (#5713); floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged ; interim macOS cursor delivery (#2292), headless coverage only; native validation pending, score and hours unchanged | | Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated | | Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) | | **Feature depth** | **105** | **~75%** | | **~250–420** | | @@ -388,7 +397,7 @@ what keeps it from beta, not from alpha. | Dimension | % | One agent (h) | Kind | Doc | |---|---:|---:|---|---| | Features (depth, weighted) | 75% | 220–360 | estimated | this file | -| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring); held spinner arrows keep stepping (#6307) | +| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring); held spinner arrows keep stepping (#6307); Vector Halftone Preview keeps the selection box without the dot marks (#6369) | | File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | @@ -478,16 +487,16 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (four generated in code are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | @@ -530,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~32% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have two (Basic Shapes, UI Frames with default 9-slice) and brush libraries four (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -550,17 +559,22 @@ since 2026-10-01. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. +- 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. **Drawing tools — Polygon and Star counts:** The existing canvas placement dialog reads the stored count and saves it only after successful placement. `polygon_and_star_canvas_counts_persist_without_new_toolbar_dialogs` covers both tools, repeated drags, tool switching, reopening and Cancel. Counts remain session tool options. Existing click and toolbar gestures are retained; no score or hour change. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. +**Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. +**Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. +**Drawing tools, Dimension tool dimensions selected with their art keep their lines and label:** Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave a dimension selected with art it measures, directly or in selected groups without fills, strokes or effects of their own, as it is and change only the other art; when the new art no longer fits its link, the dimension stays where it was, no longer linked, and undo links it again. `expand_leaves_a_dimension_selected_with_its_art_as_it_is`, `expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is`, `outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is`, `a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing`, `a_group_effect_expanded_reaches_the_dimension_it_holds`, `expand_keeps_a_dimension_in_its_asset_and_its_variable_binding`, `a_dimension_keeps_no_link_to_the_art_expand_replaced`, `a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced`, `a_dimension_outlasts_the_stroke_expand_outlined` and `expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. **Review follow-up:** The fit-query follow-up covers fitting after draft dimensions change, updated artwork after a document revision, cache scope and new-artboard fitting/history. Selection uses the existing application bounds cache; global artwork queries include all drawn document art and are cached by active document and revision; draft geometry and target artboards do not limit membership. This is not a native timing claim. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. +- **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -630,6 +644,7 @@ since 2026-10-01. Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. - **New ruler guides (G1):** Alt swaps orientation while dragging from either ruler, following the [public shortcut reference](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). Preview, snapping and final placement use the current axis. Tool and egui event tests exercise both rulers, modifier changes, cancellation and one-step Undo/Redo; existing-guide Alt-copy and ruler-origin gestures are unchanged. +- **Artboard guides with Cmd/Ctrl + ruler drag:** VectorCraft's own option, Preferences › Guides & Grid › Artboard Guides with Cmd/Ctrl + Ruler Drag (`cmdRulerDragArtboardGuides`), off by default. With it on, Cmd/Ctrl held when a drag leaves a ruler makes a guide of the active artboard, and with the Artboard tool a canvas guide; the key is read when the drag starts. The guide is made only where it lies within the active artboard. The documented way to limit a guide to one artboard, dragging it out with the Artboard tool ([Align objects with guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html)), is unchanged when the option is off. Tests `rulerguide::tests::a_guide_within_its_artboard_is_made_only_inside_it`, `tests::menucmds::cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on` and `canvas::tests::cmd_ruler_drags_make_artboard_guides_with_the_preference_on`. - **Width-point finite-arithmetic follow-up (G1/M3):** Adjoining adjustments avoid intermediate overflow; non-finite normalized or resulting factors are rejected before committing. Engine regressions in `tests/widthpoints.rs` cover finite extremes, small nonzero results, atomic rejection, Undo/Redo and native save/reopen. Historical focused validation on the `a3357121` integration observed 2 expected RED failures with 11 passing controls, then all 13 tests passing with the repair; none were ignored. All three added regressions passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. Scores and hours unchanged. - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). @@ -637,9 +652,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`. - **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. +- **Ruler double-click (#5239):** a double-click (both clicks on that ruler) on the top ruler adds a vertical guide at the pointer's x and one on the left ruler a horizontal guide at its y (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), through `guide.add` in one undo step; the guide is not snapped, is shown even when guides were hidden, and is an artboard guide with the Artboard tool, as a drag from a ruler makes one. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe the gesture, so it follows the request in #5239. egui frame tests cover both rulers, a lone click, Undo, a canvas click before a ruler click, and rotated views. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). +- **Artboard guide labels (#6414, in part):** Artboard Options' Guide Label gives an artboard a color label (`artboard.setProps`/`artboard.new` `guideLabel`: a layer color's name, `#rrggbb` or null), and the canvas draws that artboard's own guides in it; canvas guides, guides of an unlabeled artboard and guides whose artboard is gone keep the guide color preference, and selected guides keep the selection color. The [help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) describes guides restricted to an artboard, not a label, so the label, its palette and the color precedence are VectorCraft's own. It is screen-only (exports and print leave it out), written to the native format only when set, kept by duplicates and copies, and changeable on a locked artboard; `artboard.setProps` adds no undo step when nothing changes. This VectorCraft addition does not raise a score. Regression tests: `tests/panelcmds.rs` (`artboard_guide_labels_are_set_and_cleared_in_one_undo_step`, `artboard_guide_labels_round_trip_through_the_native_format`), `doc` `tests.rs` (`guide_label_text_names_a_layer_color_or_rgb`, `artboard_guide_label_defaults_and_skips_in_json`), `canvas::tests` (`artboard_guide_label_colors_only_its_own_guides`), `dialogs/tests.rs` (`artboard_options_shows_sets_and_clears_the_guide_label`). - **Fade Region Outside Artboard:** Artboard Options' Global checkboxes Fade Region Outside Artboard and Update While Dragging ([user guide, Artboard options](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)) are application preferences (`fadeRegionOutsideArtboard`, `artboardUpdateWhileDragging`), both on by default. While the Artboard tool is active the canvas draws everything outside the artboards, the pasteboard and the art on it, with each color channel at 60% of its value (VectorCraft's own amount), or only outside the box around all the artboards when crossing artboards would leave more than 4,096 rectangles in view; with Update While Dragging off it draws as usual during an artboard move or resize. Presentation Mode, exports and print do not change. Regression tests: `canvas::tests` (`the_artboard_tool_fades_the_pasteboard`, `presentation_mode_draws_no_fade`, `the_fade_darkens_art_on_the_pasteboard_and_not_on_the_artboard`, `outside_rects_cover_the_bounds_minus_the_boards`, `outside_rects_of_a_row_of_boards_stay_few`, `the_fade_of_crossing_boards_stays_bounded`, `update_while_dragging_keeps_or_drops_the_fade_during_an_artboard_drag`, `the_pasteboard_fade_leaves_exports_unchanged`), `dialogs/tests.rs` (`artboard_options_global_checkboxes_set_the_preferences`, `artboard_options_error_leaves_the_global_preferences_unchanged`). - **Saved views (G7):** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): up to 25 views per document, saved with it, renamed or deleted in Edit Views, chosen from the bottom of the View menu). Each view keeps the zoom, centre and rotation; the View menu lists all 25 (`view.goto1`–`view.goto25`), and Edit Views… lists them to rename or delete, applied together as one undo step. A file's views are checked when it opens (at most 25, unique names up to 255 characters, a finite centre and a positive zoom). Tests: `saved_views_round_trip_and_from_a_file_are_tidied` (`crates/format/tests/prop_format.rs`), `crates/engine/src/cmd/views.rs`, `crates/ui-egui/src/dialogs/edit_views.rs` and `every_saved_view_is_listed_at_the_bottom_of_the_view_menu` (`crates/ui-egui/src/menus.rs`). - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. @@ -647,8 +664,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. +- **Export and Import Preferences (#6435):** an optional VectorCraft feature. The Preferences dialog's Export Preferences… saves the applied preferences to a JSON file (`prefs.export`; the web build downloads it), and Import Preferences… applies such a file (`prefs.import`). The file leaves out the folder preferences. The import checks every value first and changes nothing on an error; it keeps the open document's units and records no undo step. The file format is VectorCraft's own. Tests: `crates/engine/src/tests/prefs_file.rs`, `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`. +- **Reset Preferences asks first (#6435):** the Preferences dialog's Reset Preferences button opens a question over the dialog (`resetPreferences`). Cancel or Escape goes back to Preferences unchanged, with its category, search and edits kept; OK goes back with every preference at its default value, which OK in Preferences applies and Cancel there discards. The question and its wording are VectorCraft's own. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. @@ -713,15 +732,30 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. + - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. + - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. + - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). + - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. + - Scripts: an uncaught error that one or more `finally` blocks pass on unchanged, in the script or in a function it called, is reported at the line and column where it was thrown, in `script.run`'s report and the Script Console. An error a `finally` block throws is reported at that throw, and one its `return`, `break` or `continue` discards is not reported. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. + - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. + - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). + - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. + - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. + - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). + - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). + - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 16.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. + - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_16_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -777,7 +811,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them. - - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes and UI Frames (Panel, Button and Bar with default 9-slice guides), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. + - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes, UI Frames (Panel, Button and Bar with default 9-slice guides), Map Markers (Pin, Flag, Crosshair and Checkpoint, no default 9-slice), Inventory Icons (Sword, Shield, Potion, Key and Gem, no default 9-slice) and Skill Icons (Attack, Defend, Heal, Buff and Ult, no default 9-slice), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. @@ -789,7 +823,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. @@ -822,6 +856,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. - Numeric spinner arrows keep stepping while held with the mouse past the click timeout and stop on release (#6307; `a_held_spinner_arrow_repeats`). + - Object › Vector Halftone keeps the selection box and hides selection marks on the temporary dots during Preview; turning Preview off or cancelling restores the original selection marks (#6369). - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. @@ -888,6 +923,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A symbol instance is measured by its symbol's art, as its transform places it, for clicks, the marquee, the bounding box, the Transform panel, Align and Distribute (the art's strokes too with Use Preview Bounds), Fit to Artwork Bounds, exports of the art, thumbnails and canvas culling; one whose symbol is missing is measured as the 20 × 20 square around the origin. A selected clip group shows the outline and anchors of its clipping path, not of the art the path clips, and its hover highlight and key object outline follow the clipping path too. That art shows its whole outline when it is selected on its own: with Edit Contents, Direct or Group Selection, or in isolation mode (#2422). Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). + Copy and Cut of a compound-path member selected on its own, with Direct or Group Selection, take only that member, as an ordinary path with the compound's paint, transparency, opacity mask and fill rule, and a gradient keeps the compound's fit; the other members stay a compound, also when one is left. Selecting the compound, or every member whole outside isolation mode, still takes the whole compound, and Clear of every member's anchors or segments removes the compound too (`engine/tests/it/copy_direct_selection.rs`). A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Make Repeat and Live Paint no longer fail on such a list, New Symbol no longer stores the object twice, and `object.transform` no longer moves it twice. - **Compound paths:** Make keeps the backmost object's appearance and each contour; selecting a member in Direct Selection reads and changes the compound's fill rule in Attributes. Direction remains a per-member edit. Rendered holes, unchanged contour geometry, no-op history and Undo/Redo are covered by `engine/tests/it/compound_attributes.rs` (#1197). Behaviour reference: [public compound-path documentation](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-paths.html). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. @@ -910,11 +946,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. Escape while an arc, spiral or grid is dragged out cancels it and puts back the options its keys, Alt and Ctrl/Cmd changed (slope, open or closed, base axis, segments, decay, dividers and skews), so the next drag and the tool's options start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). + A Scissors cut (`path.split`) leaves one of the two new end points selected and the rest of the path unselected, as [Cut paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/split-paths.html) and the "Split a path" section of the [CS3 user guide](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) describe: the two end points lie on top of each other, and a Direct Selection drag from them moves the selected one with its handles and no other anchor. A closed path cut once stays one open path. While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). + A Reshape drag stretches part of a path from the dragged point without losing the path's general form, as [Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) has it. On an open path the stretch reaches both ends: the end anchors stay in place, each anchor between moves less the nearer it lies to an end, and the handles of a smooth anchor stay in line (`path.reshape`; the reach and the shares are VectorCraft's own rule, in `docs/mcp.md`). A click sets a focal point and a click on a segment adds a highlighted anchor there; Shift-click adds more, and a drag from any of them moves them all. The anchors that are not direct-selected stay in place and a closed path selected whole moves whole (VectorCraft's own rule, in `docs/mcp.md`). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). Its Scale section has the Relative and Absolute radio buttons the same page describes: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`, which Transform Again repeats); Absolute measures each object's own box, and the scales act along its axes about that box's reference point, so a turned shape keeps its shape and objects of different sizes come out alike; the fields show the width and height the selected objects share and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), and a size typed into them sets every object to it (a side left Mixed keeps its size), our choices where the page leaves them open; a live Repeat scales its spacing by the geometric mean of the two factors, so its Absolute size is approximate. The link beside the two scales keeps them in proportion, as the shape dialogs' Width and Height link does (`__constrain`, kept with the dialog's values; where it sits is our choice, the public pages don't establish it). The panel lists Scale, Move and Rotate, then the Options in two columns (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, Reflect Y, Random), the Reference Point, and Preview at the left of the button row, level with Copy, Cancel and OK. Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). @@ -923,6 +961,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Direct Selection and Group Selection, a drag that starts on an object while the selection also holds direct-selected anchors or segments moves those anchors and the objects selected whole in one undo step (`path.moveAnchors`), as a drag of a selected anchor does, and the other anchors of a partly selected path stay in place. While Alt is held during the drag, it copies the selected objects instead (`object.transform`). When direct-selected anchors are moved or transformed together with objects selected whole (such a drag, an anchor drag, an arrow key, or Move, Rotate, Scale, Reflect and Shear without a copy), a path inside a selected group is transformed once, with the group. A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included). With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies). + When the Add Anchor Point tool, the Pen's Auto Add, the Smart Pen's Alt-click on a segment or Alt-click with the Scissors adds an anchor to a path with direct-selected anchors or segments (`path.insertAnchor`), the same points stay selected: the anchors and segments after the new anchor are numbered one higher, and a selected segment that the new anchor splits stays selected as its two halves. `path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0. `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. @@ -937,7 +976,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. @@ -997,6 +1036,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only. Snap to Last Location: with Smart Guides on and Preferences › Smart Guides › Snap to Last Location on (off by default, VectorCraft's own choice), a selection moved by dragging with the Selection, Group Selection or Direct Selection tool, Shift too, goes back to where the move began while it is within Snapping Tolerance of it, labeled "last location"; an Alt copy is not pulled back onto its original, and with View › Snap to Grid or Snap to Pixel on the move snaps to the grid or to whole pixels instead (`prefs.set` key `snapToLastLocation`). The public [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) page lists the option as snapping objects back to their previous position; the distance, the label, the copy rule and the grid and pixel rule are VectorCraft's own. Tests: `select::tests::snap_to_last_location_puts_a_move_back_where_it_began`, `select::tests::snap_to_pixel_wins_over_snap_to_last_location`, `direct::tests::snap_to_last_location_puts_a_path_moved_by_its_anchor_back`, `direct::tests::snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor`, `tests_prefs::snap_to_last_location_puts_a_dragged_square_back`. + Pixel Preview: with View › Pixel Preview on, the tools use no Smart Guides. Nothing snaps to Smart Guides or shows their guides, and Snap to Point acts as it does with Smart Guides off. View › Smart Guides keeps its setting and applies again when Pixel Preview is turned off. [Work with Smart Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/work-with-smart-guides.html) says Smart Guides cannot be used while Pixel Preview is on. Tests: `tests::smartguides::pixel_preview_turns_smart_guides_off`, `canvas::tests::pixel_preview_stops_smart_guides_snapping_a_move`. Distance Guides: with Smart Guides on (View › Smart Guides and Preferences › Smart Guides › Invoke Distance Guides, on by default), something selected and the Selection tool, holding Alt/Option over another object shows the distances between the selection's bounds and that object's: on an axis on which they lie apart, one line across the gap between their facing edges; on one on which they overlap, the offsets of their left and right (top and bottom) edges, each line running across the middle of where they overlap, with a dashed line out to the other object's edge when it passes beside it. Over an empty part of an artboard it shows the selection's distances from the artboard's left, right, top and bottom edges. Each line has ticks at its ends and its length in a pill of the smart guide colour, in the General unit; letting Alt go, pressing (a pan or zoom press too), moving off them, leaving the canvas or a floating panel, menu or dialog over the pointer takes them away, and with the pointer still they follow what lies under it as the view scrolls, zooms or turns, as the document, the tab or the selection changes, and when the Selection tool is switched to. Behaviour from the public [Use Distance Guides for accurate placement](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html) and [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) pages, which describe hovering only: nothing shows while moving or nudging. Tests: `select::tests::alt_over_*`, `select::tests::distance_guides_need_alt_a_selection_and_smart_guides`, `tests_prefs::invoke_distance_guides_measures_the_selection_to_the_object_under_alt`, `canvas::tests::alt_over_another_object_draws_the_distance_guide_label`, `canvas::tests::distance_guides_go_when_the_hover_ends`, `canvas::tests::distance_guides_follow_what_lies_under_a_still_pointer`. A move pressed on a path's stroke, or within 6 px of its outline away from its anchors and center, is held by the nearest point of that path, with the Selection, Group Selection or Direct Selection tool, and a straight segment Direct Selection drags is held by its own nearest point. That point is what snaps, so the path lands exactly on a ruler guide (with "align") instead of the pressed spot landing there and the path a few pixels beside it, an offset that showed only zoomed far in (#3701). A press in the middle of a fill still snaps the pressed spot: the public [Align objects with guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) page has Snap to Point align the point under the pointer, which is placed exactly where the object should meet the guide. A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center. @@ -1049,6 +1089,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). + - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). @@ -1061,7 +1103,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size; the font size follows the vertical scale, so a W typed alone, out of proportion, changes the horizontal scale instead. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)). @@ -1092,7 +1134,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. - The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. + The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. @@ -1116,6 +1158,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). + Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. @@ -1144,10 +1187,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. - Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. + Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). @@ -1180,11 +1223,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. +- **Gradient mesh keeps the object's outline (#2752):** Create Gradient Mesh, a Mesh tool click and Expand of a gradient to a mesh lay the mesh's border on the path's outline: each border edge is the piece of the outline between its two points, and where the outline bends through an anchor (a star's points, an L-shape's inner corner, a rounded rectangle's arcs) no single curve follows, the mesh takes a line through that anchor, up to 256 each way. Holes and other subpaths of a compound path are still left out. Public reference: [About mesh objects and Create mesh objects, CS3 user guide, pages 184 to 185](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) (mesh lines carry anchor points as well as mesh points). Tests: `a_mesh_keeps_the_outline_of_every_shape` and `a_mesh_takes_lines_only_where_the_outline_needs_them` in `crates/doc/src/live.rs`, `a_mesh_tool_click_keeps_a_stars_points` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed, isn't CMYK and can be read at its recorded size is cut from its own pixels when the crop edges fall on its pixel grid, copied as they are without resampling; any other image is drawn, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. Smooth Color counts the steps of each pair of keys from their color change, or from the distance between their visible bounding-box edges when their colors match or they hold gradients or patterns ([Blend Options](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)); the step counts, their caps and the colors compared are VectorCraft's own. @@ -1251,7 +1295,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). @@ -1287,7 +1331,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; eight generated style libraries (including Game HUD and Status Effects) save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). @@ -1332,7 +1376,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). - PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). + PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`). - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only. @@ -1394,11 +1438,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). - PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. + PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), 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, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version. PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. + PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). A placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`). @@ -1486,6 +1531,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD (on Linux, of the process's cgroup memory limit where that is lower, and in a 32-bit build at most a quarter of the address space), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. + The undo histories of all the open documents together keep their steps within a quarter of the installed memory on the same systems (at least 64 MiB, and at most a quarter of the address space in a 32-bit build), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps of any open document are dropped first, but each document keeps its last eight undo steps and its next eight redo steps. Closing a document frees its share for the others. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. @@ -1519,7 +1565,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. - With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing, and the edge of their joint bounds nearer a gridline lands on it. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. + With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. Without Shift, on each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With Shift, an edge also lands only when it ends within a quarter cell of a gridline: a horizontal or vertical move can land only an edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. When no edge on those axes is that close, the move does not snap. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. @@ -1607,14 +1653,17 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. +- **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. @@ -1676,7 +1725,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi `pdf::tests::dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill` and the Gradient panel's `the_method_menu_makes_the_gradient_perceptual`. Scores and hours unchanged. -PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html); [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html)). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there, or at the stop's mix position between the two colors when that mix's lightness turns back on the way from one color to the other, as from red to blue (VectorCraft's own rule). Dithered fills, freeform gradients, Perceptual gradients that mix a spot color with RGB or Lab stops, and Perceptual gradients whose curve has more than 4,096 stops once expanded (VectorCraft's own limit) are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve`, `ink_mixes_follow_the_perceptual_curve` and `ink_mixes_use_the_mix_position_when_the_lightness_turns_back`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. +PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html); [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html)). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there, or at the stop's mix position between the two colors when that mix's lightness turns back on the way from one color to the other, as from red to blue (VectorCraft's own rule). Dithered fills, freeform gradients, Perceptual gradients that mix a spot color with RGB or Lab stops, and Perceptual gradients whose curve has more than 4,096 stops once expanded (VectorCraft's own limit) are still images, and the export warns that their spot colors are written in process colors. In a CMYK document, or an export that converts colors to a CMYK destination profile, RGB and Lab stops are written in CMYK, so a Perceptual gradient that mixes a spot color with them is a DeviceN shading; an export that converts colors to an RGB destination profile keeps them in RGB. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve`, `ink_mixes_follow_the_perceptual_curve` and `ink_mixes_use_the_mix_position_when_the_lightness_turns_back`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings`, `perceptual_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. In these Separation and DeviceN shadings, a white stop linked to a swatch that is not a spot color (a global White swatch, for example) is paper white, 0% of the ink, as an unlinked white stop is, so a gradient of one spot ink and such a stop stays a Separation shading; a stop linked to a white spot color stays a stop of that ink (VectorCraft's own rule). Test: `engine::tests::linked_stops::white_stops_linked_to_process_swatches_are_paper_in_spot_gradients`. @@ -1865,7 +1914,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol keeps the instances linked and keeps each change whose object the edited art still has; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests, `crates/engine/src/tests/symboledit.rs`. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) @@ -1914,9 +1963,11 @@ The right-click followed (2026-11-10), as the same page's "Correct spelling erro The page's other way to turn Auto Spell Check on followed (2026-11-10): right-click the text and choose Spelling › Auto Spell Check (#4936). A right-click on type (which selects it) has a Spelling submenu with Auto Spell Check, which runs `edit.autoSpellCheck` as Edit › Spelling does. The context menu draws no check marks, its toggles saying what they do (Show Rulers, Hide Guides), but a submenu that holds only toggles shows them, so Auto Spell Check is checked while it is on; other art has no Spelling submenu (`right_click_on_type_turns_auto_spell_check_on_and_off`). +Edit Custom Dictionary followed (2026-11-10), as the same page's "Edit the custom dictionary" describes it: Edit › Edit Custom Dictionary…, then enter a word in the Entry field and Add it, select a word and Delete it, or select a word, edit it in the Entry field and Change it, then Done (#4936). The dialog lists the custom dictionary's words (`text.spelling.dictionary`); picking one puts it in the Entry field. Add and Change take one word that isn't listed yet, kept in lower case as the dictionary keeps it, and Delete and Change need a word picked. Nothing changes until Done, which deletes and adds the words that differ (`text.spelling.removeWord`, `text.spelling.addWord`); Cancel leaves the dictionary as it was (`ui-egui/src/dialogs/custom_dictionary.rs`). The Check Spelling dialog remains. + ## Control bar: Paragraph section while type is selected (2026-11-06) -With type selected, the Control bar shows a Paragraph: link and the Align left, Align center and Align right buttons after its Character controls, as the public documentation has the Control panel format paragraphs while the Type tool is in use, with a way to the Paragraph panel ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html)), and its alignment buttons align text as the Paragraph panel's do ([Align text](https://helpx.adobe.com/illustrator/using/align-text.html)). A button aligns the selected type (`text.setStyle {justify}`, one undo step) and the shared alignment shows selected; the link opens the Paragraph panel in a popover under it, as Character: opens the Character panel. Keeping to the three alignments that don't justify is our choice, for the bar's room; the popover has all seven. Short of room the bar leaves controls out from its right end: the inline X/Y/W/H fields, the Distribute and Align buttons, area type's vertical alignment, then these buttons and last the link, keeping room for the Transform link; on a 1440 pt bar with one point type object, the buttons show (pinned by a headless test, #5372). +With type selected, the Control bar shows a Paragraph: link and the Align left, Align center and Align right buttons after its Character controls, as the public documentation has the Control panel format paragraphs while the Type tool is in use, with a way to the Paragraph panel ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html)), and its alignment buttons align text as the Paragraph panel's do ([Align text](https://helpx.adobe.com/illustrator/using/align-text.html)). A button aligns the selected type (`text.setStyle {justify}`, one undo step) and the shared alignment shows selected; the link opens the Paragraph panel in a popover under it, as Character: opens the Character panel. Keeping to the three alignments that don't justify is our choice, for the bar's room; the popover has all seven. Short of room the bar leaves controls out from its right end: the inline X/Y/W/H fields, the Distribute and Align buttons, area type's vertical alignment, then these buttons and last the link, keeping room for the Transform link; on a 1536 pt bar with one point type object, the buttons show (pinned by a headless test, #5372). ## Area type: vertical alignment in the Properties and Control panels (2026-11-04) @@ -1930,10 +1981,20 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | +| 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | +| 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | +| 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | +| 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | +| 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | +| 2026-11-10 | minor | File formats: in a `.ai` or EPS opened from its editing data, an envelope of type opens live where it draws as its drawn art and the layers with it draw as the page (the envelope weighed as the art it draws); elsewhere, and where a text or visibility variable binds its type, it keeps its drawn art, with a note (#3179); evidence updated; scores and hours unchanged. | +| 2026-11-10 | minor | Bundled content: built-in Game HUD graphic style library; style libraries evidence updated; ~34% → ~36%; more built-in graphic-style libraries remain. | +| 2026-11-10 | minor | Bundled content: built-in Map Markers symbol library (Pin, Flag, Crosshair, Checkpoint); symbol libraries evidence updated; ~32% → ~34%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | | 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports (evidence in Feature revisions). Scores and estimates unchanged. | +| 2026-11-09 | minor | Bundled content: built-in Game Trails brush library (Speed Line, Dash Trail, Burst Ray, Zigzag); brush libraries evidence updated; ~32% → ~34%; more built-in brush libraries remain. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | | 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. | | 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. | @@ -2010,6 +2071,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | 3D Extrude & Bevel, Inflate and Rotate apply to type, which stays editable (#4715); scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | +| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool; evidence added; scores unchanged. | | 2026-10-30 | minor | Presentation Mode fits the active artboard and hides selections, guides, grids and artboard edges; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: Select by Color Kind; scores and estimates unchanged | @@ -2077,6 +2139,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-20 | minor | Type core: Hyphenation… and Justification… no longer listed as missing (done in #2310, #2755); evidence: a paragraph's Auto Leading drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; scores unchanged | | 2026-10-22 | minor | Drawing tools: a Pen, Anchor Point or Direct Selection drag's preview no longer stalls (#834: the position where the pointer re-entered the window could be skipped, and the move in the release's frame was lost); score and hours unchanged (estimated) | | 2026-10-23 | minor | Guides and snapping: a path moved by a press on its stroke or beside its edge (Selection, Group Selection, Direct Selection), and a straight segment Direct Selection drags, snap by the nearest point of the path, so the path lands exactly on a ruler guide with "align" instead of the pressed spot landing there and the path a few pixels beside it (#3701); `tests_prefs::a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide`, `tests_prefs::a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide`. Score and hours unchanged. | +| 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). | | 2026-10-22 | minor | Live vector effects: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); the area row now lists only the options on a fill's or stroke's own Transform effect. Score and hours unchanged (estimated). | | 2026-10-23 | minor | COLR colour fonts (v0 and v1) draw and export as vector art; evidence added; scores unchanged. | | 2026-10-22 | minor | Libraries panel: the buttons beside the library picker show the items as icons (the default) or in a list, colours and graphics as rows with their names (a colour's hex value too), dragged and dropped by row; graphic thumbnails are shrunk to the size they are drawn at, so thin strokes stay whole. Tests: `panels::libraries::tests::the_list_view_shows_names_and_takes_drops_by_row`, `the_panel_draws_its_libraries`. Scores and hours unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index 0e9901061..635c1285e 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -4,7 +4,8 @@ The other three 3D types of **Effect › 3D and Materials** next to [Revolve](re live effect: the source path stays editable, the effect draws shaded vector faces, and SVG/PDF export, native saves and **Object › Expand Appearance** use the same evaluator. -Select a path or compound path, then choose **Effect › 3D and Materials › Extrude & Bevel…**, **Inflate…** or +Select a path, compound path, type, clipping group, blend, symbol instance, or a group or layer of them, then choose +**Effect › 3D and Materials › Extrude & Bevel…**, **Inflate…** or **Rotate…**. The options dialog opens beside the artboard with tabs: - **Object** (Extrude & Bevel and Inflate): the shape's own options, below. @@ -105,7 +106,8 @@ The rest is our reading: - The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's grid stays flat. -- A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws. +- A stroke whose outline takes more than 65,536 points, or whose solid takes more than 163,840 faces, is left out, + and the fill's solid still draws. ## Groups @@ -117,13 +119,48 @@ it, at any depth of its groups, each from its own fills and strokes as above ([F and they turn, twist and taper together about the center of their bounds together; with Perspective one eye sees them all. The members stay editable: moving or editing one reshapes the solids. Our reading: -- Each member's own geometry effects shape it first; the group's geometry effects before the 3D effect reshape the +- Each member's own geometry effects shape it first, then those of each group inside the 3D group that holds it, + applied to that group's members together; the 3D group's geometry effects before the 3D effect reshape the members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and - strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. + strokes, are not drawn. A member's opacity and blending mode apply to its solids, and the blending mode of a group + inside the 3D group applies to the solids of that group's members together. An opacity mask on + a member masks its solids, and one on a group inside the 3D group masks the solids of that group's members + together. The mask stays where it is on the page; it does not turn with the solids. A disabled mask is ignored. +- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, each + solid is drawn on its own, the backmost member's first, as when a solid is transparent. - When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn on its own, the backmost member's first. -- Hidden members and guides are left out. A group holding an image, a symbol instance, a clipping group or another - live object is refused with a message saying what to release or expand; so is one with more than 4,096 shapes. +- Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a + repeat) is refused with a message saying what to release or expand; so is one with more than 4,096 shapes. + +## Clipping groups, blends and symbol instances + +Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate and Revolve) also applies to a clipping +group, a blend or a symbol instance, alone or inside a group or layer. The help page says that effects apply to +objects, groups or layers ([Apply and modify effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html)), +that a clipping mask hides everything outside its shape ([Clipping masks](https://helpx.adobe.com/illustrator/using/clipping-masks.html)), +and that a blend is treated as one object ([Blending objects](https://helpx.adobe.com/illustrator/using/blending-objects.html)). +How the solid is built from each is VectorCraft's own rule: it is built from what the object shows, as a group's +solids are ([Groups](#groups)), turned about one shared axis. + +- A clipping group (or a layer with a clipping mask) builds from its art inside the clipping path: each shape's fill + area cut to the clip, and each stroke's outline cut to the clip and filled in the stroke's paint. The clipping path + draws no solid. A clipping group inside another keeps what both clips show. A clip that shows none of the art + leaves nothing to build, and the effect is refused. +- A blend builds from its key objects and its steps, each in its own paint, in their order. +- A symbol instance builds from its symbol's art where the instance places it. Redefining the symbol reshapes the + solid. An instance of a symbol that is not in the document, a symbol that holds an instance of itself, or art + nested more than 64 levels deep is refused. +- Inside a 3D group, the geometry effects of a clipping group, a blend or a symbol instance reshape the art it shows, + as a group's do. Inside a clipping group, a group's geometry effects reshape its members before the clip cuts them. +- Art whose symbol instances, blends and clipping groups take more than 262,144 objects to go through (counting + hidden objects, and a symbol's art once for each instance that shows it) is refused. +- The objects stay live: editing the clipping path, the blend's keys or the symbol reshapes the solid. +- An image is refused, alone or in a group: trace it first with Object › Image Trace › Make. The help page routes + images to 3D through Image Trace ([Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html)). + A hidden image in a group is left out like any hidden member, and so is an image in a clipping group whose bounds + lie wholly outside the clipping path's bounds, when the image has no visible fills or strokes of its own and + neither the image nor a group holding it inside the clipping group has effects. ## Rotate @@ -231,14 +268,34 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. drawn in pieces about as long as a wall slice is wide (fewer near the limit of 32,768 facets). The piece drawn on top can still lie a few points behind another nearby piece. - Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation. -- Paths, compound paths, type and groups or layers of them (see [Groups](#groups)); images, symbol instances, - clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about - one axis ([revolve.md](revolve.md)). One 3D effect per object. +- Paths, compound paths, type, clipping groups, blends, symbol instances and groups or layers of them (see + [Groups](#groups) and [Clipping groups, blends and symbol instances](#clipping-groups-blends-and-symbol-instances)); + images have to be traced first, and other live objects expanded. Revolve on a group sweeps its members about one + axis ([revolve.md](revolve.md)). One 3D effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points - and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is - counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, - when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace - every hole between them); past it, the shape is too complex for the effect. + and a large one up to 32. Extrude & Bevel and Inflate take up to 65,536 outline points and 163,840 faces for each + solid (a stroke's outline is counted on its own), and Rotate (which only turns the outline) takes 65,536 points. + These limits are VectorCraft's own values. Before any point is sampled, Extrude & Bevel counts the faces its walls, + bevels and caps need, and Inflate counts the faces of its sides; when they do not fit, every curve of the shape + takes fewer pieces, down to 4 (a curve that needs fewer takes fewer). A shape that does not fit at 4 pieces a + curve is refused because the 3D surface is too complex. Inflate counts the faces of its front and back as it + builds them, against the same 163,840. The point limit is counted on the shape as drawn and, when its contours + meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between + them); past it, the shape is too complex for the effect. +- The faces drawn, exported and made by **Object › Expand Appearance** keep the shape's curves: the caps of + Extrude & Bevel, the flat face of Rotate, and the edges of the walls and bevels where they meet a cap are cubic + curves, with smooth anchors where the art's are smooth, while visibility and depth are worked out on the + sampled points. Without perspective and bevel each curve maps exactly. With perspective, a curve is split into + cubics until each lies within 0.001 pt of the projected outline. A face inset or outset by a bevel follows its + sampled outline within the sampling tolerance above, one curve for each sampled edge through its two sampled + points, so the cap and the walls next to it draw the edge they share as the same curves. The edges between two bevel steps or two wall slices, a + piece of curve that does not fit after 8 halvings, an edge of a twisted wall cut into pieces, and the curves + past 1,048,576 for one solid stay straight. The tolerances and limits are VectorCraft's own values. Inflate's + faces are polygons. +- Inflate also limits the work of filling its grid, which grows with the outline's points times the grid cells its + outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth + across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a + circle builds. - **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. diff --git a/docs/type-parity.md b/docs/type-parity.md index 8f3c8e7c2..702ecd504 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -25,7 +25,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | -| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show) | +| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); Direct Selection shows the anchors and direction handles of selected type on a path's path and drags both, the brackets keeping their fractions of the path and the text reflowing along it (#3980, #3565; `text.reshapePath`, `text.setPathHandle`), as the [Direct Selection page](https://helpx.adobe.com/illustrator/using/tool-techniques/direct-selection-tool.html) describes for a path; Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar) sizes type on a path to fill the span between its brackets and fits it again after each edit, as the [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) describes; the 0.1 pt steps, the 0.1 to 1,296 pt range and keeping the drawn sizes when it is turned off or threaded are VectorCraft's own rules (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`) | | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` | | Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | @@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | -| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. The Check Spelling and Edit Custom Dictionary dialogs remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. Edit › Edit Custom Dictionary… lists the custom dictionary's words, with an Entry field and Add, Delete and Change, applied on Done (Cancel leaves the dictionary as it was). The Check Spelling dialog remains. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click), `crates/ui-egui/src/dialogs/custom_dictionary.rs` (the dialog). | | Smart Punctuation, Change Case, Find & Replace | done | | | Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | @@ -81,6 +81,10 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | +| 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | +| 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | +| 2026-11-10 | minor | Type on a path (#3565): Direct Selection shows the direction handles of selected type on a path's path, as of a path selected whole, and drags them (`text.setPathHandle`; Alt: one side, Shift: 45° steps); the text reflows along the new curve. Scores and estimates unchanged. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on type has Spelling › Auto Spell Check, checked while it is on; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-09 | minor | Auto Spell Check (#4936): Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, without type with effects; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain. Scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7629084b8..296826671 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,9 +1,13 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) - +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> +> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). +> > Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). +> **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged. + ## Feature submission evidence Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. @@ -11,6 +15,8 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. + **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged. @@ -45,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -58,6 +65,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-06 | minor | Shape tools: a press on the selection's centre point (Show Center) moves the selection, also where no art is painted, and a press in the rotate zone just outside a bounding-box corner turns it about the centre, both as the Selection tool's do (#5477); the Selection tool takes its centre point too, so an unfilled shape moves instead of starting a marquee; scores and estimates unchanged | | 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar — a field's scrub label was a second, invisible tab stop, and the W/H link between W and H is now made after the fields, so it stays a tab stop after them; scores and estimates unchanged | | 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: and the Properties panel's Stroke, links to their panels, scrub the field after them as other labels do; a click still opens the panel (#4088); scores and estimates unchanged | +| 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged | | 2026-11-05 | minor | Direct Selection: Cmd/Ctrl held lends the Selection tool, whose bounding box shows while the key is down over objects selected whole; its handles scale and rotate, and a Cmd drag moves the whole object with its direct-selected anchors kept (#3901); scores and estimates unchanged | | 2026-11-05 | minor | Join tool: scrubbing over the ends of two crossing open paths that run past the crossing trims those ends and joins the paths there with a corner, instead of drawing a segment between the ends (#5844); scores and estimates unchanged | @@ -150,10 +158,11 @@ tools, never checked side by side) isn't captured row by row. | Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge | | Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it | | Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed | -| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests | +| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests While the button is down a round Blob Brush shows the outline of the area it will add, rounded at both ends, not a line as wide as the brush that ends square (#4343). | | Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) | | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | +| Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` | | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 | @@ -229,6 +238,7 @@ tools, never checked side by side) isn't captured row by row. | Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set, including Snap to Glyph and Alignment Guides (#6164); no View › Snapping submenu. Floating-panel parity remains unverified. | | Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | +| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | | Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | | Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | @@ -246,6 +256,7 @@ tools, never checked side by side) isn't captured row by row. | Moving a rounded rectangle with Craquelure at high DPI | partial | Distance searches optimized; moved-viewport pixels unchanged for three texture effects; `texture_drag_perf` probe available. Idle Windows mouse-drag QA remains (#1257) | | Bounding-box handles: Shift proportional, Alt from centre, rotate outside the corners | done | | | Rotated objects keep a rotated bounding box and their angle; Reset Bounding Box | done | | +| Vector Halftone Preview keeps the selection box and hides selection marks on the temporary dots | done | #6369; Preview off and Cancel restore the original marks. | | Free Transform, Scale and Reflect along a rotated box | missing | | | Free Transform widget: constrain, free distort, perspective distort; the key-held variants | done | | | Transform tools: click sets the reference point (snaps to anchors/centres), Alt-click opens the dialog, Alt-release copies | done | with anchors selected with Direct Selection, the tools, the dialogs and Move transform those anchors alone, about the centre of their box by default (#1408; the default point and whole-object copies are our choices) | @@ -273,7 +284,7 @@ tools, never checked side by side) isn't captured row by row. | Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | -| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | +| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864). A file that records no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files) opens and reverts in General units; native, SVG and DXF files and a `.ai` or EPS with ruler units keep theirs, as a [Community Expert's answer](https://community.adobe.com/questions-652/change-units-from-points-to-inches-won-t-stick-765372) describes (#5820) | | Units › East Asian Type (Q and Ha) | done | #394: `unitsAsianType` offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide's Measurement Units](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`); only it lists Q and Ha. The public pages do not say which fields use it, so no field uses it yet and that waits for a source. Tests `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | Field precision: 3 decimals for steps; values kept as f64 | done | | | Scrubby labels (beyond Illustrator) | done | #400; the labels that are links (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke) scrub too, and a click on them still opens their panel (#4088) | @@ -354,6 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | +| A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | | Behaviour | Status | Notes | |---|---|---| @@ -398,7 +410,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | -| Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | +| Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; five built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons; original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | | Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | @@ -429,6 +441,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | | Outline view draws on a white canvas, the artboards as their edges | done | Outline shows only the paths ([Outline mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/preview-artwork-as-outlines.html)). VectorCraft's own rule for the canvas: in every interface brightness and Canvas Color the whole canvas is white, pasteboard included; the artboards draw no paper (no Simulate Colored Paper or artboard color, and no paper with Hide Artboards); the artboard names and the ruler origin's crosshair are dark gray; split views and floating windows follow their own view. Tests `outline_draws_on_a_white_canvas_with_artboards_as_edges`, `outline_draws_no_colored_paper`, `a_pane_draws_its_own_views_outline`, `the_ruler_origin_crosshair_shows_on_the_outline_canvas` | +| Outline view: a click selects by the outlines drawn, so it reaches a path behind an image (picked on its frame, unless Show Images in Outline Mode draws its pixels) or a gradient mesh (picked on its mesh lines) | done | #6437; in Outline mode the path is selected directly ([Object selection methods and preferences](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/object-selection-methods-and-preferences.html), Object Selection by Path Only). Type, symbol instances, blends, envelopes and placed documents are still picked within their bounds. Tests `outline_mode_picks_paths_through_images_and_meshes`, `outline_view_click_through_an_image_selects_the_path_behind` | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools runs Arrange in Grid… (grid by row, one row or one column; columns, H and V spacing, Center on Artboard; Preview), Swap Objects, Swap Objects and Sizes, Replace with Top Object… (Actual Size, Bounds or Bounds (Fit Proportionally); Keep Original; Scale Strokes & Effects; Preview), Set Size of Each… (Width and Height each ticked on or off, Constrain Width and Height Proportions with one side, the reference point, Scale Strokes & Effects; Preview) and Zoom to Selection, each one undo step | done | `addon.arrange.*`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Group Overlapping Objects groups the selected objects whose visual bounds overlap or touch (chains included, within one layer or group), each group at its frontmost member's stacking place, one undo step | done | #4928; `addon.arrange.groupOverlapping`, `crates/engine/src/cmd/addons/arrange.rs` and its tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each selected object its own opacity at random between Minimum and Maximum (%), with a Seed that repeats it and Preview, one undo step | done | #4925; `addon.arrange.randomizeOpacity`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | @@ -498,6 +511,11 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | +| 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | +| 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). | +| 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. | | 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | @@ -535,6 +553,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged | | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); scores and estimates unchanged | | 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) | +| 2026-11-11 | minor | Blob Brush: while painting, the preview of a round tip is the outline of the area the stroke adds, round at both ends, as the stroke it commits (#4343). Scores unchanged (estimated). | | 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged | | 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged | | 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains | @@ -562,6 +581,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): its text tools in the panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | +| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool (ten re-editable shape kinds), its panel and Convert to Parametric Shape; scores and estimates unchanged | | 2026-10-30 | minor | Smart Pen add-on: an Alt-drag away from paths selects points with a marquee; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: Select by Color Kind; scores and estimates unchanged | diff --git a/sonar-project.properties b/sonar-project.properties index dc74b9bd0..287b7eb9c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,10 @@ sonar.projectKey=storytold_vectorcraft sonar.organization=storytold + +# S4790 (weak hash): MD5 is the key derivation of the PDF standard security handler revisions 2-4 +# (ISO 32000-1 §7.6.3.3), which PDF 1.3-1.6 files use. It is needed to read those files and to write +# the compatibility the user picks, not chosen for new security: the default is 256-bit AES +# (revision 6), which hashes with SHA-2. +sonar.issue.ignore.multicriteria=pdf_md5 +sonar.issue.ignore.multicriteria.pdf_md5.ruleKey=rust:S4790 +sonar.issue.ignore.multicriteria.pdf_md5.resourceKey=crates/pdf/src/encrypt.rs diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 260b10387..c827a1ef7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,6 +15,7 @@ mod pngsuite; mod sha256; mod stats; mod test262; +mod ui_commands; mod version; mod web_fonts; @@ -30,11 +31,12 @@ commands: cursors warn about Cursor values with no entry in crates/tools/src/cursors.toml (never fails) i18n-report list the strings each language's catalog lacks, shown in English (fails only on a broken scan; ci runs it too) + ui-commands regenerate crates/mcp/src/ui_catalog.rs from desktop UI_COMMANDS; --check fails on drift pdf-deps verify immutable shared PDF writer and patched Krilla sources layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates and the web app test the workspace tests (cargo nextest when installed, else cargo test), then the doctests - ci fmt --check, clippy -D warnings, test, doctests, no-bundled-fonts, pdf-deps, assets, brands, cursors (warnings only), layers, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, doctests, no-bundled-fonts, pdf-deps, assets, brands, cursors (warnings only), ui-commands --check, layers, wasm (stops at first failure) corpus [--download] [--affinity] show where test corpora live; --download fetches PngSuite into corpus/pngsuite, --affinity the pinned public Affinity documents into corpus/affinity @@ -59,6 +61,7 @@ fn main() -> ExitCode { Some("brands") => brands::run(&root()), Some("cursors") => cursors::run(&root()), Some("i18n-report") => cmd_i18n_report(), + Some("ui-commands") => ui_commands::run(&root(), rest.contains(&"--check")), Some("layers") => cmd_layers(), Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), @@ -302,6 +305,7 @@ fn cmd_ci() -> Result<(), String> { ("brands", Box::new(|| brands::run(&root()))), ("cursors", Box::new(|| cursors::run(&root()))), ("i18n-report", Box::new(cmd_ci_i18n_report)), + ("ui-commands", Box::new(|| ui_commands::run(&root(), true))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), ]; diff --git a/xtask/src/ui_commands.rs b/xtask/src/ui_commands.rs new file mode 100644 index 000000000..4bf12a272 --- /dev/null +++ b/xtask/src/ui_commands.rs @@ -0,0 +1,466 @@ +//! `cargo xtask ui-commands`: regenerate or check the MCP mirror of desktop `UI_COMMANDS`. +//! +//! Source of truth: `crates/ui-egui/src/menus/ui_commands.rs`. +//! Mirror: `crates/mcp/src/ui_catalog.rs` (mcp must not depend on ui-egui). +//! +//! `cargo xtask ui-commands` writes the mirror. +//! `cargo xtask ui-commands --check` fails when the committed file drifts. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const SOURCE: &str = "crates/ui-egui/src/menus/ui_commands.rs"; +pub const MIRROR: &str = "crates/mcp/src/ui_catalog.rs"; +const UI_EGUI_SRC: &str = "crates/ui-egui/src"; + +pub fn run(root: &Path, check_only: bool) -> Result<(), String> { + let expected = generate(root)?; + let mirror_path = root.join(MIRROR); + if check_only { + let actual = std::fs::read_to_string(&mirror_path).map_err(|e| format!("{MIRROR}: {e}"))?; + if actual == expected { + println!("ui-commands: {MIRROR} matches {SOURCE}"); + return Ok(()); + } + return Err(format!("{MIRROR} is out of date with {SOURCE}. Run `cargo xtask ui-commands` and commit the result.")); + } + if let Some(parent) = mirror_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{parent:?}: {e}"))?; + } + std::fs::write(&mirror_path, expected).map_err(|e| format!("{MIRROR}: {e}"))?; + println!("ui-commands: wrote {MIRROR}"); + Ok(()) +} + +/// Build the mirror file contents from the desktop table (rustfmt'd, same as `cargo fmt`). +pub fn generate(root: &Path) -> Result { + let source = std::fs::read_to_string(root.join(SOURCE)).map_err(|e| format!("{SOURCE}: {e}"))?; + let entries = parse_ui_commands(&source)?; + let resolved = resolve_entries(root, &entries)?; + rustfmt_source(root, &render_mirror(&resolved)) +} + +/// Format `src` with the workspace `rustfmt` (edition 2024, same as `cargo fmt -p vectorcraft-mcp`). +fn rustfmt_source(root: &Path, src: &str) -> Result { + let dir = std::env::temp_dir(); + let path = dir.join(format!("vectorcraft-ui-catalog-{}.rs", std::process::id())); + std::fs::write(&path, src).map_err(|e| format!("temp mirror: {e}"))?; + let config = root.join("rustfmt.toml"); + let status = + Command::new("rustfmt").args(["--edition", "2024", "--config-path"]).arg(&config).arg(&path).status().map_err(|e| format!("rustfmt: {e}"))?; + let result = if status.success() { + std::fs::read_to_string(&path).map_err(|e| format!("temp mirror read: {e}")) + } else { + Err(format!("rustfmt failed with {status}")) + }; + let _ = std::fs::remove_file(&path); + result +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Entry { + id: IdExpr, + label: String, + shortcut: String, + params: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum IdExpr { + Lit(String), + Path(String), +} + +fn parse_ui_commands(src: &str) -> Result, String> { + let start = src.find("pub const UI_COMMANDS").ok_or_else(|| format!("{SOURCE}: no `pub const UI_COMMANDS`"))?; + let after = &src[start..]; + // Prefer `= &[` so the type `&[(&str, …)]` is not mistaken for the value. + let bracket = after.find("= &[").ok_or_else(|| format!("{SOURCE}: UI_COMMANDS is not an `= &[...]`"))?; + let open_idx = start + bracket + 3; // index of '[' + let body_start = open_idx + 1; + let body_end = find_matching_bracket(src, open_idx).ok_or_else(|| format!("{SOURCE}: unclosed UI_COMMANDS array"))?; + let body = &src[body_start..body_end]; + let tuples = split_top_level_tuples(body)?; + let mut out = Vec::with_capacity(tuples.len()); + for t in tuples { + out.push(parse_tuple(t)?); + } + if out.is_empty() { + return Err(format!("{SOURCE}: UI_COMMANDS is empty")); + } + Ok(out) +} + +fn find_matching_bracket(src: &str, open_idx: usize) -> Option { + let bytes = src.as_bytes(); + if open_idx >= bytes.len() || bytes[open_idx] != b'[' { + return None; + } + let mut depth = 0usize; + let mut in_str = false; + let mut esc = false; + let mut i = open_idx; + while i < bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + i += 1; + continue; + } + match c { + '"' => in_str = true, + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + i += 1; + } + None +} + +fn split_top_level_tuples(body: &str) -> Result, String> { + let mut out = Vec::new(); + let bytes = body.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + while i < bytes.len() && ((bytes[i] as char).is_whitespace() || bytes[i] == b',') { + i += 1; + } + if i >= bytes.len() { + break; + } + if bytes[i] != b'(' { + return Err(format!("{SOURCE}: expected `(` in UI_COMMANDS, found {:?}", body[i..].chars().take(20).collect::())); + } + let start = i; + let mut depth = 0usize; + let mut in_str = false; + let mut esc = false; + while i < bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + i += 1; + continue; + } + match c { + '"' => in_str = true, + '(' => depth += 1, + ')' => { + depth -= 1; + i += 1; + if depth == 0 { + out.push(&body[start..i]); + break; + } + continue; + } + _ => {} + } + i += 1; + } + if depth != 0 { + return Err(format!("{SOURCE}: unclosed tuple in UI_COMMANDS")); + } + } + Ok(out) +} + +fn parse_tuple(tup: &str) -> Result { + let inner = tup.strip_prefix('(').and_then(|s| s.strip_suffix(')')).ok_or_else(|| format!("{SOURCE}: bad tuple {tup:?}"))?; + let fields = split_fields(inner)?; + if fields.len() != 4 { + return Err(format!("{SOURCE}: tuple has {} fields, want 4: {tup}", fields.len())); + } + let id = parse_id(fields[0].trim())?; + let label = parse_string_lit(fields[1].trim())?; + let shortcut = parse_string_lit(fields[2].trim())?; + let params = parse_string_lit(fields[3].trim())?; + Ok(Entry { id, label, shortcut, params }) +} + +fn split_fields(inner: &str) -> Result, String> { + let mut out = Vec::new(); + let bytes = inner.as_bytes(); + let mut start = 0usize; + let mut i = 0usize; + let mut depth = 0isize; + let mut in_str = false; + let mut esc = false; + while i < bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + i += 1; + continue; + } + match c { + '"' => in_str = true, + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + ',' if depth == 0 => { + out.push(inner[start..i].trim()); + start = i + 1; + } + _ => {} + } + i += 1; + } + let last = inner[start..].trim(); + if !last.is_empty() { + out.push(last); + } + Ok(out) +} + +fn parse_id(field: &str) -> Result { + if field.starts_with('"') { + return Ok(IdExpr::Lit(parse_string_lit(field)?)); + } + if field.starts_with("crate::") { + return Ok(IdExpr::Path(field.to_string())); + } + Err(format!("{SOURCE}: unsupported id expression `{field}`")) +} + +fn parse_string_lit(field: &str) -> Result { + let s = field.trim(); + if !s.starts_with('"') { + return Err(format!("{SOURCE}: expected string literal, got `{s}`")); + } + let mut out = String::new(); + let mut chars = s[1..].chars(); + while let Some(c) = chars.next() { + if c == '\\' { + let Some(n) = chars.next() else { + return Err(format!("{SOURCE}: trailing escape in {s}")); + }; + match n { + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + '\\' | '"' | '\'' => out.push(n), + other => return Err(format!("{SOURCE}: unknown escape `\\{other}` in {s}")), + } + } else if c == '"' { + return Ok(out); + } else { + out.push(c); + } + } + Err(format!("{SOURCE}: unclosed string {s}")) +} + +fn resolve_entries(root: &Path, entries: &[Entry]) -> Result, String> { + let mut out = Vec::with_capacity(entries.len()); + for e in entries { + let id = match &e.id { + IdExpr::Lit(s) => s.clone(), + IdExpr::Path(path) => resolve_const_path(root, path)?, + }; + out.push((id, e.label.clone(), e.shortcut.clone(), e.params.clone())); + } + Ok(out) +} + +fn resolve_const_path(root: &Path, path: &str) -> Result { + let trimmed = path.strip_prefix("crate::").ok_or_else(|| format!("{SOURCE}: path `{path}` does not start with crate::"))?; + let mut parts: Vec<&str> = trimmed.split("::").collect(); + let const_name = parts.pop().ok_or_else(|| format!("{SOURCE}: empty path `{path}`"))?; + resolve_in_module(root, &parts, const_name, path) +} + +fn resolve_in_module(root: &Path, module_parts: &[&str], const_name: &str, original: &str) -> Result { + let file = module_file(root, module_parts).ok_or_else(|| format!("{SOURCE}: no file for `{original}`"))?; + let src = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; + if let Some(v) = find_str_const(&src, const_name) { + return Ok(v); + } + // Follow `pub use path::{..., CONST, ...}` / `pub use path::CONST`. + for target in reexport_targets(&src, const_name) { + let mut next = module_parts.to_vec(); + if target.first() == Some(&"super") { + next.pop(); + next.extend(target.iter().skip(1).copied()); + } else if target.first() == Some(&"crate") { + next = target.iter().skip(1).copied().collect(); + } else { + next.extend(target); + } + if let Ok(v) = resolve_in_module(root, &next, const_name, original) { + return Ok(v); + } + } + Err(format!("{SOURCE}: could not resolve `{original}` (`pub const {const_name}: &str` missing under {})", file.display())) +} + +fn module_file(root: &Path, parts: &[&str]) -> Option { + let base = root.join(UI_EGUI_SRC); + if parts.is_empty() { + return Some(base.join("lib.rs")); + } + let mut dir = base.clone(); + for (i, p) in parts.iter().enumerate() { + let last = i + 1 == parts.len(); + if last { + let rs = dir.join(format!("{p}.rs")); + if rs.is_file() { + return Some(rs); + } + let mod_rs = dir.join(p).join("mod.rs"); + if mod_rs.is_file() { + return Some(mod_rs); + } + return None; + } + dir = dir.join(p); + } + None +} + +fn find_str_const(src: &str, name: &str) -> Option { + // `pub const NAME: &str = "...";` (allow whitespace) + let needle = format!("pub const {name}:"); + let mut search = src; + while let Some(idx) = search.find(&needle) { + let after = &search[idx + needle.len()..]; + let after = after.trim_start(); + if !after.starts_with("&str") { + search = &search[idx + needle.len()..]; + continue; + } + let after = after["&str".len()..].trim_start(); + if !after.starts_with('=') { + search = &search[idx + needle.len()..]; + continue; + } + let after = after[1..].trim_start(); + return parse_string_lit(after).ok(); + } + None +} + +fn reexport_targets<'a>(src: &'a str, const_name: &str) -> Vec> { + let mut out = Vec::new(); + for line in src.lines() { + let t = line.trim(); + if !t.starts_with("pub use ") { + continue; + } + let rest = t.trim_start_matches("pub use ").trim_end_matches(';').trim(); + if let Some((path, braced)) = rest.split_once("::{") { + let names = braced.trim_end_matches('}'); + let hit = names.split(',').map(str::trim).any(|n| n == const_name); + if hit { + out.push(path.split("::").collect()); + } + } else if let Some((path, name)) = rest.rsplit_once("::") + && name.trim() == const_name + { + out.push(path.split("::").collect()); + } + } + out +} + +fn render_mirror(rows: &[(String, String, String, String)]) -> String { + let mut out = String::new(); + out.push_str("//! Desktop UI command catalogue for headless MCP discovery (`ui: true`).\n"); + out.push_str("//! Generated from `crates/ui-egui/src/menus/ui_commands.rs` by `cargo xtask ui-commands`.\n"); + out.push_str("//! Do not edit by hand; re-run the xtask when that table changes.\n"); + out.push('\n'); + out.push_str("/// `(id, label, shortcut, params doc)` — same shape as the desktop `UI_COMMANDS` table.\n"); + out.push_str("pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[\n"); + for (id, label, shortcut, params) in rows { + out.push_str(" ("); + out.push_str(&rust_string_lit(id)); + out.push_str(", "); + out.push_str(&rust_string_lit(label)); + out.push_str(", "); + out.push_str(&rust_string_lit(shortcut)); + out.push_str(", "); + out.push_str(&rust_string_lit(params)); + out.push_str("),\n"); + } + out.push_str("];\n"); + out +} + +fn rust_string_lit(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_literal_and_path_ids() { + let src = r#" +pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ( + crate::addons::arrange::ZOOM, + "Zoom to Selection", + "", + "{importer: \"plugin.\"}", + ), + ("app.language", "Interface Language", "Cmd+L", "{}"), +]; +"#; + let entries = parse_ui_commands(src).unwrap(); + assert_eq!(entries.len(), 2); + assert!(matches!(entries[0].id, IdExpr::Path(_))); + assert_eq!(entries[0].params, "{importer: \"plugin.\"}"); + assert_eq!(entries[1].id, IdExpr::Lit("app.language".into())); + } + + #[test] + fn escape_round_trip_no_extra_backslash() { + let s = "{importer: \"plugin.\"}"; + let lit = rust_string_lit(s); + assert_eq!(lit, r#""{importer: \"plugin.\"}""#); + assert!(!lit.contains(r#"\\\""#)); + } + + #[test] + fn find_const_in_source() { + let src = r#"pub const ZOOM: &str = "addon.arrange.zoomSelection";"#; + assert_eq!(find_str_const(src, "ZOOM").as_deref(), Some("addon.arrange.zoomSelection")); + } +}