diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 400a81944..058705ce2 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -13,27 +13,27 @@ on: workflow_dispatch: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 +# A newer push to a PR branch cancels its older run. +concurrency: + group: print-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Desktop printing on Windows, through the suite's shared gate. Pull requests restore the cache + # but never save one; a manual run on main saves the cache they restore. windows: - runs-on: windows-2022 - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master - with: - toolchain: stable - components: clippy - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: desktop-print - - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings - - run: cargo test --locked -p vectorcraft - - name: Check pending submission and queue refusal feedback - shell: bash - # A filter that matches nothing still passes, so fail unless some test ran. - run: | - cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee printps.log - grep -Eq 'test result: ok\. [1-9][0-9]* passed' printps.log || { echo "::error::tests::printps matched no tests"; exit 1; } + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + os: '["windows-2022"]' + timeout-minutes: 90 + # As before: printing builds and tests without craft-fonts, nextest or the wasm target. + craft-fonts-ref: "" + tools: "" + targets: "" + # A filter that matches nothing still passes, so the printps run fails unless some test ran. + command: | + cargo clippy --locked -p vectorcraft --all-targets -- -D warnings + cargo test --locked -p vectorcraft + log="$RUNNER_TEMP/printps.log" + cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee "$log" + grep -Eq 'test result: ok\. [1-9][0-9]* passed' "$log" || { echo "::error::tests::printps matched no tests"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1677e538..07b3c6b7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,7 @@ jobs: needs: git-sources permissions: contents: write + packages: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/ASSETS.md b/ASSETS.md index 5bebefae6..3233c99d0 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -345,6 +345,9 @@ Generated-in-code art is original and has no file to list. This covers the defau | `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | | | `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | | | `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | | +| `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` | +| `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` | +| `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` | ## Assets embedded by the PDF backend diff --git a/Cargo.lock b/Cargo.lock index 62f084afc..248679aac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,6 +705,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -1940,6 +1951,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl 0.1.12", +] + [[package]] name = "gif" version = "0.14.2" @@ -2233,7 +2254,7 @@ dependencies = [ "rustc-hash 2.1.3", "smallvec", "stringprep", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -2609,14 +2630,14 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -2837,7 +2858,7 @@ dependencies = [ "bumpalo", "flate2", "float-cmp", - "gif", + "gif 0.14.2", "image-webp", "imagesize 0.14.0", "indexmap", @@ -2852,7 +2873,7 @@ dependencies = [ "tiny-skia-path 0.12.0", "xmp-writer", "yoke", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -4380,12 +4401,15 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif 0.13.3", + "image-webp", "log", "pico-args", "rgb", "svgtypes 0.15.3", "tiny-skia", "usvg 0.45.1", + "zune-jpeg 0.4.21", ] [[package]] @@ -5490,7 +5514,7 @@ dependencies = [ "half", "quick-error 2.0.1", "weezl 0.1.12", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -6065,6 +6089,7 @@ dependencies = [ "smithay-clipboard", "socket2", "vectorcraft-engine", + "vectorcraft-render", "vectorcraft-text", "vectorcraft-ui-egui", "wayland-backend", @@ -6168,7 +6193,7 @@ dependencies = [ "tiff", "vectorcraft-color", "vectorcraft-geom", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -6191,9 +6216,11 @@ dependencies = [ name = "vectorcraft-engine" version = "0.19.0" dependencies = [ + "cfb", "craft-docpath", "crc32fast", "flate2", + "hayro-jpeg2000", "image", "kurbo 0.13.1", "log", @@ -6373,7 +6400,7 @@ name = "vectorcraft-render" version = "0.19.0" dependencies = [ "flate2", - "gif", + "gif 0.14.2", "image", "jpeg-encoder", "log", @@ -6382,6 +6409,7 @@ dependencies = [ "psd", "serde_json", "tiff", + "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", @@ -6484,6 +6512,7 @@ dependencies = [ "kurbo 0.13.1", "serde", "serde_json", + "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", @@ -7948,19 +7977,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/README.md b/README.md index fd45eb9cd..0b7b9932b 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to * the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). -On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's -pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse and the app reads its -pressure: set the same preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use -XWayland only when a pen is attached as VectorCraft starts, and restart; or start the app with +On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft can't get a drawing tablet's pen +input on native Wayland yet (#491, #2028). When a pen is attached as VectorCraft starts, the window opens through +XWayland, where the pen works as a mouse and the app reads its pressure (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps +Wayland). For a pen attached later, set the same preference to **X11 (XWayland)** and restart, or start the app with `WAYLAND_DISPLAY= vectorcraft` (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). ### Use it from Claude Code and other agents diff --git a/ROADMAP.md b/ROADMAP.md index e7d8f0bba..4d5918cba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ **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-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Color Effects add-on's Find Nearest Swatch; headline numbers 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 @@ -129,6 +129,16 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). +- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). +- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). +- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917). +- 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged. +- 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged. +- 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged. +- 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged. +- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. +- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. @@ -202,6 +212,16 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Document Tools add-on's Save a Timestamped Copy (#4926); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for Divide into Dashes (Path Precision Tools add-on, #4950); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for Group Overlapping Objects (Arrange Tools add-on, #4928); headline numbers unchanged. | +| 2026-10-31 | minor | Progress log row for Export for Screens laid out as the reference app's (#4895) and the 2026-10-31 community landings; headline numbers unchanged. | +| 2026-10-31 | minor | Progress log row for the Arrange Tools add-on; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux Flatpak clipboard's core Wayland protocol text fallback; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index cb9183315..8f39e3b58 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -34,8 +34,9 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel", # DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs, # shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR -# Apache-2.0. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } +# Apache-2.0. Win32_System_Threading has `GetProcessAffinityMask`, which the desktop app's +# src/windows_cores.rs, shared, uses to count the cores the process may run on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading"] } # CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font # managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bba1976c1..b7334586c 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -49,6 +49,10 @@ mod prefs_dir; #[cfg(windows)] #[path = "../../vectorcraft/src/system_fonts.rs"] mod system_fonts; +/// The desktop app's affinity-mask reader, shared: renders start workers only for usable cores. +#[cfg(windows)] +#[path = "../../vectorcraft/src/windows_cores.rs"] +mod windows_cores; use serde_json::{Value, json}; use vectorcraft_engine::file_access::{self, AutomationRoots}; @@ -161,6 +165,9 @@ fn help() -> Result<(), String> { } fn main() -> ExitCode { + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before any font lookup: the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 0657c93c8..47d26eb7b 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -54,7 +54,12 @@ pollster = "1" # Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to # read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the +# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which +# src/caps_lock.rs uses to read whether Caps Lock is on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# The renderer's worker count follows those cores (src/windows_cores.rs). +vectorcraft-render = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK @@ -86,8 +91,8 @@ pollster = "1" winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } # A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a # second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build -# through winit with the `xinput` feature. -x11rb = { version = "0.13", default-features = false, features = ["xinput"] } +# through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs). +x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one. Both are already in the build through winit. diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 05c756a0c..423fb53fc 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,6 +1,8 @@ #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] use super::gpu; use super::prefs::{save_prefs, save_prefs_soon}; +#[cfg(not(target_os = "macos"))] +use super::single_instance; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; @@ -66,6 +68,9 @@ impl eframe::App for App { // macOS converts the frame from sRGB to the display's profile (#2249). #[cfg(all(target_os = "macos", feature = "wgpu"))] super::mac_color_space::tag(ui); + if let Some(on) = self.host_caps_lock.read_transition() { + self.app.set_caps_lock(on); + } file_access::unconfined(|| self.app.ui(ui)); // Preferences are written when confirmed, not only at exit: a session ended any other way // (the system shutting down, the process ended or crashing) used to lose them (#1747). @@ -105,11 +110,13 @@ impl App { vectorcraft_ui_egui::background::wait_all(&mut self.app); return; } - // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs - // again on its document when it answers. - #[cfg(target_os = "macos")] + // Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file + // dialog is open: what asked for it runs again on its document when it answers. if !self.app.file_dialog_open() { + #[cfg(target_os = "macos")] let files = open_documents::take(); + #[cfg(not(target_os = "macos"))] + let files = single_instance::take(); if !files.is_empty() { bring_window_forward(ctx); } @@ -147,15 +154,15 @@ fn end_before_teardown(frames: u64) { } } -/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back -/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769). -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +/// Show the window when files are opened from the Finder or the Dock, or handed over by a later +/// launch (#4892): a minimized window comes back and the app takes focus, as a document-based app +/// does (#3769). pub(crate) fn bring_window_forward(ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } -/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that +/// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec) { for f in files { diff --git a/apps/vectorcraft/src/caps_lock.rs b/apps/vectorcraft/src/caps_lock.rs new file mode 100644 index 000000000..231a37e03 --- /dev/null +++ b/apps/vectorcraft/src/caps_lock.rs @@ -0,0 +1,216 @@ +//! Read the host's Caps Lock state, which gives the drawing tools the precise crosshair while it is on. + +/// Only host transitions replace a value set by a script through `view.capsLock`. +#[derive(Default)] +pub struct HostCapsLock { + previous: Option, + #[cfg(target_os = "linux")] + x11: Option, +} + +#[cfg(target_os = "linux")] +struct X11CapsLock { + connection: x11rb::rust_connection::RustConnection, + locked: bool, + initial_sequence: u16, +} + +impl HostCapsLock { + pub fn new(window: Option<&winit::window::Window>) -> Self { + #[cfg(target_os = "linux")] + let x11 = window.and_then(x11_connection); + #[cfg(not(target_os = "linux"))] + let _ = window; + Self { + previous: None, + #[cfg(target_os = "linux")] + x11, + } + } + + pub fn read_transition(&mut self) -> Option { + #[cfg(target_os = "linux")] + let current = match self.x11.as_mut() { + Some(state) => match state.poll() { + Some(locked) => Some(locked), + None => { + self.x11 = None; + None + } + }, + None => { + // Native Wayland does not expose the seat's XKB lock bit through winit or egui. + None + } + }; + #[cfg(any(target_os = "macos", windows))] + let current = Some(read()); + // This platform has no lock-state reader. + #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] + let current = None; + self.transition(current) + } + + fn transition(&mut self, reading: Option) -> Option { + reading.and_then(|current| self.observe(current)) + } + + fn observe(&mut self, current: bool) -> Option { + if self.previous.replace(current) == Some(current) { None } else { Some(current) } + } +} + +#[cfg(target_os = "linux")] +fn x11_connection(window: &winit::window::Window) -> Option { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + use x11rb::protocol::xkb; + if !matches!(window.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) { + return None; + } + let (connection, _) = x11rb::connect(None).ok()?; + let supported = x11rb::protocol::xkb::use_extension(&connection, 1, 0).ok()?.reply().ok()?.supported; + if !supported { + return None; + } + // Subscribe before the initial read so changes during setup remain queued. + xkb::select_events( + &connection, + xkb::ID::USE_CORE_KBD.into(), + xkb::EventType::default(), + xkb::EventType::default(), + xkb::MapPart::default(), + xkb::MapPart::default(), + &xkb::SelectEventsAux { + state_notify: Some(xkb::SelectEventsAuxStateNotify { + affect_state: xkb::StatePart::MODIFIER_LOCK, + state_details: xkb::StatePart::MODIFIER_LOCK, + }), + ..Default::default() + }, + ) + .ok()? + .check() + .ok()?; + let (locked, initial_sequence) = read_x11(&connection)?; + Some(X11CapsLock { connection, locked, initial_sequence }) +} + +#[cfg(target_os = "linux")] +impl X11CapsLock { + fn poll(&mut self) -> Option { + use x11rb::connection::Connection as _; + use x11rb::protocol::Event; + while let Some(event) = self.connection.poll_for_event().ok()? { + if let Event::XkbStateNotify(state) = event + // Events queued before the initial GetState reply must not replace that newer snapshot. + && state.sequence.wrapping_sub(self.initial_sequence) < 0x8000 + { + self.locked = lock_modifier_is_set(state.locked_mods); + } + } + Some(self.locked) + } +} + +#[cfg(target_os = "linux")] +fn read_x11(connection: &x11rb::rust_connection::RustConnection) -> Option<(bool, u16)> { + use x11rb::protocol::xkb; + let state = xkb::get_state(connection, xkb::ID::USE_CORE_KBD.into()).ok()?.reply().ok()?; + Some((lock_modifier_is_set(state.locked_mods), state.sequence)) +} + +#[cfg(target_os = "linux")] +fn lock_modifier_is_set(modifiers: x11rb::protocol::xproto::ModMask) -> bool { + use x11rb::protocol::xproto::ModMask; + u16::from(modifiers) & u16::from(ModMask::LOCK) != 0 +} + +#[cfg(target_os = "macos")] +const MAC_CAPS_LOCK: usize = 1 << 16; + +#[cfg(target_os = "macos")] +fn mac_flags_have_caps_lock(flags: usize) -> bool { + flags & MAC_CAPS_LOCK != 0 +} + +#[cfg(target_os = "macos")] +pub fn read() -> bool { + mac_flags_have_caps_lock(objc2_app_kit::NSEvent::modifierFlags_class().bits()) +} + +#[cfg(windows)] +fn windows_state_has_caps_lock(state: i16) -> bool { + state & 1 != 0 +} + +#[cfg(windows)] +#[allow(unsafe_code)] +pub fn read() -> bool { + // GetKeyState reports the toggle bit for the calling UI thread's keyboard state. + use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, VK_CAPITAL}; + // SAFETY: `GetKeyState` takes a virtual-key code by value and reads no caller memory. + windows_state_has_caps_lock(unsafe { GetKeyState(i32::from(VK_CAPITAL.0)) }) +} + +// Querying XWayland for a native Wayland window would read a different keyboard. + +#[cfg(test)] +mod tests { + #[test] + fn no_host_reader_preserves_scripted_value_and_next_reading_transitions() { + let mut host = super::HostCapsLock::default(); + let mut session = vectorcraft_engine::Session::new(); + session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap(); + if let Some(on) = host.transition(None) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + assert_eq!(host.transition(Some(false)), Some(false)); + } + + #[cfg(target_os = "linux")] + #[test] + fn no_x11_reader_has_no_transition() { + assert_eq!(super::HostCapsLock::default().read_transition(), None); + } + + #[test] + fn scripted_value_survives_unchanged_host_until_transition() { + let mut host = super::HostCapsLock::default(); + let mut session = vectorcraft_engine::Session::new(); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap(); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + session.execute("view.capsLock", &serde_json::json!({"on": false})).unwrap(); + if let Some(on) = host.observe(true) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(!session.caps_lock); + } + + #[cfg(target_os = "macos")] + #[test] + fn mac_modifier_flags_map_caps_lock() { + assert!(!super::mac_flags_have_caps_lock(0)); + assert!(!super::mac_flags_have_caps_lock(1 << 17)); + assert!(super::mac_flags_have_caps_lock(1 << 16)); + assert!(super::mac_flags_have_caps_lock((1 << 16) | (1 << 17))); + } + + #[cfg(windows)] + #[test] + fn windows_key_state_maps_toggle_bit() { + assert!(!super::windows_state_has_caps_lock(0)); + assert!(!super::windows_state_has_caps_lock(i16::MIN)); + assert!(super::windows_state_has_caps_lock(1)); + } +} diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 431d40346..00f92405e 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -182,11 +182,14 @@ fn any_hardware(kinds: &[DeviceType]) -> bool { kinds.iter().any(|k| *k != DeviceType::Cpu) } -/// The largest texture side to ask the device for, which bounds the window's surface: the -/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than -/// that keeps its surface (#1459). Never below `default`, which is what was asked for before. -pub fn surface_limit(default: u32, adapter: u32) -> u32 { - default.max(adapter) +/// The limits to ask the device for: what eframe `asked` for, lowered to what the `adapter` offers +/// wherever it offers less, so a GPU short of one of them still gets a device (#2105). The texture +/// side, which bounds the window's surface, is the adapter's own, above eframe's 8192 px when the +/// adapter allows more, so a window larger than that keeps its surface (#1459). +pub fn device_limits(asked: wgpu::Limits, adapter: &wgpu::Limits) -> wgpu::Limits { + let mut limits = asked.or_worse_values_from(adapter); + limits.max_texture_dimension_2d = adapter.max_texture_dimension_2d; + limits } /// One adapter, as [`adapter_order`] sees it. diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 3d13b5157..b1511a232 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -5,11 +5,34 @@ fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { } /// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that -/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing. +/// takes 16384 px textures. #[test] fn the_surface_may_be_as_large_as_the_adapter_allows() { - assert_eq!(surface_limit(8192, 16384), 16384); - assert_eq!(surface_limit(8192, 4096), 8192); + let adapter = wgpu::Limits { max_texture_dimension_2d: 16384, ..wgpu::Limits::default() }; + let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + assert_eq!(device_limits(asked, &adapter).max_texture_dimension_2d, 16384); +} + +/// #2105: a GPU that offers less than eframe asks for on any limit (here a Raspberry Pi 5's V3D 7.1: +/// 15 inter-stage variables on Vulkan, 4096 px textures on OpenGL) gets a device with what it +/// offers, instead of failing and leaving the window to the software rasterizer. A GPU that offers +/// more is asked for no more than before, except for the texture side. +#[test] +fn the_device_asks_for_no_more_than_the_adapter_offers() { + let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + let vulkan = wgpu::Limits { max_inter_stage_shader_variables: 15, max_texture_dimension_2d: 7680, ..wgpu::Limits::default() }; + let limits = device_limits(asked.clone(), &vulkan); + assert_eq!((limits.max_inter_stage_shader_variables, limits.max_texture_dimension_2d), (15, 7680)); + assert!(limits.check_limits(&vulkan), "{limits:?}"); + let gl = wgpu::Limits { max_texture_dimension_2d: 4096, ..wgpu::Limits::downlevel_webgl2_defaults() }; + let webgl2 = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::downlevel_webgl2_defaults() }; + let limits = device_limits(webgl2.clone(), &gl); + assert_eq!(limits.max_texture_dimension_2d, 4096); + assert!(limits.check_limits(&gl), "{limits:?}"); + let ample = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + assert_eq!(device_limits(asked.clone(), &le), asked); + let better = wgpu::Limits { max_inter_stage_shader_variables: 31, max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + assert_eq!(device_limits(asked.clone(), &better), asked, "never more than eframe asked for"); } fn names(c: &[Candidate], order: &[usize]) -> Vec { diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index e90977726..980d3826b 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,7 +1,10 @@ //! VectorCraft desktop app. //! //! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...] -//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! +//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given +//! (Windows and Linux; `single_instance`). //! //! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on //! macOS instead of the macOS menu bar (`mac_menu`). @@ -16,6 +19,7 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] mod automation_args; +mod caps_lock; mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu; @@ -45,11 +49,15 @@ mod open_documents; mod portal_save; mod prefs_dir; mod printing; +#[cfg(not(target_os = "macos"))] +mod single_instance; #[cfg(windows)] mod system_fonts; mod tablet; mod window; mod window_frame; +#[cfg(windows)] +mod windows_cores; mod windows_stylus; mod xwayland; @@ -87,6 +95,7 @@ use titlebar::{custom_titlebar, native_options}; struct App { app: VectorcraftApp, + host_caps_lock: caps_lock::HostCapsLock, /// Reported by wgpu when the window's graphics device is lost (a driver reset). graphics_loss: GraphicsLoss, /// The graphics device was lost and the unsaved changes are kept for Data Recovery. @@ -118,7 +127,9 @@ Options: (may be given more than once) --in-window-menus Keep the menus inside the window on macOS (or VECTORCRAFT_IN_WINDOW_MENUS=1) - --version Print the version and exit + --new-instance Open the files in a new window even when VectorCraft + already runs (Windows and Linux) + --version Print the version and exit -h, --help Print this help and exit Without a window (converting, scripting, the MCP server), use vectorcraft-cli: @@ -142,6 +153,7 @@ fn main() -> std::process::ExitCode { automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT")); let mut files = Vec::new(); let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); + let mut new_instance = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { @@ -154,6 +166,7 @@ fn main() -> std::process::ExitCode { } match a.as_str() { "--in-window-menus" => in_window_menus = true, + "--new-instance" => new_instance = true, "--version" => { println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); return std::process::ExitCode::SUCCESS; @@ -183,7 +196,19 @@ fn main() -> std::process::ExitCode { }; // Before desktop services, so every start-up warning is recorded (`logging`). let logger = logging::install(); + // Files opened from the file manager while VectorCraft runs open in its window (#4892). Before + // the log file is attached: a launch that hands its files over leaves the running app's log be. + #[cfg(not(target_os = "macos"))] + let instance = match single_instance::start(control_port.is_some(), new_instance, &files) { + single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS, + single_instance::Start::Running(primary) => primary, + }; + #[cfg(target_os = "macos")] + let _ = new_instance; vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before the first font scan (the app's start): the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); @@ -265,15 +290,15 @@ fn main() -> std::process::ExitCode { let options = native_options(custom_titlebar); // winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux // Display Server › X11 opens the window through XWayland instead, where file drops work (#1151), - // and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028). + // and the other choices do so when a pen is attached, where the pen draws (#2028, #4967). #[cfg(target_os = "linux")] let options = { let mut options = options; let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) { if xwayland::x11_libraries_found() { - if pref == Some("x11WithPen") { - log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)"); + if pref != Some("x11") { + log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)"); } else { log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)"); } @@ -321,14 +346,15 @@ fn main() -> std::process::ExitCode { // losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another. create.instance_descriptor.backend_options.dx12.shader_compiler = eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc); - // The window's surface may be as large as the adapter allows, not eframe's fixed - // 8192 px: a window stretched over several monitors, or grown by a display scale - // change, otherwise fails to configure its surface and the app closes (#1459). + // The device asks for no more than the adapter offers, so a GPU short of one of + // eframe's limits still gets a device (#2105). The window's surface may be as large as + // the adapter allows, not eframe's fixed 8192 px: a window stretched over several + // monitors, or grown by a display scale change, otherwise fails to configure its + // surface and the app closes (#1459). let device = create.device_descriptor.clone(); create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| { let mut descriptor = device(adapter); - let limit = &mut descriptor.required_limits.max_texture_dimension_2d; - *limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d); + descriptor.required_limits = gpu::device_limits(descriptor.required_limits, &adapter.limits()); descriptor }); } @@ -452,7 +478,10 @@ fn main() -> std::process::ExitCode { } } #[cfg(not(target_os = "macos"))] - let _ = in_window_menus; + { + let _ = in_window_menus; + single_instance::set_ui(&cc.egui_ctx); + } app.services.native_input_hook = native_input_hook(&app); file_access::unconfined(|| open_files(&mut app, files)); // The first frame is due from now (`gpu::watch_first_frame`). @@ -461,6 +490,7 @@ fn main() -> std::process::ExitCode { // The content and the pointer stay aligned on Windows (#1758). let window_frame = window_frame::service(cc, custom_titlebar); Ok(Box::new(App { + host_caps_lock: caps_lock::HostCapsLock::new(winit_window.as_deref()), app, graphics_loss, graphics_lost: false, @@ -473,6 +503,10 @@ fn main() -> std::process::ExitCode { }), ) }); + // The window is gone: later launches start their own app, including the one a graphics + // failure starts again below. + #[cfg(not(target_os = "macos"))] + drop(instance); #[cfg(feature = "wgpu")] let outcome = gpu::finish(outcome, &startup); #[cfg(not(feature = "wgpu"))] diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs new file mode 100644 index 000000000..b8df2bb5a --- /dev/null +++ b/apps/vectorcraft/src/single_instance.rs @@ -0,0 +1,386 @@ +//! One app per user (#4892): a file opened from the file manager (double-click, Open With, a drop +//! on the executable) while VectorCraft runs opens as a new document tab in the running app, which +//! comes to the front, instead of in a second app. +//! +//! The running app (the primary) listens on a loopback port and records the port and two random +//! tokens in `instance.json` next to the preferences (owner-only on Unix; on Windows the settings +//! folder is the user's own). A later launch with files connects; the primary greets it with +//! `hello`, which only a process that could read the file (or talk to the primary) knows; only then +//! does the launch send `token` and the absolute paths of its files, one JSON line. The primary +//! checks the token, keeps the paths that are existing files, acknowledges and opens them; the +//! launch exits. Anything else (no file, no listener, a wrong greeting, no answer) starts the launch +//! as its own app, which replaces a stale file. Nothing received is ever run. +//! +//! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward +//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files +//! opened from Finder to the running app itself (`open_documents`), so this is off there. + +use std::fs::{self, OpenOptions}; +use std::hash::{BuildHasher as _, RandomState}; +use std::io::{self, BufRead as _, BufReader, Read as _, Write as _}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::prefs::{prefs_enabled, prefs_path}; + +/// The endpoint file, next to the preferences. +const FILE_NAME: &str = "instance.json"; +/// The longest message either side reads, and the longest endpoint file. +const MAX_MESSAGE: u64 = 64 * 1024; +/// The most files one launch hands over. +const MAX_FILES: usize = 256; +/// A live primary's kernel accepts at once, even while its window is busy; a stale port on Windows +/// would otherwise retry for about two seconds. +const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); +/// How long the primary waits on one launch; it serves launches one at a time. +const SERVER_TIMEOUT: Duration = Duration::from_secs(2); +/// How long a launch waits on the primary: longer than a launch served before it can take. +const CLIENT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Paths handed over by later launches that the UI hasn't opened yet. +static PENDING: Mutex> = Mutex::new(Vec::new()); +/// The UI to wake when files arrive (unset until the window exists; the first frame opens them). +static UI: OnceLock = OnceLock::new(); + +/// What this launch does about an app already running. +pub enum Start { + /// The running app took the files: this launch exits. + Forwarded, + /// This launch runs; `Some` while it is the app later launches hand their files to. + Running(Option), +} + +/// Hand `files` to the app already running, or become the app later launches hand theirs to. +/// `automation`: started with `--control`. +pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start { + if automation || !prefs_enabled() { + return Start::Running(None); + } + let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) }; + // With nothing to hand over, the same exchange only asks whether an app is running. + let send: &[String] = if new_instance { &[] } else { files }; + match forward(&path, send) { + Ok(()) if send.is_empty() => Start::Running(None), + Ok(()) => Start::Forwarded, + Err(e) => { + log::debug!("no running VectorCraft to hand files to ({e})"); + Start::Running(listen(&path, queue)) + } + } +} + +/// Wake `ctx` when files arrive from later launches. +pub fn set_ui(ctx: &egui::Context) { + // Set once; a second call would hand over the same context. + let _ = UI.set(ctx.clone()); +} + +/// The paths later launches handed over since the last call. +pub fn take() -> Vec { + std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)) +} + +fn queue(files: Vec) { + PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files); + if let Some(ctx) = UI.get() { + ctx.request_repaint(); + } +} + +/// This app is the one recorded in the endpoint file; dropping it removes the file, unless a later +/// app has replaced it since. +pub struct Primary { + path: PathBuf, + token: String, +} + +impl Drop for Primary { + fn drop(&mut self) { + if read_endpoint(&self.path).is_some_and(|e| e.token == self.token) { + // Best effort: a file left behind is stale, and the next launch replaces it. + let _ = fs::remove_file(&self.path); + } + } +} + +struct Endpoint { + port: u16, + token: String, + hello: String, +} + +fn invalid(why: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, why) +} + +fn read_endpoint(path: &Path) -> Option { + let mut text = String::new(); + fs::File::open(path).ok()?.take(MAX_MESSAGE).read_to_string(&mut text).ok()?; + let v: Value = serde_json::from_str(&text).ok()?; + let port = u16::try_from(v.get("port")?.as_u64()?).ok().filter(|p| *p != 0)?; + Some(Endpoint { port, token: v.get("token")?.as_str()?.to_owned(), hello: v.get("hello")?.as_str()?.to_owned() }) +} + +/// Hand `files` (made absolute against this launch's folder) to the app recorded at `path`; `Ok` +/// once it acknowledged them. +fn forward(path: &Path, files: &[String]) -> io::Result<()> { + let endpoint = read_endpoint(path).ok_or_else(|| invalid("no endpoint file"))?; + let files = files.iter().map(|f| std::path::absolute(f).map(|p| p.to_string_lossy().into_owned())).collect::>>()?; + let stream = TcpStream::connect_timeout(&(Ipv4Addr::LOCALHOST, endpoint.port).into(), CONNECT_TIMEOUT)?; + stream.set_read_timeout(Some(CLIENT_TIMEOUT))?; + stream.set_write_timeout(Some(CLIENT_TIMEOUT))?; + // The token and the paths go only to a listener that knows the greeting. + if !same(read_message(&stream)?.get("hello"), &endpoint.hello) { + return Err(invalid("the listener isn't VectorCraft")); + } + write_message(&stream, &json!({ "token": endpoint.token, "files": files }))?; + match read_message(&stream)?.get("ok") { + Some(Value::Bool(true)) => Ok(()), + _ => Err(invalid("refused")), + } +} + +/// Listen for later launches and record this app at `path`; each launch's files go to `deliver`. +/// `None` when that fails (logged): the app then runs on its own, as before. +fn listen(path: &Path, deliver: impl Fn(Vec) + Send + 'static) -> Option { + try_listen(path, deliver).inspect_err(|e| log::warn!("files opened while VectorCraft runs will open in another window: {e}")).ok() +} + +fn try_listen(path: &Path, deliver: impl Fn(Vec) + Send + 'static) -> io::Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let (token, hello) = random_tokens(); + write_endpoint(path, &json!({ "port": port, "token": token, "hello": hello }))?; + // From here on a failure removes the file again. + let primary = Primary { path: path.to_path_buf(), token: token.clone() }; + std::thread::Builder::new().name("open-files-listener".into()).spawn(move || { + for stream in listener.incoming() { + match stream.and_then(|s| receive(&s, &hello, &token)) { + Ok(files) if files.is_empty() => {} + Ok(files) => deliver(files), + Err(e) => { + log::warn!("refused files from another launch: {e}"); + // An accept that keeps failing (out of handles) doesn't spin. + std::thread::sleep(Duration::from_millis(50)); + } + } + } + })?; + Ok(primary) +} + +/// Serve one launch: greet it, check its token, keep the paths that are existing files. +fn receive(stream: &TcpStream, hello: &str, token: &str) -> io::Result> { + stream.set_read_timeout(Some(SERVER_TIMEOUT))?; + stream.set_write_timeout(Some(SERVER_TIMEOUT))?; + write_message(stream, &json!({ "hello": hello }))?; + let message = read_message(stream)?; + if !same(message.get("token"), token) { + return Err(invalid("wrong token")); + } + let files = message.get("files").and_then(Value::as_array).ok_or_else(|| invalid("no file list"))?; + if files.len() > MAX_FILES { + return Err(invalid("too many files")); + } + let files = files + .iter() + .filter_map(Value::as_str) + .filter(|f| { + let p = Path::new(f); + let ok = p.is_absolute() && p.is_file(); + if !ok { + log::warn!("another launch named {f}, which isn't a file"); + } + ok + }) + .map(str::to_owned) + .collect(); + write_message(stream, &json!({ "ok": true }))?; + Ok(files) +} + +/// One JSON line of at most [`MAX_MESSAGE`] bytes. Each side sends one line and waits for the +/// other's, so nothing read past it is lost. +fn read_message(stream: &TcpStream) -> io::Result { + let mut line = Vec::new(); + BufReader::new(stream.take(MAX_MESSAGE)).read_until(b'\n', &mut line)?; + if line.last() != Some(&b'\n') { + return Err(invalid("message too long or cut short")); + } + serde_json::from_slice(&line).map_err(|e| invalid(&e.to_string())) +} + +fn write_message(mut stream: &TcpStream, message: &Value) -> io::Result<()> { + let mut line = serde_json::to_vec(message).map_err(io::Error::other)?; + line.push(b'\n'); + stream.write_all(&line) +} + +/// `given` is the string `expected`, compared in constant time. +fn same(given: Option<&Value>, expected: &str) -> bool { + given + .and_then(Value::as_str) + .is_some_and(|g| g.len() == expected.len() && g.bytes().zip(expected.bytes()).fold(0, |acc, (a, b)| acc | (a ^ b)) == 0) +} + +/// Two unrelated 128-bit hex tokens. `RandomState` is keyed from the system's random numbers, and +/// its keyed hash of distinct values gives unpredictable, independent output (no crate needed). +fn random_tokens() -> (String, String) { + let keyed = RandomState::new(); + let token = |a: u8, b: u8| format!("{:016x}{:016x}", keyed.hash_one(a), keyed.hash_one(b)); + (token(1, 2), token(3, 4)) +} + +/// Replace `path` with `endpoint` at once (a temporary file renamed over it), readable only by the +/// user on Unix. +fn write_endpoint(path: &Path, endpoint: &Value) -> io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension(format!("{}.tmp", std::process::id())); + // A leftover of an earlier app with the same process id; `create_new` sets the mode below. + let _ = fs::remove_file(&tmp); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let bytes = serde_json::to_vec(endpoint).map_err(io::Error::other)?; + let written = options.open(&tmp).and_then(|mut f| f.write_all(&bytes)).and_then(|()| fs::rename(&tmp, path)); + if written.is_err() { + // Best effort: the temporary file is all there is to clean up. + let _ = fs::remove_file(&tmp); + } + written +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + + use super::*; + + /// A fresh folder for one test, with an existing file in it. + fn folder(name: &str) -> (PathBuf, PathBuf, String) { + let dir = std::env::temp_dir().join(format!("vc-single-instance-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a b.svg"); + fs::write(&file, "").unwrap(); + let file = file.to_string_lossy().into_owned(); + (dir.join(FILE_NAME), dir, file) + } + + fn primary(path: &Path) -> (Primary, mpsc::Receiver>) { + let (tx, rx) = mpsc::channel(); + let primary = listen(path, move |files| tx.send(files).unwrap()).unwrap(); + (primary, rx) + } + + /// Connects as a launch would and sends `message` after the greeting; what came back. + fn send_raw(path: &Path, message: &[u8]) -> io::Result { + let endpoint = read_endpoint(path).unwrap(); + let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, endpoint.port)).unwrap(); + stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap(); + assert!(same(read_message(&stream).unwrap().get("hello"), &endpoint.hello)); + (&stream).write_all(message)?; + read_message(&stream) + } + + #[test] + fn a_launch_hands_its_files_to_the_running_app() { + let (path, dir, file) = folder("forward"); + let (primary, rx) = primary(&path); + let missing = dir.join("missing.svg").to_string_lossy().into_owned(); + forward(&path, &[file.clone(), missing]).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file), "the missing file is left out"); + // An empty list only asks whether the app runs: nothing is delivered. + forward(&path, &[]).unwrap(); + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + drop(primary); + assert!(!path.exists(), "the endpoint file goes with the app"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_wrong_token_garbage_and_oversized_messages_are_refused() { + let (path, dir, file) = folder("refused"); + let (_primary, rx) = primary(&path); + let token = read_endpoint(&path).unwrap().token; + let wrong = format!("{}\n", json!({ "token": "0".repeat(token.len()), "files": [file] })); + assert!(send_raw(&path, wrong.as_bytes()).is_err()); + assert!(send_raw(&path, b"not json\n").is_err()); + assert!(send_raw(&path, &vec![b'a'; MAX_MESSAGE as usize + 10]).is_err()); + let many = format!("{}\n", json!({ "token": token, "files": vec![file.clone(); MAX_FILES + 1] })); + assert!(send_raw(&path, many.as_bytes()).is_err()); + let relative = format!("{}\n", json!({ "token": token, "files": ["a b.svg", 7] })); + assert_eq!(send_raw(&path, relative.as_bytes()).unwrap(), json!({ "ok": true })); + assert!(rx.try_recv().is_err(), "nothing was delivered"); + // The listener still serves a launch with the right token. + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_stale_endpoint_file_is_ignored_and_replaced() { + let (path, dir, file) = folder("stale"); + // No listener on the recorded port any more. + let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); + write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + // A listener that doesn't know the greeting gets neither the token nor the paths. + let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = impostor.local_addr().unwrap().port(); + write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + let heard = std::thread::spawn(move || { + let (stream, _) = impostor.accept().unwrap(); + write_message(&stream, &json!({ "hello": "x" })).unwrap(); + stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap(); + read_message(&stream).ok() + }); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + assert!(heard.join().unwrap().is_none()); + // Garbage in the file is no endpoint either. + fs::write(&path, b"\xff not json").unwrap(); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + // A new primary replaces the stale file. + let (_primary, rx) = primary(&path); + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_replaced_endpoint_file_stays_when_the_earlier_app_ends() { + let (path, dir, _) = folder("replaced"); + let (first, _rx1) = primary(&path); + let (_second, _rx2) = primary(&path); + let second_token = read_endpoint(&path).unwrap().token; + drop(first); + assert_eq!(read_endpoint(&path).map(|e| e.token), Some(second_token)); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn tokens_are_random_and_distinct() { + let (a, b) = random_tokens(); + let (c, _) = random_tokens(); + assert_eq!(a.len(), 32); + assert!(a != b && a != c); + } + + #[cfg(unix)] + #[test] + fn the_endpoint_file_is_the_users_alone() { + use std::os::unix::fs::PermissionsExt as _; + let (path, dir, _) = folder("mode"); + let _primary = primary(&path); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/apps/vectorcraft/src/windows_cores.rs b/apps/vectorcraft/src/windows_cores.rs new file mode 100644 index 000000000..b35c0876c --- /dev/null +++ b/apps/vectorcraft/src/windows_cores.rs @@ -0,0 +1,40 @@ +//! Windows: the cores the process may run on, from its affinity mask, for the renderer's worker +//! count (`vectorcraft_render::set_usable_cores`). `available_parallelism` counts every processor +//! in the machine, also when the process may run on fewer (started with `start /affinity`, or +//! pinned by a launcher). The desktop app and `vectorcraft-cli` (exports, MCP) both install it; the +//! CLI shares this file. +//! +//! `GetProcessAffinityMask` is a C API with no safe binding, hence the scoped `unsafe_code` +//! allowance. +#![allow(unsafe_code)] + +use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessAffinityMask}; + +/// Tells the renderer how many cores the process may run on. +pub fn install() { + vectorcraft_render::set_usable_cores(usable_cores()); +} + +/// The number of cores in the process's affinity mask, or `None` when Windows reports none (a +/// process with threads in more than one processor group). +fn usable_cores() -> Option { + let (mut process, mut system) = (0usize, 0usize); + // SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no closing, and both pointers + // point at live `usize`s for the length of the call. + unsafe { GetProcessAffinityMask(GetCurrentProcess(), &mut process, &mut system) }.ok()?; + (process != 0).then(|| process.count_ones() as usize) +} + +#[cfg(test)] +mod tests { + /// The mask Windows reports for this process gives at least one core and no more than the + /// machine has. (A child process started with `start /affinity` would test a one-core mask + /// end to end, but a process spawned while other tests hold sockets inherits them and resets + /// their connections; `automatic_threads` covers the one-core count.) + #[test] + fn the_affinity_mask_gives_the_cores_the_process_may_use() { + let cores = super::usable_cores().unwrap(); + let all = std::thread::available_parallelism().unwrap().get(); + assert!((1..=all).contains(&cores), "{cores} of {all}"); + } +} diff --git a/apps/vectorcraft/src/xwayland.rs b/apps/vectorcraft/src/xwayland.rs index 509a70056..38b210c05 100644 --- a/apps/vectorcraft/src/xwayland.rs +++ b/apps/vectorcraft/src/xwayland.rs @@ -1,8 +1,10 @@ //! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland //! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop, -//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app -//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11 -//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and +//! #1151). Automatic and X11 (XWayland) When a Pen Is Attached do so only when a pen is attached +//! as the app starts, as PhotoCraft does: winit 0.30 doesn't bind the Wayland tablet protocol, so +//! on native Wayland the pen moves only the compositor's cursor, and XWayland hands the pen to X11 +//! windows as a pointer (#2028, #4967). `VECTORCRAFT_NATIVE_WAYLAND=1` keeps a pen's window on +//! Wayland. Only when XWayland can run the window: an X display is set and //! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland, //! so a missing library never keeps the app from starting. //! @@ -21,16 +23,17 @@ const X11_LIBS: &[&str] = &["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"]; /// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or -/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever -/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for -/// `x11WithPen`, after the session checks. +/// any other choice (`automatic`, `x11WithPen`, none saved) with `pen` finding a pen and +/// `VECTORCRAFT_NATIVE_WAYLAND` unset (or `0`); a Wayland session (winit picks Wayland whenever +/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only after +/// the other checks. pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option, pen: impl FnOnce() -> bool) -> bool { let set = |name: &str| var(name).is_some_and(|v| !v.is_empty()); let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY"); + let native_wayland = var("VECTORCRAFT_NATIVE_WAYLAND").is_some_and(|v| !v.is_empty() && v != "0"); match pref { Some("x11") => session, - Some("x11WithPen") => session && pen(), - _ => false, + _ => session && !native_wayland && pen(), } } @@ -149,27 +152,35 @@ mod tests { let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]); assert!(wanted(Some("x11"), &wayland, no_probe)); assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe)); - assert!(!wanted(Some("automatic"), &wayland, no_probe)); - assert!(!wanted(None, &wayland, no_probe)); - assert!(!wanted(Some("wayland"), &wayland, no_probe)); + assert!(!wanted(Some("automatic"), &wayland, || false)); + assert!(!wanted(None, &wayland, || false)); // An X11 session already opens on X11; without an X display there is no XWayland to use. assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe)); assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe)); assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe)); } - /// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X - /// display when a pen is found, the session's own display server otherwise. The pen check - /// runs only on such a session. + /// Automatic and X11 (XWayland) When a Pen Is Attached (#2028, #4967): XWayland on a Wayland + /// session with an X display when a pen is found, where the pen reaches the window, the + /// session's own display server otherwise. The pen check runs only on such a session, and + /// `VECTORCRAFT_NATIVE_WAYLAND` keeps Wayland. #[test] - fn the_pen_choice_opens_xwayland_only_with_a_pen() { + fn a_pen_opens_xwayland_unless_native_wayland_is_asked_for() { let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]); - assert!(wanted(Some("x11WithPen"), &wayland, || true)); - assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true)); - assert!(!wanted(Some("x11WithPen"), &wayland, || false)); - assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe)); - assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe)); - assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe)); + let native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "1")]); + let not_native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "0")]); + for pref in [Some("automatic"), None, Some("x11WithPen")] { + assert!(wanted(pref, &wayland, || true), "{pref:?}"); + assert!(wanted(pref, env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true), "{pref:?}"); + assert!(!wanted(pref, &wayland, || false), "{pref:?}"); + assert!(!wanted(pref, env(&[("DISPLAY", ":0")]), no_probe), "{pref:?}"); + assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe), "{pref:?}"); + assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe), "{pref:?}"); + assert!(!wanted(pref, &native, no_probe), "{pref:?}"); + assert!(wanted(pref, ¬_native, || true), "{pref:?}"); + } + // The X11 choice opens XWayland with or without a pen; the opt-out is for the pen only. + assert!(wanted(Some("x11"), &native, no_probe)); } /// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the diff --git a/contributors/people.toml b/contributors/people.toml index 0134ded6c..f3c8629b7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -180,3 +180,12 @@ real_name = "Ashok Ram" [people.dylanmtaylor] sync_github_name = true + +[people.marshiyar] +display_name = "Arshiya" +real_name = "Arshiya Rahgozar" +sync_github_name = false + +[people.WadahBadawi] +display_name = "Wadah Badawi" +sync_github_name = true diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 62e2974d2..f0339d698 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -17,7 +17,7 @@ Affinity files. | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) | | Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths | | Compound shapes (`Comp`, add and subtract) | one even-odd path | -| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) | +| A shape, curve or text with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) | | Vector masks (`AdCh` curves and shapes) | clip group | | Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask | | Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) | diff --git a/crates/affinity/tests/support/text_fixture.rs b/crates/affinity/tests/support/text_fixture.rs index 574a0ca38..01baa067e 100644 --- a/crates/affinity/tests/support/text_fixture.rs +++ b/crates/affinity/tests/support/text_fixture.rs @@ -98,20 +98,26 @@ pub fn utf8(text: &str) -> F { } pub fn document(blocks: Vec, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec { - let text = F::Obj( - tag(if frame { b"TxtF" } else { b"TxtA" }), - vec![ - (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])), - ( - tag(b"TxtH"), - F::Obj( - tag(if frame { b"FrFr" } else { b"ArFr" }), - vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))], - ), + document_with_children(blocks, frame, dpi, transform, vec![]) +} + +/// [`document`] whose text holds `children` (objects nested inside the text). +pub fn document_with_children(blocks: Vec, frame: bool, dpi: f64, transform: [f64; 6], children: Vec) -> Vec { + let mut fields = vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])), + ( + tag(b"TxtH"), + F::Obj( + tag(if frame { b"FrFr" } else { b"ArFr" }), + vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))], ), - (tag(b"Xfrm"), F::F64s(transform.to_vec())), - ], - ); + ), + (tag(b"Xfrm"), F::F64s(transform.to_vec())), + ]; + if !children.is_empty() { + fields.push((tag(b"Chld"), F::Shared(children))); + } + let text = F::Obj(tag(if frame { b"TxtF" } else { b"TxtA" }), fields); let mut root = vec![ (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])), ( diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 9ef049f86..179c0de51 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -138,9 +138,10 @@ pub fn stain(inst: &Node) -> Option<(Color, f32)> { Some((f.paint.color()?, f.opacity)) } -/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]). +/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic +/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]). pub fn instance_art(art: &Node, inst: &Node) -> Node { - let mut a = art.clone(); + let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst); if let Some((c, amount)) = stain(inst) { tint_node(&mut a, &c, amount); } diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index e76e67ba5..fbec57011 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -386,10 +386,22 @@ impl CornerSource { #[serde(default)] pub struct PatternBrush { pub side: Node, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub outer_corner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outer_corner_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outer_corner_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub inner_corner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inner_corner_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inner_corner_swatch: Option, /// What the outer corners take when there is no `outer_corner` art. Without art or a source, /// they take the `inner_corner` art. #[serde(skip_serializing_if = "Option::is_none")] @@ -400,8 +412,16 @@ pub struct PatternBrush { pub inner_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub end: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_swatch: Option, /// Scale in percent. pub scale: f64, /// Space between tiles in percent of the tile width. @@ -422,12 +442,22 @@ impl Default for PatternBrush { fn default() -> Self { Self { side: defaults::stitch_tile(), + side_frame: None, + side_swatch: None, outer_corner: None, + outer_corner_frame: None, + outer_corner_swatch: None, inner_corner: None, + inner_corner_frame: None, + inner_corner_swatch: None, outer_source: None, inner_source: None, start: None, + start_frame: None, + start_swatch: None, end: None, + end_frame: None, + end_swatch: None, scale: 100.0, spacing: 0.0, fit: PatternFit::Stretch, @@ -500,6 +530,14 @@ fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f6 (within(a, range, fallback), within(b, range, fallback)) } +fn valid_tile_frame(frame: Rect) -> bool { + [frame.x0, frame.y0, frame.x1, frame.y1].into_iter().all(f64::is_finite) + && frame.width().is_finite() + && frame.height().is_finite() + && frame.width() > 1e-9 + && frame.height() > 1e-9 +} + impl Colorization { /// A Hue Shift key colour that isn't a colour (components out of reach) becomes black. fn sanitize(&mut self) { @@ -600,6 +638,11 @@ impl Brush { BrushKind::Pattern(p) => { p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0); p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0); + for frame in [&mut p.side_frame, &mut p.outer_corner_frame, &mut p.inner_corner_frame, &mut p.start_frame, &mut p.end_frame] { + if frame.is_some_and(|f| !valid_tile_frame(f)) { + *frame = None; + } + } p.colorization.sanitize(); } BrushKind::Bristle(b) => { diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 28bbcda75..a7320895c 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -561,6 +561,43 @@ fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() { assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty"); } +#[test] +fn pattern_without_start_tile_runs_side_to_path_start() { + let path = line(0.0, 0.0, 100.0, 0.0); + let doc = Document::new(1.0, 1.0); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.start = Some(rect_art(20.0, 4.0)); + } + let with_start = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(with_start[0].geometric_bounds().unwrap().width(), 20.0); + if let BrushKind::Pattern(p) = &mut b.kind { + p.start = None; + } + let without_start = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(without_start.len(), 10); + assert!(near(without_start[0].geometric_bounds().unwrap().x0, 0.0, 1e-6)); + assert!(near(without_start[0].geometric_bounds().unwrap().width(), 10.0, 1e-6)); +} + +#[test] +fn pattern_without_end_tile_runs_side_to_path_end() { + let path = line(0.0, 0.0, 100.0, 0.0); + let doc = Document::new(1.0, 1.0); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.end = Some(rect_art(20.0, 4.0)); + } + let with_end = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert!(with_end.iter().any(|n| near(n.geometric_bounds().unwrap().width(), 20.0, 1e-6))); + if let BrushKind::Pattern(p) = &mut b.kind { + p.end = None; + } + let without_end = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(without_end.len(), 10); + assert!(near(without_end.last().unwrap().geometric_bounds().unwrap().x1, 100.0, 1e-6)); +} + #[test] fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() { let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap(); @@ -1097,7 +1134,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { let mut brushed = rect_art(10.0, 4.0); brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) })); for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] { - doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) }); + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art.clone()) }); doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)])); } let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 99f7e6304..78c2f547f 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -41,7 +41,8 @@ const MASK_STEPS: usize = 32; /// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than /// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord /// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`. -/// Text, images and symbol instances can't bend and are dropped. +/// Text, images and symbol instances can't bend and are dropped. The art of a linked opacity mask +/// bends with the object it masks. pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option { let mut out = n.clone(); out.appearance.scale_strokes(width_scale); @@ -63,9 +64,21 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max } _ => return None, } + map_mask(&mut out, |art| warp_node(art, f, flat_tol, max_du, doc_tol, width_scale)); Some(out) } +/// Replace the art of `n`'s linked opacity mask, if it has one, with what `map` makes of it. Where +/// `map` draws nothing, the mask's art becomes an empty group. +fn map_mask(n: &mut Node, map: impl FnOnce(&Node) -> Option) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + let id = m.art.id; + m.art = Arc::new(map(&m.art).unwrap_or_else(|| Node::group(id, vec![]))); + } +} + /// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords /// would, else the chords. Points `f` cannot place (not finite) are left out of the chords. fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData { @@ -234,6 +247,16 @@ fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> (b.width() > 1e-9).then_some((a, b)) } +/// Orient a tile's repeat frame with its art. The frame can be smaller than the art. +fn oriented_tile(art: &Node, frame: Option, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> { + let (node, bounds) = oriented(art, Direction::LeftToRight, flip_along, flip_across)?; + let Some(frame) = frame else { return Some((node, bounds)) }; + let x = if flip_along { (-frame.x1, -frame.x0) } else { (frame.x0, frame.x1) }; + let y = if flip_across { (-frame.y1, -frame.y0) } else { (frame.y0, frame.y1) }; + let frame = Rect::new(x.0, y.0, x.1, y.1); + (frame.width() > 1e-9 && frame.height() > 1e-9).then_some((node, frame)) +} + /// Piecewise-linear map from art x (offset from the art's left edge) to arc length. #[derive(Clone, Copy, Debug)] pub(crate) struct AlongMap { @@ -338,7 +361,8 @@ fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option { /// Everything else matches [`warp_node`] deliberately, so curves are the only difference between /// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every /// path, an empty path or a group left with no children is dropped, and strokes are scaled by -/// `width_scale` at every level rather than by the transform's own factor. +/// `width_scale` at every level rather than by the transform's own factor, the art of linked +/// opacity masks included. fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option { let mut out = prepared(n, width_scale)?; out.transform(a, false); @@ -365,6 +389,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option { } _ => return None, } + map_mask(&mut out, |art| prepared(art, width_scale)); Some(out) } @@ -416,6 +441,7 @@ fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node { if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind { *children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect(); } + map_mask(&mut out, |art| Some(widen_strokes(art, factor))); out } @@ -590,6 +616,9 @@ fn anchors_of(n: &Node) -> usize { if let Some(c) = n.children() { stack.extend(c.iter().map(Arc::as_ref)); } + if let Some(m) = &n.extras().mask { + stack.push(&m.art); + } } total } @@ -781,13 +810,13 @@ fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option { } pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { - let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across); - let Some(side) = orient(&pb.side) else { return vec![] }; + let orient = |n: &Node, frame| oriented_tile(n, frame, pb.flip_along, pb.flip_across); + let Some(side) = orient(&pb.side, pb.side_frame) else { return vec![] }; let k = weight * pb.scale.max(1.0) / 100.0; - let outer = pb.outer_corner.as_ref().and_then(orient); - let inner = pb.inner_corner.as_ref().and_then(orient); - let start = pb.start.as_ref().and_then(orient); - let end = pb.end.as_ref().and_then(orient); + let outer = pb.outer_corner.as_ref().and_then(|n| orient(n, pb.outer_corner_frame)); + let inner = pb.inner_corner.as_ref().and_then(|n| orient(n, pb.inner_corner_frame)); + let start = pb.start.as_ref().and_then(|n| orient(n, pb.start_frame)); + let end = pb.end.as_ref().and_then(|n| orient(n, pb.end_frame)); let tw = side.1.width() * k; let gap = tw * pb.spacing.max(0.0) / 100.0; // Each kind of corner takes its own art, else its source, else the other kind's art. @@ -1133,4 +1162,52 @@ mod tests { } } } + + /// A linked opacity mask on brush art bends and widens with the art, on a bent run and on a + /// straight one, so a mask drawn over the art still covers it. An unlinked mask stays put. + #[test] + fn a_linked_opacity_mask_bends_with_the_art() { + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let stroked = |id: u64| { + Node::path( + NodeId(id), + vectorcraft_geom::shapes::ellipse(b), + Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + 2.0, + ), + ) + }; + let masked = |linked: bool| { + let mut n = stroked(0); + let mut mask = vectorcraft_doc::OpacityMask::new(stroked(1), true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n + }; + let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let bent_track = tracks(&round, 0.05).remove(0); + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((320.0, 0.0)); + let straight_track = tracks(&line, 0.01).remove(0); + let along = AlongMap::linear(b.width(), 100.0, 140.0); + for (name, t) in [("bent", &bent_track), ("straight", &straight_track)] { + let out = bend(t, &masked(true), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run: the mask's art bends with the art it was drawn over"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run: the mask's strokes widen with the art's"); + + let out = bend(t, &masked(false), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(*mask.art, stroked(1), "{name} run: an unlinked mask stays where it was"); + + let out = bend_widened(t, &masked(true), b, along, 2.0, &|_| 0.5).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run, widened: the mask's art bends with the art"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run, widened: the mask's strokes narrow with the art's"); + } + assert_eq!(anchors_of(&masked(true)), 8, "the anchor budget counts the mask's art"); + } } diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 4886d9ea5..2ea6dea4f 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -21,5 +21,6 @@ image = { workspace = true } [dev-dependencies] serde_json = { workspace = true } -# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine). -vectorcraft-text = { workspace = true, features = ["bundled-fonts"] } +# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine) +# and in fonts built in code (`vectorcraft_text::test_fonts`). +vectorcraft-text = { workspace = true, features = ["bundled-fonts", "test-fonts"] } diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index 1e43c45dc..6857c2a17 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -622,7 +622,7 @@ impl<'d, 'a> Build<'d, 'a> { let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str()); let symbol = unique(label, &mut self.next_suffix, &self.symbol_names); self.symbol_names.insert(symbol.clone()); - self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) }); + self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) }); symbol }); self.symbols.insert(key, made.clone()); diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index d0dde65c9..6bd3d1c10 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -1049,6 +1049,34 @@ fn drawings_the_export_writes_read_back() { } } +#[test] +fn a_lone_segment_reads_back_from_the_line_the_export_writes() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut b = kurbo::BezPath::new(); + b.move_to((20.0, 30.0)); + b.line_to((120.0, 70.0)); + let id = doc.alloc_id(); + let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0); + doc.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap(); + for version in crate::DxfVersion::ALL.into_iter().filter(|v| v.r2000()) { + let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() }; + let out = crate::export(&doc, &opts).unwrap(); + assert!(std::str::from_utf8(&out.bytes).unwrap().contains("\nLINE\n"), "{version:?}"); + let back = open(&out.bytes, &points()); + let art = leaves(&back.document); + assert_eq!(art.len(), 1, "{version:?}"); + let path = art[0].path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1, "{version:?}"); + assert!(!path.subpaths[0].closed, "{version:?}"); + let shift = Affine::translate((0.0, 100.0 - height(&back.document))); + let points = path_points(&art[0]); + assert_eq!(points.len(), 17, "{version:?}: one segment"); + let ends = [shift * points[0], shift * points[16]]; + assert!(ends[0].distance(Point::new(20.0, 30.0)) < 1e-6 && ends[1].distance(Point::new(120.0, 70.0)) < 1e-6, "{version:?}: {ends:?}"); + } +} + #[test] fn closed_control_point_splines_stay_closed_through_export_and_import() { // (explicitly closed, ends where it starts): coincident endpoints alone do not close a path. diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 4afe84e33..3fc4bcb3b 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -233,7 +233,8 @@ impl RasterFormat { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Preserve { /// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or - /// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush + /// outside, width profiles, arrowheads, fitted dashes, dashes offset into their pattern) + /// become their filled outlines; brush /// strokes become their brush art. #[default] Appearance, @@ -277,7 +278,9 @@ pub struct DxfOptions { pub colors: ColorDepth, pub raster: RasterFormat, pub preserve: Preserve, - /// Alter Paths for Appearance: every stroke is written as the filled area it paints. + /// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`] + /// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have + /// no lineweights) is written as the filled area it paints. pub alter_paths: bool, /// Type is written as glyph outlines (always with [`Preserve::Appearance`]). pub outline_text: bool, diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7ab1d17f8..ea3b8558d 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -10,7 +10,7 @@ use kurbo::PathEl; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit}; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; use crate::aci::nearest_aci; use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed}; @@ -184,6 +184,46 @@ fn circular_subpath(sub: &Sub) -> Option { Some(CircularSub { centre, radius, samples }) } +/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle +/// scaled out of proportion. `u` and `v` are the conjugate semi-diameters through its first two +/// anchors. +struct EllipticalSub { + centre: Point, + u: Vec2, + v: Vec2, +} + +/// Fit a closed subpath of four cubics to one ellipse. Its anchors give the center and two +/// conjugate semi-diameters; the subpath mapped by their inverse must pass [`circular_subpath`] as +/// the unit circle, so the same tolerance and turn checks apply. +fn elliptical_subpath(sub: &Sub) -> Option { + if !sub.closed || sub.segs.len() != 4 { + return None; + } + let (a0, a1, a2) = (sub.start, sub.segs.first()?.end(), sub.segs.get(1)?.end()); + let centre = a0.midpoint(a2); + let (u, v) = (a0 - centre, a1 - centre); + let det = u.cross(v); + let scale2 = u.hypot2().max(v.hypot2()); + if !det.is_finite() || !scale2.is_finite() || det.abs() <= scale2 * 1e-9 { + return None; + } + let unit = |p: Point| { + let d = p - centre; + Point::new(d.cross(v) / det, u.cross(d) / det) + }; + let mut segs = Vec::with_capacity(4); + for seg in &sub.segs { + let Seg::Cubic(a, b, to) = *seg else { return None }; + segs.push(Seg::Cubic(unit(a), unit(b), unit(to))); + } + let circle = circular_subpath(&Sub { start: unit(sub.start), segs, closed: true })?; + if circle.centre.to_vec2().hypot() > 5e-4 || (circle.radius - 1.0).abs() > 5e-4 { + return None; + } + Some(EllipticalSub { centre, u, v }) +} + fn angle_deg(p: Point, centre: Point) -> f64 { (p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0) } @@ -233,6 +273,17 @@ const MAX_ENTITIES: u32 = 10_000_000; const MAX_COORD: f64 = 1e9; const MAX_POINTS: usize = 4_000_000; +/// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of +/// odd length repeats twice in its linetype. +fn starts_with_pattern(d: &Dash) -> bool { + if !d.is_dashed() || !d.offset.is_finite() { + return true; + } + let period = d.pattern.iter().sum::() * if d.pattern.len() % 2 == 1 { 2.0 } else { 1.0 }; + let phase = d.offset.rem_euclid(period); + phase.min(period - phase) <= period * 1e-9 +} + /// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped. fn polygons(bp: &BezPath) -> Vec> { // Far outside any drawing, a curve flattens to millions of points at this tolerance. @@ -494,7 +545,7 @@ impl<'a> Scene<'a> { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); self.nest += 1; self.node(&art, true); @@ -612,8 +663,7 @@ impl<'a> Scene<'a> { /// The DXF lineweight nearest to `width` points (scaled with the drawing when asked). fn lineweight(&mut self, width: f64) -> i32 { - let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 }; - let hundredths = width * 2540.0 / 72.0 * scale; + let hundredths = self.hundredths(width); let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1]; if hundredths > f64::from(max) + 5.0 { self.warn(WIDE_LINES); @@ -621,6 +671,21 @@ impl<'a> Scene<'a> { LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max) } + /// `width` points in hundredths of a millimetre, the unit of DXF lineweights (scaled with + /// the drawing when asked). + fn hundredths(&self, width: f64) -> f64 { + let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 }; + width * 2540.0 / 72.0 * scale + } + + /// Can a CAD line draw a plain stroke `width` points wide in this version? R12 polylines + /// carry their width, R13 and R14 have no lineweights, and from 2000 a lineweight holds up + /// to 2.11 mm, to the hundredth. + fn line_keeps_width(&self, width: f64) -> bool { + let v = self.o.version; + !v.handles() || (v.r2000() && self.hundredths(width).round() <= f64::from(LINEWEIGHTS[LINEWEIGHTS.len() - 1])) + } + /// A linetype for dash pattern `d` (one per distinct pattern). fn linetype(&mut self, d: &Dash) -> String { let mut lengths: Vec = d.pattern.iter().map(|v| v * self.k).collect(); @@ -673,7 +738,11 @@ impl<'a> Scene<'a> { } let Some(rgb) = self.paint_rgb(&st.paint) else { return }; let plain = stroke::is_plain(st); - if self.o.alter_paths || (appearance && !plain) { + // A linetype has no phase: dashes that start part way into their pattern need outlines. + let lined = plain && st.dash.as_ref().is_none_or(starts_with_pattern); + // Alter Paths for Appearance outlines a stroke only when a line can't draw it. + let altered = self.o.alter_paths && !(lined && self.line_keeps_width(st.width)); + if altered || (appearance && !lined) { let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st); let style = self.style(rgb, st.opacity, None, None); self.area(®ion.to_bezpath(), FillRule::NonZero, &style, true); @@ -740,9 +809,9 @@ impl<'a> Scene<'a> { self.e.int(98, 0); } - /// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened), - /// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives - /// polylines as a width. + /// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for + /// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones. + /// `width`: the stroke weight, which DXF before 2000 gives polylines as a width. fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) { let v = self.o.version; let width = (!v.r2000() && width > 0.0).then_some(width * self.k); @@ -755,11 +824,17 @@ impl<'a> Scene<'a> { if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) { pts.pop(); } - self.polyline(&pts, sub.closed, style, width); + // A LINE has no width, so a stroke before 2000 keeps its polyline width. + match (sub.closed, width, pts.as_slice()) { + (false, None, &[a, b]) => self.line(a, b, style), + _ => self.polyline(&pts, sub.closed, style, width), + } } else if (v.handles() || width.is_none()) && self.circular(&sub, style) { // Native circles/arcs are preferred by CAD/CAM software. In R12, however, a // stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain // its width. Non-circular curves continue through the existing fallback. + } else if v.handles() && self.elliptical(&sub, style) { + // R13 and later write an ellipse as a native ELLIPSE. } else if v.handles() { self.spline(&sub, style); } else { @@ -778,6 +853,16 @@ impl<'a> Scene<'a> { } } + /// A line from `a` to `b` (document space). + fn line(&mut self, a: Point, b: Point, style: &EntityStyle) { + let (a, b) = (self.pt(a), self.pt(b)); + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("LINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbLine"); + self.e.xyz(10, a.x, a.y); + self.e.xyz(11, b.x, b.y); + } + /// A polyline through `pts` (document space): lightweight from R14, else a polyline with a /// vertex entity per point. fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option) { @@ -787,7 +872,8 @@ impl<'a> Scene<'a> { let pts: Vec = pts.iter().map(|p| self.pt(*p)).collect(); let layer = self.layer.clone(); let h = self.handle(); - let flags = i64::from(closed); + // Bit 128: a linetype runs on through the vertices instead of restarting at each one. + let flags = i64::from(closed) | if style.linetype.is_some() { 128 } else { 0 }; if self.o.version.lwpolyline() { self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline"); self.e.int(90, pts.len() as i64); @@ -887,6 +973,53 @@ impl<'a> Scene<'a> { true } + /// Write a recognized elliptical subpath as a native DXF ELLIPSE (R13 and later): its center, + /// the end of its major axis relative to the center, and the minor to major ratio. Returns + /// false when the curve is not an ellipse, leaving the caller to write it as a spline. + fn elliptical(&mut self, sub: &Sub, style: &EntityStyle) -> bool { + let Some(last) = sub.segs.last().map(|s| s.end()) else { return false }; + if !sub.closed || last.distance(sub.start) > 1e-9 { + return false; + } + let Some(ellipse) = elliptical_subpath(sub) else { return false }; + let centre = self.to_dxf * ellipse.centre; + let u = self.to_dxf * (ellipse.centre + ellipse.u) - centre; + let v = self.to_dxf * (ellipse.centre + ellipse.v) - centre; + // The semi-axes are the square roots of the eigenvalues of [[a, b], [b, d]] = u u^T + v v^T. + let (a, b, d) = (u.x * u.x + v.x * v.x, u.x * u.y + v.x * v.y, u.y * u.y + v.y * v.y); + let mean = (a + d) / 2.0; + let spread = ((a - d) / 2.0).hypot(b); + let (major, minor) = ((mean + spread).sqrt(), (mean - spread).max(0.0).sqrt()); + let angle = 0.5 * (2.0 * b).atan2(a - d); + let axis = Vec2::new(major * angle.cos(), major * angle.sin()); + let ratio = (minor / major).min(1.0); + // A thinner ellipse stays a spline: DXF readers such as ezdxf need a ratio of 1e-6 or more. + if !centre.x.is_finite() || !centre.y.is_finite() || !axis.x.is_finite() || !axis.y.is_finite() || !ratio.is_finite() || ratio < 1e-6 { + return false; + } + + // The drawing extents take the ellipse's bounding box: half-widths hypot(u.x, v.x) and + // hypot(u.y, v.y) about the center. + let (c, eu, ev) = (ellipse.centre, ellipse.u, ellipse.v); + let (hx, hy) = (eu.x.hypot(ev.x), eu.y.hypot(ev.y)); + for p in [Point::new(c.x - hx, c.y), Point::new(c.x + hx, c.y), Point::new(c.x, c.y - hy), Point::new(c.x, c.y + hy)] { + self.pt(p); + } + + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("ELLIPSE", h, fixed::MODEL_RECORD, &layer, style, "AcDbEllipse"); + self.e.xyz(10, centre.x, centre.y); + self.e.xyz(11, axis.x, axis.y); + self.e.normal(); + // The ratio and the end parameter are written in full: rounded to six decimals, a thin + // ellipse's minor axis would change size, and a whole turn would read back as an open curve. + self.e.raw(40, &ratio.to_string()); + self.e.num(41, 0.0); + self.e.raw(42, &std::f64::consts::TAU.to_string()); + true + } + /// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it /// passes through every anchor (straight segments become straight cubics). fn spline(&mut self, sub: &Sub, style: &EntityStyle) { @@ -929,7 +1062,8 @@ impl<'a> Scene<'a> { } /// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text - /// entity per run of each line. + /// entity per run of each line, whose height (group 40) is [`CAP_HEIGHT`] times the em size its + /// run is drawn at ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text(&mut self, n: &Node, t: &TextObject) { let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. }); if outline { @@ -941,6 +1075,8 @@ impl<'a> Scene<'a> { if !n.appearance.items.is_empty() { self.warn(TYPE_ITEMS); } + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); + let t = &*resolved; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let plain = t.plain_text(); let xf = self.to_dxf * t.xf; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 30a8d1a81..f1b537b70 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit}; -use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes}; use crate::*; @@ -125,6 +125,71 @@ fn every_version_declares_its_acadver_and_is_well_formed() { assert_eq!(DxfVersion::from_id("2019"), None); } +/// The structure strict readers (Autodesk Fusion, #3085) need: the required sections and table +/// entries, handles that every reference resolves to, and LAYOUT objects with only the group codes +/// the DXF Reference defines for PLOTSETTINGS and LAYOUT (AutoCAD DXF Reference, OBJECTS section: +/// https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-1113675E-AB07-4567-BD47-B7FBE7290D1C). +#[test] +fn r2000_and_later_files_have_the_structure_cad_apps_require() { + use std::collections::HashSet; + // Common object codes, then AcDbPlotSettings, then AcDbLayout (76 is the shade plot mode in + // the first and the UCS type in the second). + let layout_codes: HashSet = [0, 5, 102, 330, 360, 100] + .into_iter() + .chain([1, 2, 4, 6, 7, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 140, 141, 142, 143, 70, 72, 73, 74, 75, 76, 77, 78, 147, 148, 149, 333]) + .chain([71, 10, 20, 11, 21, 12, 22, 32, 14, 24, 34, 15, 25, 35, 146, 13, 23, 33, 16, 26, 36, 17, 27, 37, 331, 345, 346]) + .collect(); + let d = shapes_doc(); + for v in DxfVersion::ALL.into_iter().filter(|v| *v >= DxfVersion::R2000) { + let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap(); + let p = pairs(&out.bytes); + for name in ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"] { + assert!(p.windows(2).any(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())), "{v:?}: {name} section"); + } + let tables = section(&p, "TABLES"); + let names = |k: &str| -> Vec { tables.iter().filter(|o| kind(o) == k).filter_map(|o| get(o, 2).map(str::to_string)).collect() }; + for (k, want) in [ + ("LTYPE", "ByBlock"), + ("LTYPE", "ByLayer"), + ("LTYPE", "Continuous"), + ("LAYER", "0"), + ("STYLE", "Standard"), + ("APPID", "ACAD"), + ("DIMSTYLE", "Standard"), + ("BLOCK_RECORD", "*Model_Space"), + ("BLOCK_RECORD", "*Paper_Space"), + ] { + assert!(names(k).iter().any(|n| n == want), "{v:?}: {k} {want}"); + } + // Every object after the header has a handle below $HANDSEED, and every reference to + // another object (owner 330, soft/hard pointers 340/350/390) names one of them. + let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..]; + let handles: HashSet = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect(); + let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap(); + assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed"); + for (code, value) in body.iter().filter(|x| matches!(x.0, 330 | 340 | 350 | 390)) { + let h = u32::from_str_radix(value, 16).unwrap(); + assert!(h == 0 || handles.contains(&h), "{v:?}: group {code} points at missing handle {value}"); + } + let objects = section(&p, "OBJECTS"); + let layouts: Vec<&Pairs> = objects.iter().filter(|o| kind(o) == "LAYOUT").collect(); + assert_eq!(layouts.len(), 2, "{v:?}: Model and Layout1"); + for l in &layouts { + let bad: Vec = l.iter().map(|x| x.0).filter(|c| !layout_codes.contains(c)).collect(); + assert!(bad.is_empty(), "{v:?}: LAYOUT has group codes the DXF Reference doesn't define: {bad:?}"); + // The paper image origin is 148 (x) and 149 (y). + assert!(get(l, 148).is_some() && get(l, 149).is_some(), "{v:?}: paper image origin"); + // Flag 1024 marks the model space layout, and only it. + let name = l.iter().skip_while(|x| x.1 != "AcDbLayout").find(|x| x.0 == 1).map(|x| x.1.as_str()); + let flags: i64 = get(l, 70).unwrap().parse().unwrap(); + assert_eq!(flags & 1024 != 0, name == Some("Model"), "{v:?}: model type flag of {name:?}"); + } + // The plot style placeholder carries only the common object codes. + let holder = objects.iter().find(|o| kind(o) == "ACDBPLACEHOLDER").expect("placeholder"); + assert!(holder.iter().all(|x| matches!(x.0, 0 | 5 | 102 | 330)), "{v:?}: placeholder {holder:?}"); + } +} + #[test] fn r12_viewport_has_no_duplicate_group_codes() { let d = shapes_doc(); @@ -208,10 +273,168 @@ fn circles_and_circular_arcs_are_native_dxf_entities() { assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}"); assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}"); } +} - // An actual ellipse is not close enough to a circle and remains a spline. - let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]); +/// The path `p` with `xf` applied. +fn transformed(p: &PathData, xf: Affine) -> PathData { + PathData::from_bezpath(&(xf * p.to_bezpath())) +} + +fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +#[test] +fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() { + let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)); + let d = doc_with(|d| { + let stroke = || Paint::solid(Color::BLACK); + vec![ + // 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5. + path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0), + // A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3. + path( + d, + transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))), + Paint::None, + stroke(), + 1.0, + ), + // The first ellipse turned 30° about its center, filled: the outline is the ellipse too. + path( + d, + transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))), + Paint::solid(Color::BLACK), + Paint::None, + 0.0, + ), + ] + }); + for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) { + let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(e.iter().map(|x| kind(x)).collect::>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}"); + let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42)); + let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]); + assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}"); + assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn"); + let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]); + assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]); + // Turned 30° counterclockwise on screen, so clockwise with DXF's y up. + let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]); + let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]); + assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}"); + } + // R12 has no ELLIPSE: the curves are flattened polylines. + let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap()); + assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}"); + // A thin ellipse keeps its ratio in full (six decimals would write 0.000002). + let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let e = entities(&export(&thin, &opts(&thin)).unwrap()); + assert_eq!(kind(&e[0]), "ELLIPSE"); + assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]); +} + +#[test] +fn exported_ellipses_read_back_as_the_same_ellipse() { + let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0)); + let d = doc_with(|d| { + vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), ["ELLIPSE"]); + let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }; + let back = import(&out.bytes, &o).unwrap().document; + let mut paths = vec![]; + back.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + paths.push(path.to_bezpath()); + } + }); + assert_eq!(paths.len(), 1); + assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed"); + // Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered. + let bp = &paths[0]; + let found = bp.bounding_box().center(); + let inverse = xf.inverse(); + let original = Point::new(60.0, 40.0); + for seg in bp.segments() { + for i in 0..=16 { + let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2(); + let q = inverse * p; + let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0); + assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})"); + } + } +} + +#[test] +fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() { + // A rounded square: four cubics through the ellipse's anchors with longer handles. + let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8); + let mut squircle = vectorcraft_geom::BezPath::new(); + squircle.move_to(Point::new(c.x + r, c.y)); + squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r)); + squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y)); + squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r)); + squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y)); + squircle.close_path(); + // An egg: one half wider than the other. + let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath(); + let mut els: Vec = egg.elements().to_vec(); + if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) { + a.x -= 10.0; + b.x -= 10.0; + } + let d = doc_with(|d| { + vec![ + path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0), + path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0), + // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). + path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0), + ] + }); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]); +} + +#[test] +fn a_lone_straight_segment_is_a_line() { + let segment = |closed: bool, extra: Option| { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(Point::new(20.0, 30.0)); + b.line_to(Point::new(120.0, 70.0)); + if let Some(p) = extra { + b.line_to(p); + } + if closed { + b.close_path(); + } + PathData::from_bezpath(&b) + }; + let d = doc_with(|d| vec![path(d, segment(false, None), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + for version in DxfVersion::ALL.into_iter().filter(|v| v.r2000()) { + let e = entities(&export(&d, &DxfOptions { version, ..opts(&d) }).unwrap()); + assert_eq!(e.iter().map(|x| kind(x)).collect::>(), ["LINE"], "{version:?}"); + assert_eq!(e[0].iter().rfind(|p| p.0 == 100).map(|p| p.1.as_str()), Some("AcDbLine"), "{version:?}"); + assert_eq!((all(&e[0], 10), all(&e[0], 20)), (vec![20.0], vec![70.0]), "{version:?}: y up from the artboard's bottom"); + assert_eq!((all(&e[0], 11), all(&e[0], 21)), (vec![120.0], vec![30.0]), "{version:?}"); + } + + // Without a width (R12 writes a fill as its outline), R12 writes a LINE too; a stroke weight + // before 2000 keeps its polyline width. + let filled = doc_with(|d| vec![path(d, segment(false, None), Paint::solid(Color::BLACK), Paint::None, 0.0)]); + let r12 = |d: &Document| kinds(&export(d, &DxfOptions { version: DxfVersion::R12, ..opts(d) }).unwrap()); + assert_eq!(r12(&filled), ["LINE"]); + assert_eq!(r12(&d), ["POLYLINE", "VERTEX", "VERTEX", "SEQEND"]); + let r14 = entities(&export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap()); + assert_eq!((kind(&r14[0]), get(&r14[0], 43)), ("LWPOLYLINE", Some("1"))); + + // A closed segment and two segments stay polylines. + for p in [segment(true, None), segment(false, Some(Point::new(150.0, 20.0)))] { + let d = doc_with(|d| vec![path(d, p, Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["LWPOLYLINE"]); + } } #[test] @@ -453,14 +676,44 @@ fn dashes_become_linetypes_and_opacity_transparency() { #[test] fn alter_paths_writes_strokes_as_filled_outlines() { - let d = shapes_doc(); - let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); - // Each fill and stroke: its outlines, then its hatch. let fill = ["LWPOLYLINE", "HATCH"]; let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"]; + // Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc. + let mut d = shapes_doc(); + let c = Point::new(150.0, 95.0); + let k = 20.0 * shapes::KAPPA; + let mut arc = vectorcraft_geom::BezPath::new(); + arc.move_to(Point::new(c.x + 20.0, c.y)); + arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0)); + let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, arc).unwrap(); + let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat()); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + // R12 polylines carry any width, so its strokes stay lines too. + let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap(); + assert!(!kinds(&r12).iter().any(|k| k == "HATCH")); + assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2"))); + // R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch. + let d = shapes_doc(); + let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat()); // The rectangle's stroke: an outer and an inner boundary. assert_eq!(get(&entities(&out)[4], 91), Some("2")); + // A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width. + let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]); + let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap(); + assert_eq!(kinds(&out), rect_stroke); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let lines = export(&wide, &opts(&wide)).unwrap(); + assert_eq!(kinds(&lines), ["LWPOLYLINE"]); + assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm"))); + // To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line. + for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] { + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]); + assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt"); + } // Preserve Appearance outlines only the strokes a line can't draw. let mut d = shapes_doc(); let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); @@ -471,6 +724,65 @@ fn alter_paths_writes_strokes_as_filled_outlines() { let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]); assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment"))); + // With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line. + let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat()); + assert!(altered.warnings.is_empty(), "{:?}", altered.warnings); +} + +#[test] +fn dashes_that_start_part_way_into_their_pattern_are_outlined() { + let dashed = |offset: f64| { + doc_with(|d| { + let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = n.appearance.stroke_mut() { + st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false }); + } + vec![n] + }) + }; + // A linetype starts at the start of its pattern: an offset of a whole period (the odd + // pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch. + for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] { + let d = dashed(offset); + let k = kinds(&export(&d, &opts(&d)).unwrap()); + if outlined { + assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}"); + } else { + assert_eq!(k, ["LWPOLYLINE"], "offset {offset}"); + } + let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()); + assert_eq!(altered, k, "offset {offset}, Alter Paths"); + } + // Maximize Editability keeps the line and its linetype. + let d = dashed(4.0); + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&editable), ["LWPOLYLINE"]); + assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1")); +} + +#[test] +fn dashed_polylines_continue_their_pattern_around_corners() { + let rect = |dash: Option| { + doc_with(|d| { + let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = n.appearance.stroke_mut() { + st.dash = dash.clone(); + } + vec![n] + }) + }; + let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string)); + // Group 70 bit 128 makes the linetype run on through the vertices instead of restarting + // at each one; a solid polyline keeps only its closed bit. + let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false })); + for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] { + assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129")); + } + let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap(); + assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129"))); + let solid = rect(None); + assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1")); } fn text_doc(text: &str, size: f64) -> Document { @@ -594,6 +906,23 @@ fn text_outside_ascii_is_escaped_before_2007() { assert_eq!(text(DxfVersion::R2018), "Größe"); } +/// Type whose font size measures the cap height is written at the em size it is drawn at: 14 pt +/// measuring a 0.7 em cap height is 20 pt type, whose TEXT height is 14. +#[test] +fn a_font_height_reference_is_written_as_the_size_drawn() { + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut d = text_doc("Plan A", 14.0); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.font_family = HEIGHT_FAMILY.into(); + t.runs[0].style.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + } + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&editable); + assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "{:?}", all(&e[0], 40)); +} + #[test] fn images_link_png_or_jpeg_files() { let mut png = vec![]; @@ -674,7 +1003,7 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { // while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it. let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]); let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let o = DxfOptions { crop: true, ..opts(&d) }; assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } diff --git a/crates/cad/src/writer.rs b/crates/cad/src/writer.rs index 665f8576f..6ba7e5a35 100644 --- a/crates/cad/src/writer.rs +++ b/crates/cad/src/writer.rs @@ -564,7 +564,10 @@ impl Drawing { c.handle(350, fixed::PLOT_NORMAL); c.raw(100, "AcDbDictionaryWithDefault"); c.handle(340, fixed::PLOT_NORMAL); - c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder"); + // A placeholder has no data and no subclass marker of its own. + c.raw(0, "ACDBPLACEHOLDER"); + c.handle(5, fixed::PLOT_NORMAL); + c.handle(330, fixed::PLOT_STYLES); self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD); self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD); } @@ -618,14 +621,23 @@ impl Drawing { c.num(141, 0.0); c.num(142, 1.0); c.num(143, 1.0); - c.int(70, 688); + // Plot flags; 1024 marks the model space layout. + c.int(70, if record == fixed::MODEL_RECORD { 688 | 1024 } else { 688 }); c.int(72, 1); c.int(73, 0); c.int(74, 5); c.raw(7, ""); c.int(75, 16); + if self.version.true_color() { + // Shade plot mode (as displayed), resolution level (normal) and custom DPI. + c.int(76, 0); + c.int(77, 2); + c.int(78, 300); + } c.num(147, 1.0); - c.xy(148, 0.0, 0.0); + // The paper image origin is 148 (x) and 149 (y), not a point at 148 and 158. + c.num(148, 0.0); + c.num(149, 0.0); c.raw(100, "AcDbLayout"); c.raw(1, name); c.int(70, 1); diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 8201b8b97..64f42f25b 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -149,11 +149,20 @@ impl IccProfile { /// Device values (RGB / CMYK / Gray, 0..1) → sRGB. pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> { + let mut src = [0.0f32; 4]; + let channels = self.channels(); + src.get_mut(..channels)?.iter_mut().zip(v.iter()).for_each(|(dst, value)| *dst = *value); + self.to_srgb_into(src.get(..channels)?, intent) + } + + /// Convert an exact-width device pixel using the cached transform and stack storage. + pub fn to_srgb_into(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> { + if v.len() != self.channels() { + return None; + } let xf = self.to_xf(intent)?; - let mut src = v.to_vec(); - src.resize(self.channels(), 0.0); let mut dst = [0.0f32; 3]; - xf.transform(&src, &mut dst).ok()?; + xf.transform(v, &mut dst).ok()?; Some(dst.map(|x| x.clamp(0.0, 1.0))) } diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 528d56033..e44b0be9e 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -78,6 +78,14 @@ const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; /// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is /// halved to keep within [`PERCEPTUAL_TOLERANCE`]. const PERCEPTUAL_DEPTH: u32 = 10; +/// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within +/// 1/65536 of a tint, finer than the 1/255 steps PDF writes. +const TINT_STEPS: u32 = 16; +/// The least OKLab lightness by which an ink at 100% must differ from paper white for +/// [`Gradient::ink_tints`] to tell its tints apart by lightness. +const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0; +/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for. +const MAX_INK_TINTS: usize = 4096; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly /// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved @@ -100,6 +108,30 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: } } +/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a` +/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½). +fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec { + let mut vs = Vec::new(); + perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs); + vs.push(0.5); + perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs); + vs +} + +/// The offset of mix position `v` between stops `a` and `b`, placed through `a`'s midpoint +/// (`v` = ½ at the midpoint) as [`Gradient::sample`] maps it. +fn curve_offset(a: &GradientStop, b: &GradientStop, v: f32) -> f32 { + let m = a.midpoint.clamp(0.01, 0.99); + let u = if v == 0.5 { + a.midpoint + } else if v < 0.5 { + 2.0 * v * m + } else { + m + (v - 0.5) * 2.0 * (1.0 - m) + }; + a.offset + (b.offset - a.offset) * u +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientStop { /// 0..=1 along the gradient. @@ -262,21 +294,9 @@ impl Gradient { // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them. let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| { - let mut vs = Vec::new(); - perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs); - vs.push(0.5); - perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs); - let m = s.midpoint.clamp(0.01, 0.99); - vs.into_iter().map(move |v| { + perceptual_mixes(s, n).into_iter().map(move |v| { let mid = v == 0.5; - let u = if mid { - s.midpoint - } else if v < 0.5 { - 2.0 * v * m - } else { - m + (v - 0.5) * 2.0 * (1.0 - m) - }; - let t = s.offset + (n.offset - s.offset) * u; + let t = curve_offset(s, n, v); let o = s.opacity + (n.opacity - s.opacity) * v; (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint)) }) @@ -284,6 +304,84 @@ impl Gradient { std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten()) }) } + /// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as + /// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab + /// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings. Mixed + /// linearly between them, the tints follow a Perceptual gradient's curve. `None` when the + /// ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of paper white's (a white or nearly + /// white ink), so lightness does not tell its tints apart, and when the curve has more than + /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + pub fn ink_tints(&self, ink: &Color) -> Option> { + use crate::libraries::srgb_to_oklab; + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN { + return None; + } + let curve: Vec<_> = self.expanded().take(MAX_INK_TINTS + 1).collect(); + if curve.len() > MAX_INK_TINTS { + return None; + } + let tint = |c: &Color| { + let want = lightness(c); + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..TINT_STEPS { + let mid = (lo + hi) / 2.0; + if lightness(&ink.tinted(mid)) > want { + lo = mid; + } else { + hi = mid; + } + } + (lo + hi) / 2.0 + }; + Some(curve.into_iter().map(|(t, c, o, _)| (t, tint(&c), o)).collect()) + } + /// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so + /// [`Self::ink_tints`] finds tints for it? + pub fn fits_ink_tints(&self) -> bool { + self.expanded().nth(MAX_INK_TINTS).is_none() + } + /// A Perceptual gradient's curve ([`Self::expanded_stops`]) as mixes of its stops' inks: + /// (offset, `i`, weight, opacity) at each stop on it, where the inks there are those of stop + /// `i` and stop `i + 1` mixed linearly at the weight (0..1; 0 on the gradient's own stops). + /// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab + /// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix + /// position itself when the two colors' lightness differs by less than + /// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than + /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + pub fn ink_mixes(&self) -> Option> { + use crate::libraries::srgb_to_oklab; + if self.method.is_classic() || !self.fits_ink_tints() { + return None; + } + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + let mut out = Vec::new(); + for (i, s) in self.stops.iter().enumerate() { + out.push((s.offset, i, 0.0, s.opacity)); + let Some(n) = self.stops.get(i + 1) else { continue }; + let (l0, l1) = (lightness(&s.color), lightness(&n.color)); + for v in perceptual_mixes(s, n) { + let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN { + v + } else { + let want = lightness(&self.method.mix(&s.color, &n.color, v)); + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..TINT_STEPS { + let mid = (lo + hi) / 2.0; + // The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one. + if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 { + lo = mid; + } else { + hi = mid; + } + } + (lo + hi) / 2.0 + }; + out.push((curve_offset(s, n, v), i, weight, s.opacity + (n.opacity - s.opacity) * v)); + } + } + Some(out) + } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each /// segment's midpoint mirrored with it. pub fn reverse(&mut self) { diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 19e9f44fc..ab195b347 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -455,3 +455,71 @@ fn perceptual_gradients_draw_as_pixels() { assert!(GradientPaint::new(radial).draws_as_pixels(fill)); } } + +/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on +/// the curve has the lightness of the color mixed there, so the tints between the stops are not +/// a straight line. +#[test] +fn ink_tints_follow_the_perceptual_curve() { + use crate::libraries::srgb_to_oklab; + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.0); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]); + g.method = GradientMethod::Perceptual; + assert!(g.fits_ink_tints()); + let tints = g.ink_tints(&ink).expect("a magenta ink's tints differ in lightness"); + assert!(tints.len() > 2, "the curve's stops: {tints:?}"); + let (first, last) = (tints[0], tints[tints.len() - 1]); + assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}"); + assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}"); + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + for (t, tint, _) in &tints { + let want = lightness(&g.sample(*t).0); + assert!((lightness(&ink.tinted(*tint)) - want).abs() < 1e-3, "at {t}: tint {tint}"); + } + let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint"); + assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}"); + // A white ink's tints all have paper white's lightness: no tints by lightness. + assert_eq!(g.ink_tints(&Color::WHITE), None); + // More stops on the curve than MAX_INK_TINTS: no tints. + let n = MAX_INK_TINTS + 1; + g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { ink } else { Color::WHITE })).collect(); + assert_eq!(g.ink_tints(&ink), None); + assert!(!g.fits_ink_tints()); +} + +/// A Perceptual gradient's curve as mixes of its stops' inks: the stops on the curve at their +/// offsets, each weighted so the two stops' colors mixed in display RGB have the lightness of +/// the color on the curve there. +#[test] +fn ink_mixes_follow_the_perceptual_curve() { + use crate::libraries::srgb_to_oklab; + let (magenta, yellow) = (Color::cmyk(0.0, 1.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.6, 0.0)); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, magenta), GradientStop::new(1.0, yellow)]); + g.stops[0].midpoint = 0.3; + assert_eq!(g.ink_mixes(), None, "a Classic gradient"); + g.method = GradientMethod::Perceptual; + let mixes = g.ink_mixes().expect("a Perceptual gradient's curve"); + let curve = g.expanded_stops(); + assert_eq!(mixes.len(), curve.len(), "a mix for each stop on the curve"); + assert_eq!((mixes[0], mixes[mixes.len() - 1]), ((0.0, 0, 0.0, 1.0), (1.0, 1, 0.0, 1.0)), "the gradient's own stops"); + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + for ((t, i, w, _), (ct, c, _)) in mixes.iter().zip(&curve) { + assert!((t - ct).abs() < 1e-6, "{t} on the curve at {ct}"); + if *i == 0 && *t > 0.0 { + let mixed = GradientMethod::Classic.mix(&magenta, &yellow, *w); + assert!((lightness(&mixed) - lightness(c)).abs() < 1e-3, "at {t}: weight {w}"); + } + } + let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint"); + assert!((mid.2 - 0.5).abs() > 0.01, "not halfway at the midpoint: {mid:?}"); + assert!(mixes.windows(2).all(|w| w[0].0 <= w[1].0), "in order: {mixes:?}"); + // Two colors of one lightness: the weights are the mix positions. + g.stops[1].color = magenta; + let mixes = g.ink_mixes().expect("the curve"); + let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint"); + assert!((mid.2 - 0.5).abs() < 1e-6, "{mid:?}"); + // More stops on the curve than MAX_INK_TINTS: no mixes. + let n = MAX_INK_TINTS + 1; + g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect(); + assert_eq!(g.ink_mixes(), None); +} diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index ccae9666b..09142df0c 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -53,6 +53,15 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; + /// The widest the stroke gets, as a multiple of its weight: the largest total of the left and + /// right factors at any of the points (between them the profile is straight), 1 for a profile + /// without points. + pub fn widest(&self) -> f64 { + if self.points.is_empty() { + return 1.0; + } + self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max) + } /// The built-in profile with this id. pub fn preset(id: &str) -> Option { Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() }) diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index b78b8eb01..6d5d50d4d 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -35,8 +35,8 @@ pub struct Artboard { pub video_ruler_pixel_aspect_ratio: f64, /// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the /// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on; - /// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave - /// it out. + /// without one the paper is white, or the Simulate Colored Paper color. Exports and print draw + /// it behind the art ([`super::Document::with_artboard_fills`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub color: Option, } @@ -77,6 +77,37 @@ impl Artboard { } } +impl super::Document { + /// A copy for output with the background colors ([`Artboard::color`]) of the artboards at + /// `indices` drawn behind all the art: each a rectangle of its artboard filled with its color + /// (in the color's own model), on a layer of their own (Artboard Fill) below the + /// others. `None` when none of them has a color. + pub fn with_artboard_fills(&self, indices: impl IntoIterator) -> Option { + let fills: Vec<(Rect, vectorcraft_color::Color)> = indices + .into_iter() + .filter_map(|i| { + let a = self.artboards.get(i)?; + Some((a.rect, a.color?)) + }) + .collect(); + if fills.is_empty() { + return None; + } + let mut d = self.clone(); + let mut layer = Node::layer(d.alloc_id(), "Artboard Fill", super::LayerColor::Preset(0)); + let paths: Vec> = fills + .into_iter() + .map(|(r, c)| { + let look = super::Appearance::basic(vectorcraft_color::Paint::solid(c), vectorcraft_color::Paint::None, 0.0); + Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look)) + }) + .collect(); + *layer.children_mut()? = paths; + d.layers.insert(0, Arc::new(layer)); + Some(d) + } +} + /// A document's locked artboards ([`Document::locked_artboards`]). pub struct LockedArtboards<'a> { pub(crate) rects: Vec, diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ced9472a7..ce20d29cd 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; use vectorcraft_geom::kurbo::ParamCurveArclen; -use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2}; +use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; @@ -1029,6 +1029,27 @@ impl Rail { } } +/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps +/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest +/// any key paints from its centre. A step's shape lies within that distance of the point on the +/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing +/// moves. +pub(crate) fn rail_reach(keys: &[Arc], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option { + if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath { + return None; + } + let spine = blend_spine(keys, spec)?.0.bounds()?; + let radius = keys + .iter() + .filter_map(|k| { + let (c, b) = (center_of(k), k.visual(symbols)?); + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; + Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max)) + }) + .fold(0.0, f64::max); + Some(spine.inflate(3.0 * radius, 3.0 * radius)) +} + // ===================================================================================== // Blend evaluation // ===================================================================================== diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 8e4f80728..6f10da9b7 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -485,7 +485,7 @@ mod tests { #[test] fn symbols_and_patterns_are_limited_too() { let mut d = Document::new(10.0, 10.0); - d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: nested(MAX_DEPTH + 3) }); assert!(d.too_deep(None)); assert!(d.limit_depth()); assert!(!d.too_deep(None)); @@ -504,7 +504,7 @@ mod tests { for _ in 0..levels { art = Arc::new(Node::group(NodeId(3), vec![art])); } - d.symbols.push(crate::Symbol { name: format!("s{i}"), art }); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("s{i}"), art }); } d } @@ -573,7 +573,7 @@ mod tests { }; let through_a_symbol = |d: &mut Document| { d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")])); - d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") }); + d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: painted(3, "p") }); }; for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] { let mut d = doc(painted(1, "p")); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 478e54ca8..976d48485 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -47,6 +47,7 @@ impl Document { patterns, pattern_edit, mask_edit, + symbol_edit, next_id: _, unknown, page_isolate, @@ -113,6 +114,7 @@ impl Document { (a, b) => a.is_none() && b.is_none(), } && *mask_edit == other.mask_edit + && *symbol_edit == other.symbol_edit && *unknown == other.unknown && *page_isolate == other.page_isolate && *page_knockout == other.page_knockout @@ -221,6 +223,7 @@ impl Document { patterns: vec![], pattern_edit: None, mask_edit: None, + symbol_edit: None, next_id: 1, unknown: Default::default(), page_isolate: false, diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index c0c85b124..b52e850c7 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -98,18 +98,22 @@ impl Document { self.views = kept; } - /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the - /// mask already holds (the engine syncs it after every edit). + /// 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). pub fn drop_edit_modes(&mut self) { if let Some(me) = self.mask_edit.take() { self.layers.retain(|l| l.id != me.layer); } + if let Some(se) = self.symbol_edit.take() { + self.layers.retain(|l| l.id != se.layer); + } } - /// The document as saved and exported: without the opacity-mask editing layer. Borrowed when - /// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.) + /// The document as saved and exported: without the opacity-mask or symbol editing layer. + /// Borrowed when neither mode is on. (Pattern editing is saved: its tile layer holds unapplied + /// edits.) pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> { - if self.mask_edit.is_none() { + if self.mask_edit.is_none() && self.symbol_edit.is_none() { return std::borrow::Cow::Borrowed(self); } let mut d = self.clone(); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 837985874..d20c1ffe4 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1,7 +1,9 @@ //! Hit testing over the document tree. +use std::cell::RefCell; use std::collections::HashSet; -use std::sync::OnceLock; +use std::rc::Rc; +use std::sync::{Arc, OnceLock}; use vectorcraft_geom::hit::{fill_contains, stroke_contains}; use vectorcraft_geom::{BezPath, FillRule, Point, Rect}; @@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n)) } +/// One level of evaluation of a live object in a document, as the canvas draws it: type in an +/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated +/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the +/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else. +pub type LiveArt = fn(Option<&Document>, &Node) -> Vec; + +/// A number that changes whenever something outside the document that [`LiveArt`] reads changes, +/// such as the installed fonts or the plug-ins. +pub type LiveInputs = fn() -> u64; + +static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new(); + +/// Register how a live object's art is evaluated for clicks, and the number that changes with what +/// else it reads (the first registration stays). +pub fn set_live_art(f: LiveArt, inputs: LiveInputs) { + // Already set: the same engine, registered by another session. + let _ = LIVE_ART.set((f, inputs)); +} + +/// The registered [`LiveInputs`] now, 0 while nothing is registered. +fn live_inputs() -> u64 { + LIVE_ART.get().map_or(0, |(_, inputs)| inputs()) +} + +/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by +/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`]. +struct EvaluatedLive { + node: Node, + symbols: Vec<(String, Arc)>, + patterns: Vec, + inputs: u64, + art: Rc>, + size: usize, +} + +impl EvaluatedLive { + /// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now? + fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool { + self.inputs == inputs + && self.symbols.len() == doc.symbols.len() + && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art)) + && self.node == *n + && self.patterns == doc.patterns + } +} + +/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an +/// envelope's stroke band hits it again and again, and an envelope can hold a blend. +const LIVE_KEPT: usize = 4; + +/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than +/// this is not kept. +const LIVE_KEPT_SIZE: usize = 250_000; + +/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths. +fn art_size(art: &[Node]) -> usize { + let mut size = 0usize; + for n in art { + n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count()))); + } + size +} + +/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes +/// when the file is read, so [`live_art`] does not keep an evaluation of `n`. +fn holds_placed(doc: &Document, n: &Node) -> bool { + let mut placed = false; + let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); + n.walk(&mut find); + doc.symbols.iter().for_each(|s| s.art.walk(&mut find)); + doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find)); + placed +} + +thread_local! { + /// The evaluations [`live_art`] keeps, the latest first. + static EVALUATED: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while +/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of +/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's +/// symbols or patterns hold a placed document ([`holds_placed`]). +fn live_art(doc: &Document, n: &Node) -> Rc> { + let inputs = live_inputs(); + let kept = EVALUATED.with_borrow_mut(|kept| { + let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?; + let e = kept.remove(i); + let art = e.art.clone(); + kept.insert(0, e); + Some(art) + }); + if let Some(art) = kept { + return art; + } + #[cfg(test)] + LIVE_EVALUATED.with(|c| c.set(c.get() + 1)); + let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n))); + let size = art_size(&art); + if size > LIVE_KEPT_SIZE || holds_placed(doc, n) { + return art; + } + let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect(); + let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size }; + EVALUATED.with_borrow_mut(|kept| { + kept.insert(0, e); + kept.truncate(LIVE_KEPT); + let mut total = 0usize; + kept.retain(|e| { + total = total.saturating_add(e.size); + total <= LIVE_KEPT_SIZE + }); + }); + art +} + +#[cfg(test)] +thread_local! { + /// How many live objects [`live_art`] evaluated on this thread. + static LIVE_EVALUATED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Topmost editable object under `p`. pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { hit_test_skipping(doc, p, opt, &|_| false, true) @@ -110,6 +234,12 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option bool) -> Option { + hit_test_skipping(doc, p, opt, skip, false) +} + /// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask /// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A /// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that @@ -538,20 +668,15 @@ fn hit_children( } /// The order a click at `p` tries the children of `parent` in, front first: their stacking order -/// from the top, except that in an Intertwine group the members drawn over the others at `p` -/// come first (from the top among them too). +/// from the top, except that in an Intertwine group the members are tried in the order they are +/// drawn at `p`, from the top ([`crate::intertwine::tier_at`]). fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc], p: Point) -> impl Iterator + 'a { - let over: Vec = if matches!(parent.kind, NodeKind::Intertwine { .. }) { - let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p); - (0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect() - } else { - vec![] - }; - let rest = (0..children.len()).rev().filter({ - let over = over.clone(); - move |i| !over.contains(i) - }); - over.into_iter().chain(rest) + let mut order: Vec = (0..children.len()).rev().collect(); + if matches!(parent.kind, NodeKind::Intertwine { .. }) { + let tier = |i: usize| children.get(i).map_or(0, |c| crate::intertwine::tier_at(c, p)); + order.sort_by_cached_key(|i| std::cmp::Reverse((tier(*i), *i))); + } + order.into_iter() } /// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke @@ -564,7 +689,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom:: stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline) } +#[cfg(test)] +thread_local! { + /// How many objects [`hit_leaf`] tested on this thread. + static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + #[cfg(test)] + LEAVES_TESTED.with(|c| c.set(c.get() + 1)); match &n.kind { NodeKind::Path { path, rule, .. } => { let bp = path.to_bezpath(); @@ -611,9 +744,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) } - NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } + // A blend or an envelope is hit within its bounds, and outside them where its evaluated + // art paints: a blend's keys and steps on a spine can reach past the keys and the spine, + // and the strokes of an envelope's distorted contents past its bounds. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return Some(HitKind::Bounds); + } + live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols)) + } NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)), NodeKind::Mesh(m) => { let bp = m.outline().to_bezpath(); @@ -650,8 +792,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool { /// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + // Hidden art does not hit. + if !n.visible { + return None; + } + // Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are + // passed by without being measured. + if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) { + return None; + } match &n.kind { - NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)), + // A clip group only hits inside its clipping path. + NodeKind::Group { children, clip } => { + if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) { + return None; + } + children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)) + } + // An intertwine group hits by its members, as on the canvas. + NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)), _ => hit_leaf(n, p, opt, symbols), } } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 08503e8a7..c4d6c565e 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() { } } +/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps +/// whose own reach holds the point, not every step of the evaluated blend. +#[test] +fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + // 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart. + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let count = |p: Point| { + LEAVES_TESTED.with(|c| c.set(0)); + let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf); + (h, LEAVES_TESTED.with(std::cell::Cell::get)) + }; + // On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it. + let (h, tested) = count(Point::new(450.0, 291.0)); + assert_eq!(h, Some(blend)); + assert!(tested < 20, "{tested} objects tested"); + // In the corner of the reach, outside every stroke: nothing hits. + let (h, tested) = count(Point::new(6.0, 291.0)); + assert_eq!(h, None); + assert!(tested < 20, "{tested} objects tested"); +} + +/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its +/// members. +#[test] +fn evaluated_art_hits_an_intertwine_group_by_its_members() { + let d = Document::new(200.0, 200.0); + let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art())); + let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] }); + let symbols = d.symbol_boxes(); + assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill)); + assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None); +} + +/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend +/// or a symbol of the document changes. +#[test] +fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0, 452.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 1); + // 100 steps 8.7 pt apart: a step still reaches (450, 291). + if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) { + spec.spacing = crate::live::BlendSpacing::Steps(100); + } + assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + assert_eq!(evaluated(), before + 2); + let art = circle(d.alloc_id(), 0.0); + d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art }); + hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()); + assert_eq!(evaluated(), before + 3); + hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()); + assert_eq!(evaluated(), before + 3); +} + +/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band +/// evaluates it again. +#[test] +fn evaluated_art_past_the_kept_size_is_not_kept() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + // A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps. + let circle = |id, x: f64| { + let points: Vec = (0..2000) + .map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 2000.0; + Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin()) + }) + .collect(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Arc::new(Node::path(id, path, stroked.clone())) + }; + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 2); +} + +/// While the document's symbols hold a placed document, whose art changes when its file is read, +/// each click in a blend's stroke band evaluates the blend again. +#[test] +fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let placed = crate::PlacedDocument { + link: crate::LinkInfo::new("/art/logo.vectorcraft"), + key: "logo".into(), + bounding: false, + page_box: crate::PageBox::Crop, + width: 80.0, + height: 40.0, + xf: vectorcraft_geom::Affine::IDENTITY, + placement: Default::default(), + }; + let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))); + d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art }); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 2); +} + +/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins +/// ([`LiveInputs`]) are as they were. +#[test] +fn a_kept_evaluation_is_stale_after_the_live_inputs_change() { + let d = Document::new(100.0, 100.0); + let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 }; + assert!(e.is_of(&d, &n, 7)); + assert!(!e.is_of(&d, &n, 8)); +} + /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the /// clipping path and the art. @@ -841,7 +986,7 @@ fn objects_at_matches_repeated_hits_on_random_documents() { fn objects_at_finds_symbol_instances_by_their_art() { let mut d = Document::new(200.0, 200.0); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art()); - d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id()); let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) }); d.insert(Some(layer), 0, instance(top, 0.0)).unwrap(); diff --git a/crates/doc/src/intertwine.rs b/crates/doc/src/intertwine.rs index e22ccbbaf..8a6ab6786 100644 --- a/crates/doc/src/intertwine.rs +++ b/crates/doc/src/intertwine.rs @@ -1,36 +1,51 @@ //! Object › Intertwine: objects that pass over and under each other. //! //! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own -//! stacking order. A member can be drawn over the other members in part of the group: that area -//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group -//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for -//! each member with an area, a copy of it clipped to that area. Where several members are drawn -//! over the others in one place, the upper one in the stacking order ends on top. +//! stacking order. A member can be drawn over other members in part of the group, at a tier: the +//! area of tier 1 is the member's [`crate::node::NodeExtras::intertwine`], and the areas of tiers 2 +//! and up are its [`crate::node::NodeExtras::intertwine_tiers`], in document coordinates. In each +//! place the members are drawn by their tier there (0 outside their areas), and members of one +//! tier in their stacking order, so any order of the members can be set for each overlap area. +//! The group draws (and exports, and is hit) as its live expansion ([`expand`]): the members, +//! then, tier by tier, a copy of each member with an area of that tier clipped to it. //! //! The members stay editable, and Release gives them back as they were (the areas let go). //! Behaviour: . use std::sync::Arc; -use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::{FillRule, PathData, Point}; use crate::{Node, NodeId, NodeKind}; -/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere). -pub fn over_area(n: &Node) -> Option<&PathData> { - n.extras().intertwine.as_deref().filter(|a| !a.is_empty()) +/// The areas where `n` is drawn over other members of its Intertwine group, each with its tier +/// (1 and up); empty ones are left out. +pub fn tier_areas(n: &Node) -> impl Iterator { + let e = n.extras(); + e.intertwine + .as_deref() + .map(|a| (1, a)) + .into_iter() + .chain(e.intertwine_tiers.iter().enumerate().map(|(k, a)| (k + 2, a))) + .filter(|(_, a)| !a.is_empty()) } -/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each -/// member with an area drawn over the others, as a clip group of that area around a copy of it -/// (ids 0: generated art). +/// The tier `n` is drawn at at `p`: the highest tier whose area holds `p` (0: none does). +pub fn tier_at(n: &Node, p: Point) -> usize { + tier_areas(n).filter(|(_, a)| fill_contains(&a.to_bezpath(), FillRule::NonZero, p)).map(|(t, _)| t).max().unwrap_or(0) +} + +/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then, for +/// each tier from 1 up, each member with an area of that tier, as a clip group of that area +/// around a copy of it (ids 0: generated art). pub fn expand(children: &[Arc]) -> Vec { let mut out: Vec = children.iter().map(|c| plain(c)).collect(); - for c in children { - let Some(area) = over_area(c) else { continue }; - if !c.visible { - continue; - } + let mut over: Vec<(usize, usize, &PathData)> = + children.iter().enumerate().filter(|(_, c)| c.visible).flat_map(|(i, c)| tier_areas(c).map(move |(t, a)| (t, i, a))).collect(); + over.sort_by_key(|(t, i, _)| (*t, *i)); + for (_, i, area) in over { + let Some(c) = children.get(i) else { continue }; let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false }); clip.appearance = crate::Appearance::default().into(); let mut copy = plain(c); @@ -40,11 +55,13 @@ pub fn expand(children: &[Arc]) -> Vec { out } -/// `n` without its Intertwine area (the expansion is plain art). +/// `n` without its Intertwine areas (the expansion is plain art). fn plain(n: &Node) -> Node { let mut n = n.clone(); - if n.extras().intertwine.is_some() { - n.extras_mut().intertwine = None; + if n.extras().intertwine.is_some() || !n.extras().intertwine_tiers.is_empty() { + let mut e = n.extras_mut(); + e.intertwine = None; + e.intertwine_tiers = Vec::new(); } n } @@ -88,6 +105,28 @@ mod tests { assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own"); } + #[test] + fn copies_are_drawn_tier_by_tier() { + let over = || shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0)); + // The lower member at tier 2, the upper one at tier 1: the lower one ends on top. + let mut a = (*square(1, 0.0)).clone(); + a.extras_mut().intertwine_tiers = vec![over()]; + let mut b = (*square(2, 5.0)).clone(); + b.extras_mut().intertwine = Some(Box::new(over())); + let kids = vec![Arc::new(a), Arc::new(b)]; + assert_eq!((tier_at(&kids[0], Point::new(7.0, 5.0)), tier_at(&kids[1], Point::new(7.0, 5.0))), (2, 1)); + assert_eq!(tier_at(&kids[0], Point::new(2.0, 5.0)), 0); + let out = expand(&kids); + assert_eq!(out.len(), 4, "both members, then the copy of tier 1, then the one of tier 2"); + assert!(out.iter().all(|n| n.extras().intertwine.is_none() && n.extras().intertwine_tiers.is_empty()), "the expansion is plain art"); + let copied = |n: &Node| match &n.kind { + NodeKind::Group { children, clip: true } => children[1].path_data().cloned(), + k => panic!("a clip group: {k:?}"), + }; + assert_eq!(copied(&out[2]).as_ref(), kids[1].path_data()); + assert_eq!(copied(&out[3]).as_ref(), kids[0].path_data()); + } + #[test] fn a_hidden_member_is_not_drawn_over_the_others() { let mut a = (*square(1, 0.0)).clone(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b6a9a60ef..b742a1dd7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -24,11 +24,13 @@ pub mod intertwine; pub mod shared; mod symbol_export_type; mod symbol_nine_slice; +pub mod symbol_overrides; mod symbol_registration; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice}; +pub use symbol_overrides::SymbolOverride; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; pub mod live; @@ -104,10 +106,12 @@ pub use selection::{AnchorRef, SegmentRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; +pub use text::FontHeightReference; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, - RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, + RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, + WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; @@ -138,7 +142,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; #[cfg(test)] use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; -pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol}; +pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -218,6 +222,10 @@ pub struct Document { /// still read so files saved mid-edit by older versions load without the editing layer. #[serde(default, skip_serializing)] pub mask_edit: Option, + /// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the + /// symbol already holds the art of its editing layer. + #[serde(default, skip_serializing)] + pub symbol_edit: Option, next_id: u64, /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "skip::is_empty_map")] diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 6a29b5b9e..9a2aa4fea 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -63,7 +63,7 @@ pub use expand::{ }; #[cfg(test)] use mesh::grid_eval; -pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; +pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; #[cfg(test)] @@ -181,6 +181,44 @@ mod tests { assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}"); } + /// #3592: a mesh read from a file can have any number of patches, and the view any zoom, so the + /// quads one tessellation makes are budgeted: the subdivision per patch gives way before memory + /// does, and each patch stays at least one quad. + #[test] + fn a_large_mesh_tessellates_within_the_quad_budget() { + let mesh = |n: u32| GradientMesh::from_surface(n, n, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5)); + // 3,600 patches at 32 x 32 would be 3.7 million quads. + let m = mesh(60); + let quads = m.quads(32); + assert!(quads.len() <= GradientMesh::MAX_QUADS, "{} quads", quads.len()); + assert!(quads.len() >= 3600 * 4, "still finer than a quad per patch: {}", quads.len()); + // A small mesh keeps the subdivision asked for. + assert_eq!(mesh(3).quads(8).len(), 9 * 64); + // The finest the view asks for, on a mesh of 22,500 patches, is a few quads per patch. + let big = mesh(150).quads(64); + assert!(big.len() <= GradientMesh::MAX_QUADS && big.len() >= 22_500, "{} quads", big.len()); + // More patches than the budget has quads is malformed, as a point count that doesn't fit is. + let point = MeshPoint { p: Point::ZERO, color: Color::rgb(0.0, 0.0, 0.0), opacity: 1.0, handles: [Vec2::ZERO; 4] }; + let huge = GradientMesh { rows: 2000, cols: 2000, points: vec![point; 2001 * 2001] }; + assert!(!huge.is_valid()); + assert!(huge.quads(1).is_empty()); + } + + /// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`] + /// gives way to: each patch's pieces are its own quads', so together they cover the mesh once. + #[test] + fn a_large_meshs_pieces_follow_the_quad_budget() { + let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5)); + let area = |pts: &[Point]| { + let n = pts.len(); + (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::().abs() / 2.0 + }; + let pieces = m.pieces(32); + assert!(pieces.len() >= 3600, "{} pieces", pieces.len()); + let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum(); + assert!((total - 90_000.0).abs() < 90.0, "covered {total}"); + } + /// A file's rows and columns can be anything: a point count too large to compute is no match /// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points). #[test] @@ -195,17 +233,86 @@ mod tests { assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p); } + /// How far the border of `m` strays from the ellipse in `r`, in points (measured across the + /// ellipse's short radius), checked at 20 places along every border edge. + fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 { + let (rows, cols) = (m.rows as usize, m.cols as usize); + let edges = (0..cols).flat_map(|c| [m.edge_u(0, c), m.edge_u(rows, c)]).chain((0..rows).flat_map(|k| [m.edge_v(k, 0), m.edge_v(k, cols)])); + let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); + edges + .flat_map(|e| (0..=20).map(move |i| e.eval(f64::from(i) / 20.0))) + .map(|p| ((p.x - c.x) / a).hypot((p.y - c.y) / b) - 1.0) + .fold(0.0f64, |w, d| w.max(d.abs() * a.min(b))) + } + + /// #3187: a gradient mesh made from an ellipse keeps the ellipse's shape: a Mesh tool click + /// (a 1×1 mesh with lines through the point) and Create Gradient Mesh's 4×4 grid both lay + /// their border on the outline, within about twice the 0.35 pt the outline is flattened to. + /// The 1×1 mesh was up to 7 pt off this 400 × 300 pt ellipse. + #[test] + fn a_mesh_from_an_ellipse_keeps_its_outline() { + const OFF: f64 = 0.75; + let r = Rect::new(100.0, 100.0, 500.0, 400.0); + let e = shapes::ellipse(r); + for (rows, cols) in [(1, 1), (4, 4), (2, 5)] { + let m = GradientMesh::for_path(&e, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let off = mesh_border_off_ellipse(&m, r); + assert!(off < OFF, "{rows}×{cols} mesh: border {off} pt off the ellipse"); + } + let made = GradientMesh::for_path(&e, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let mut m = made.clone(); + // Off centre, so each line splits its edges unevenly. + let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap(); + let off = mesh_border_off_ellipse(&m, r); + assert!(off < OFF, "after a Mesh tool click: {off} pt"); + // Deleting the lines again (Alt-click) gives the border back as it was. + assert!(m.remove_point_lines(i)); + assert_eq!((m.rows, m.cols), (1, 1)); + for (k, (now, was)) in m.points.iter().zip(&made.points).enumerate() { + let moved = now.handles.iter().zip(&was.handles).map(|(a, b)| (*a - *b).hypot()).fold(0.0, f64::max); + assert!(now.p.distance(was.p) < 1e-6 && moved < 1e-3, "point {k}: {now:?}, made as {was:?}"); + } + // With a handle at the new border point shortened since, the two edges no longer make one + // curve: the merged edge is straight rather than grown past its ends. + let mut m = made.clone(); + let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap(); + let top = m.idx(0, 1); + m.points[top].handles[H_LEFT] *= 1e-4; + assert!(m.remove_point_lines(i)); + let (a, b) = (m.points[0].p, m.points[1].p); + assert!((m.points[0].handles[H_RIGHT] - (b - a) / 3.0).hypot() < 1e-9, "{:?}", m.points[0].handles); + // A rectangle's mesh keeps straight edges with handles a third of the way along. + let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 60.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let e = m.edge_u(0, 0); + assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}"); + } + #[test] fn mesh_insert_and_remove_lines() { let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); - let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap(); + let i = m.add_lines_at(Point::new(30.0, 60.0), 0.0).unwrap(); assert_eq!((m.rows, m.cols), (2, 2)); assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p); assert!(m.remove_point_lines(i)); assert_eq!((m.rows, m.cols), (1, 1)); } + /// A point outside the mesh within the tolerance locates the nearest point of its edge. + #[test] + fn mesh_locate_within_reaches_the_edge() { + let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let p = Point::new(102.0, 30.0); + assert_eq!(m.locate(p), None); + assert_eq!(m.locate_within(p, 1.0), None); + let (r, c, u, v) = m.locate_within(p, 3.0).unwrap(); + assert_eq!((r, c), (0, 1)); + assert_eq!(u, 1.0, "on the right edge"); + let q = m.eval(r, c, u, v); + assert!(q.distance(Point::new(100.0, 30.0)) < 1e-6, "{q:?}"); + assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0))); + } + /// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB. #[test] fn a_cmyk_mesh_mixes_its_inks() { diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index c67dbee3e..66d20f27b 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -293,9 +293,10 @@ pub fn is_live(n: &Node) -> bool { ) } -/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. +/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined +/// where the colour hardly changes ([`GradientMesh::pieces`]). pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec { - m.quads(n) + m.pieces(n) .into_iter() .map(|q| { let mut ap = Appearance::default(); diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index dbbfc66bd..a87cbd9b4 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -35,6 +35,15 @@ pub struct MeshQuad { pub corners: [(Color, f32); 4], } +/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through +/// tessellation points, its colour and opacity. +#[derive(Clone, Debug, PartialEq)] +pub struct MeshPiece { + pub pts: Vec, + pub color: Color, + pub opacity: f32, +} + /// Create Gradient Mesh → Appearance. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MeshAppearance { @@ -157,6 +166,76 @@ impl Coons { // Gradient meshes // ===================================================================================== +/// The handles, as offsets from `ends`, of the cubic from `ends.0` to `ends.1` that comes closest +/// (least squares) to `side` between `span.0` and `span.1`; `None` when they can't be computed. +/// Each point of the side is matched to the curve's nearest point, refined over a few rounds. +fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(Vec2, Vec2)> { + const N: usize = 32; + const ROUNDS: usize = 6; + let (p0, p3) = (ends.0, ends.1); + let qs: Vec = (1..N).map(|i| Coons::sample(side, span.0 + (span.1 - span.0) * i as f64 / N as f64)).collect(); + 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) = (0.0, 0.0, 0.0); + let (mut x1, mut x2) = (Vec2::ZERO, Vec2::ZERO); + 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 r = q.to_vec2() - b0 * p0.to_vec2() - b3 * p3.to_vec2(); + c11 += b1 * b1; + c12 += b1 * b2; + c22 += b2 * b2; + x1 += b1 * r; + x2 += b2 * r; + } + let det = c11 * c22 - c12 * c12; + if det.abs() < 1e-12 { + break; + } + let (p1, p2) = (((c22 * x1 - c12 * x2) / det).to_point(), ((c11 * x2 - c12 * x1) / det).to_point()); + if !(p1.is_finite() && p2.is_finite()) { + break; + } + fit = Some((p1 - p0, p2 - p3)); + // One Newton step towards the nearest point of the curve for each point of the side. + let c = CubicBez::new(p0, p1, p2, p3); + let (a, b, k) = (p1 - p0, p2 - p1, p3 - 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 +} + +/// 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 +/// split [`GradientMesh::insert_row`] and [`GradientMesh::insert_col`] make, so deleting a mesh +/// line keeps the curve of the border it crossed (#3187). The share is the split parameter, read +/// from the lengths of the middle handles. When the edges no longer fit one cubic (a point or a +/// handle was moved since the split), the cubic grown back would not pass through the middle +/// point, and the merged edge is straight. +fn merged_handles(a: (Point, Vec2), mid: (Point, Vec2, Vec2), b: (Point, Vec2)) -> (Vec2, Vec2) { + let straight = ((b.0 - a.0) / 3.0, (a.0 - b.0) / 3.0); + let (la, lb) = (mid.1.hypot(), mid.2.hypot()); + let t = la / (la + lb); + if !(0.01..=0.99).contains(&t) { + return straight; + } + let (ha, hb) = (a.1 / t, b.1 / (1.0 - t)); + let through = CubicBez::new(a.0, a.0 + ha, b.0 + hb, b.0).eval(t); + let tol = 1e-3 * (a.0.distance(mid.0) + mid.0.distance(b.0)).max(1e-9); + if ha.is_finite() && hb.is_finite() && through.distance(mid.0) <= tol { (ha, hb) } else { straight } +} + /// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. fn even_steps(n: u32) -> Vec { let n = n.clamp(1, 200); @@ -170,11 +249,22 @@ pub(super) fn grid_len(rows: u32, cols: u32) -> Option { } impl GradientMesh { + /// The most quads one tessellation ([`Self::quads`]) makes, and so the most patches a mesh can + /// have: about 170 bytes a quad, so about 170 MB. A file's mesh can have any number of patches + /// and the view any zoom; the subdivision per patch gives way before memory does, and a mesh + /// with more patches than that is malformed, like a point count that doesn't fit (#3592). + pub const MAX_QUADS: usize = 1 << 20; + pub fn idx(&self, r: usize, c: usize) -> usize { r * (self.cols as usize + 1) + c } pub fn is_valid(&self) -> bool { - self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) + self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) && self.patches() <= Self::MAX_QUADS + } + + /// The number of patches (rows times columns), saturating. + fn patches(&self) -> usize { + (self.rows as usize).saturating_mul(self.cols as usize) } /// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v @@ -263,7 +353,49 @@ impl GradientMesh { /// 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)?; - Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p))) + let (us, vs) = (even_steps(cols), even_steps(rows)); + 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) + } + + /// Give each edge on the mesh's border the handles that make it follow the outline it stands + /// for, in place of the handles [`Self::from_grid`] estimates from the surface's derivatives + /// (#3187). Where two border edges meet along a side, their handles are made collinear, so the + /// border stays smooth there; the four corners keep the handles their own edges were fitted with. + fn fit_border(&mut self, coons: &Coons, us: &[f64], vs: &[f64]) { + let (rows, cols) = (self.rows as usize, self.cols as usize); + if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 { + return; + } + let edges = [(0, &coons.top), (rows, &coons.bottom)] + .map(|(r, side)| (r, side, true)) + .into_iter() + .chain([(0, &coons.left), (cols, &coons.right)].map(|(c, side)| (c, side, false))); + for (line, side, along_u) in edges { + let (n, steps) = if along_u { (cols, us) } else { (rows, vs) }; + for k in 0..n { + let (a, b, out, back) = if along_u { + (self.idx(line, k), self.idx(line, k + 1), H_RIGHT, H_LEFT) + } else { + (self.idx(k, line), self.idx(k + 1, line), H_DOWN, H_UP) + }; + if let Some((h1, h2)) = fit_edge(side, (steps[k], steps[k + 1]), (self.points[a].p, self.points[b].p)) { + self.points[a].handles[out] = h1; + self.points[b].handles[back] = h2; + } + } + for k in 1..n { + let (i, out, back) = if along_u { (self.idx(line, k), H_RIGHT, H_LEFT) } else { (self.idx(k, line), H_DOWN, H_UP) }; + let h = &mut self.points[i].handles; + let (fwd, bwd) = (h[out], h[back]); + let dir = (fwd.normalize() - bwd.normalize()).normalize(); + if fwd.hypot2() > 0.0 && bwd.hypot2() > 0.0 && dir.is_finite() { + h[out] = dir * fwd.hypot(); + h[back] = -dir * bwd.hypot(); + } + } + } } /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to @@ -347,13 +479,20 @@ impl GradientMesh { (Color::rgb(rgb[0], rgb[1], rgb[2]), op) } - /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners. + /// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser + /// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least). + fn subdivision(&self, n: usize) -> usize { + n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1) + } + + /// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre + /// and its corners. pub fn quads(&self, n: usize) -> Vec { if !self.is_valid() { return vec![]; } - let n = n.clamp(1, 64); - let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); + let n = self.subdivision(n); + let mut out = Vec::with_capacity(self.patches() * n * n); let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)]; for r in 0..self.rows as usize { @@ -383,6 +522,105 @@ impl GradientMesh { out } + /// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG, + /// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads + /// joined into square blocks as large as keeps the colour across each block within about one + /// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps + /// all its quads. A piece's outline runs through its quads' outer corners, so it covers what + /// they covered, and it takes the colour at its centre. + pub fn pieces(&self, n: usize) -> Vec { + let n = self.subdivision(n); + let quads = self.quads(n); + let cols = (self.cols as usize).max(1); + let mut out = Vec::with_capacity(quads.len()); + for (k, patch) in quads.chunks_exact(n * n).enumerate() { + let (r, c) = (k / cols, k % cols); + let side = self.block_side(r, c, n, patch); + if side == 1 { + out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity })); + continue; + } + // Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of. + let at = |i: usize, j: usize| -> Option { + let (qi, qj) = (i.min(n - 1), j.min(n - 1)); + let q = patch.get(qj * n + qi)?; + q.pts + .get(match (i > qi, j > qj) { + (false, false) => 0, + (true, false) => 1, + (true, true) => 2, + (false, true) => 3, + }) + .copied() + }; + for j0 in (0..n).step_by(side) { + for i0 in (0..n).step_by(side) { + let (i1, j1) = (i0 + side, j0 + side); + // Around the block, in the quads' order: along its top, down its right side, + // back along its bottom and up its left side. + let ring = (i0..i1) + .map(|i| (i, j0)) + .chain((j0..j1).map(|j| (i1, j))) + .chain((i0 + 1..=i1).rev().map(|i| (i, j1))) + .chain((j0 + 1..=j1).rev().map(|j| (i0, j))); + let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::>>() else { continue }; + let half = side as f64 / 2.0; + let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64); + out.push(MeshPiece { pts, color, opacity }); + } + } + } + out + } + + /// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into + /// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one + /// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline + /// of a block across the fold wouldn't fill it as its quads do. + fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize { + let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc)); + let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else { + return 1; + }; + // Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too. + let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. })); + let channels = |p: &MeshPoint| -> Vec { + let mut v = p.color.to_rgb().to_vec(); + if inks && let Color::Cmyk { c, m, y, k } = p.color { + v.extend([c, m, y, k]); + } + v.push(p.opacity); + v + }; + let [a, b, cc, d] = [p00, p01, p10, p11].map(channels); + // A bilinear patch changes across a block of side h (0..1 of the patch) by at most + // h × (du + dv) in each channel, du and dv its largest change along each parameter. + let mut steps = 0.0f32; + for k in 0..a.len() { + let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default(); + let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs()); + let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs()); + steps = steps.max((du + dv) * 255.0); + } + if !steps.is_finite() { + return 1; + } + let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1); + if side > 1 { + // Every quad turning the same way: the patch doesn't fold, so a block's outline + // encloses just what its quads cover. + let turn = |q: &MeshQuad| { + let [a, b, c, d] = q.pts; + (b - a).cross(c - a) + (c - a).cross(d - a) + }; + let first = quads.first().map(turn).unwrap_or_default(); + if !quads.iter().all(|q| turn(q) * first > 0.0) { + return 1; + } + } + side + } + /// The mesh's outer boundary as a closed path. pub fn outline(&self) -> PathData { if !self.is_valid() { @@ -466,12 +704,21 @@ impl GradientMesh { /// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh). pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> { + self.locate_within(p, 0.0) + } + + /// As [`Self::locate`], and for a point outside the mesh within `tol` of its outline, the + /// patch and local (u, v) of the nearest point of the mesh's edge. + pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> { if !self.is_valid() { return None; } let bp = self.outline().to_bezpath(); if bp.winding(p) == 0 { - return None; + if tol.is_nan() || tol <= 0.0 { + return None; + } + return self.nearest_edge(p).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); } const N: usize = 16; let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); @@ -508,6 +755,30 @@ impl GradientMesh { Some((r, c, u, v)) } + /// The point of the mesh's outer edge nearest `p`: its squared distance from `p`, and its + /// boundary patch and local (u, v). + fn nearest_edge(&self, p: Point) -> Option<(f64, usize, usize, f64, f64)> { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut best: Option<(f64, usize, usize, f64, f64)> = None; + let mut take = |e: CubicBez, at: (usize, usize, Option, Option)| { + let n = e.nearest(p, 1e-9); + if best.is_none_or(|b| n.distance_sq < b.0) { + let (r, c, u, v) = at; + best = Some((n.distance_sq, r, c, u.unwrap_or(n.t), v.unwrap_or(n.t))); + } + }; + for c in 0..cols { + take(self.edge_u(0, c), (0, c, None, Some(0.0))); + take(self.edge_u(rows, c), (rows - 1, c, None, Some(1.0))); + } + for r in 0..rows { + take(self.edge_v(r, 0), (r, 0, Some(0.0), None)); + take(self.edge_v(r, cols), (r, cols - 1, Some(1.0), None)); + } + best + } + /// Index of the mesh point within `tol` of `p`. pub fn point_near(&self, p: Point, tol: f64) -> Option { self.points @@ -595,9 +866,10 @@ impl GradientMesh { self.cols += 1; } - /// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index. - pub fn add_lines_at(&mut self, p: Point) -> Option { - let (r, c, u, v) = self.locate(p)?; + /// Mesh tool click: add a row and a column through `p` (inside the mesh, or outside within + /// `tol` of its edge, through the nearest point of the edge). Returns the new point index. + pub fn add_lines_at(&mut self, p: Point, tol: f64) -> Option { + let (r, c, u, v) = self.locate_within(p, tol)?; let mut rr = r; let mut cc = c; let row_new = v > 0.02 && v < 0.98; @@ -632,10 +904,11 @@ impl GradientMesh { let above = self.idx(r - 1, cc); let below = self.idx(r + 1, cc); let mid = self.idx(r, cc); - let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p); - let _ = pm; - self.points[above].handles[H_DOWN] = (pb - pa) / 3.0; - self.points[below].handles[H_UP] = (pa - pb) / 3.0; + let (pa, pb, m) = (self.points[above].p, self.points[below].p, &self.points[mid]); + let hm = (m.p, m.handles[H_UP], m.handles[H_DOWN]); + let (down, up) = merged_handles((pa, self.points[above].handles[H_DOWN]), hm, (pb, self.points[below].handles[H_UP])); + self.points[above].handles[H_DOWN] = down; + self.points[below].handles[H_UP] = up; } self.points.drain(r * w..(r + 1) * w); self.rows -= 1; @@ -646,9 +919,11 @@ impl GradientMesh { let rows = self.rows as usize; for rr in 0..=rows { let (l, rt) = (rr * w + c - 1, rr * w + c + 1); - let (pl, pr) = (self.points[l].p, self.points[rt].p); - self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0; - self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0; + let (pl, pr, m) = (self.points[l].p, self.points[rt].p, &self.points[rr * w + c]); + let hm = (m.p, m.handles[H_LEFT], m.handles[H_RIGHT]); + let (right, left) = merged_handles((pl, self.points[l].handles[H_RIGHT]), hm, (pr, self.points[rt].handles[H_LEFT])); + self.points[l].handles[H_RIGHT] = right; + self.points[rt].handles[H_LEFT] = left; } let mut k = 0; self.points.retain(|_| { diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a971a383b..a1268505f 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -253,6 +253,10 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize { b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize); b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::() + path_bytes(a)); + if e.intertwine_tiers.capacity() > 0 { + b += ALLOC + e.intertwine_tiers.capacity() * size_of::() + e.intertwine_tiers.iter().map(path_bytes).sum::(); + } + b += e.symbol_overrides.capacity() * size_of::(); b += e.objects_on_path.as_deref().map_or(0, |o| { ALLOC + size_of::() + o.members.capacity() * size_of::() }); diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 633f9b696..239dce93c 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -44,6 +44,11 @@ pub struct NodeExtras { /// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates, /// filled non-zero) where this object is drawn over the group's other members. pub intertwine: Option>, + /// Object › Intertwine: as a member of an Intertwine group, the areas (document coordinates, + /// filled non-zero) where this object is drawn over members that are themselves drawn over + /// others there. Item `k` is the area of tier `k + 2`; [`NodeExtras::intertwine`] is tier 1 + /// (see [`crate::intertwine`]). + pub intertwine_tiers: Vec, /// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults). pub live_paint_gaps: Option>, /// Object › Objects on Path: the group's path and the objects attached to it (see @@ -58,6 +63,9 @@ pub struct NodeExtras { pub compound_shape: bool, /// As a member of a compound shape, how this object combines with the members below it. pub shape_mode: crate::ShapeMode, + /// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's + /// art (see [`crate::symbol_overrides`]); empty for none. + pub symbol_overrides: Vec, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -76,11 +84,13 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { perspective: None, dimension: None, intertwine: None, + intertwine_tiers: Vec::new(), live_paint_gaps: None, objects_on_path: None, colorize: None, compound_shape: false, shape_mode: crate::ShapeMode::Add, + symbol_overrides: Vec::new(), }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index fcf2c9429..b5ae03e8b 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -198,7 +198,8 @@ impl Node { /// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in /// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers /// without a clipping mask and Intertwine groups take in their members' boxes, grown by their - /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. + /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box + /// takes in where its spine can put its keys and steps. pub fn reach_bounds(&self) -> Option { self.reach(None) } @@ -234,6 +235,11 @@ impl Node { let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o))) }), + // Keys and steps placed on a spine, turned to it with Align to Path, can reach past the + // keys and the spine. + NodeKind::Blend { children, spec } => { + vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols)) + } _ => self.visual(symbols), } } @@ -496,8 +502,13 @@ impl Node { Arc::make_mut(&mut m.art).transform_scaled(a, sc); } // The area where an Intertwine member is drawn over the others moves with it. - if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) { - area.transform(a); + if let Some(e) = self.extras_if_any() { + if let Some(area) = e.intertwine.as_mut() { + area.transform(a); + } + for area in &mut e.intertwine_tiers { + area.transform(a); + } } } @@ -686,6 +697,12 @@ impl Node { /// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient /// stops or a colorized image. Hidden objects show none. pub fn shows_transparency(&self) -> bool { + self.shows_transparency_with(&|_| true) + } + + /// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize` + /// accepts it, for files that write some colorized images in their colorize paint (EPS). + pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool { use vectorcraft_color::Paint; let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); @@ -693,14 +710,14 @@ impl Node { && (self.opacity < 1.0 || self.blend != BlendMode::Normal || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) - || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some()) + || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self)) || effects(&self.appearance.effects) || self .appearance .items .iter() .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) - || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency()))) + || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize)))) } } diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index dc241841f..df675580e 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -339,6 +339,35 @@ fn transform_group_recurses() { assert_eq!(child.appearance.stroke_width(), 2.0); } +/// A symbol instance in perspective keeps its picture moved by a transform, and loses its +/// projection when no invertible map of finite numbers draws the moved picture. +#[test] +fn a_projection_follows_the_transform_or_is_removed() { + use vectorcraft_geom::{Homography, Point}; + let instance = |h: Homography| { + let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY }); + n.extras_mut().perspective = + Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) })); + n + }; + let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection); + // A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down. + let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); + let mut n = instance(big); + n.transform(Affine::scale(10.0), false); + let p = Point::new(3.0, 7.0); + assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9); + // A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0). + let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let mut n = instance(far); + n.transform(Affine::scale(1e150), false); + assert_eq!(projection(&n), None); + // A transform that is not invertible leaves the projection as it is. + let mut n = instance(far); + n.transform(Affine::scale(0.0), false); + assert_eq!(projection(&n), Some(far.to_array())); +} + #[test] fn unplaced_gradients_pin_only_when_a_refit_would_differ() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; @@ -455,6 +484,23 @@ fn objects_are_read_within_the_node_budget() { assert!(with_node_budget(3, read).is_ok()); } +/// An Intertwine member's areas of tier 2 and up are written and read back, and each one counts +/// against [`with_node_budget`] as an object does, so a file of many empty areas is refused. +#[test] +fn intertwine_tiers_are_read_within_the_node_budget() { + let mut n = Node::group(NodeId(1), vec![]); + let area = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + n.extras_mut().intertwine_tiers = vec![PathData::default(), area]; + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains("\"intertwine_tiers\""), "{json}"); + let read = || serde_json::from_str::(&json); + assert_eq!(with_node_budget(3, read).unwrap(), n, "the object and its two areas fit a budget of three"); + let why = with_node_budget(2, read).unwrap_err().to_string(); + assert!(why.contains("more than 2 objects"), "{why}"); + let plain = serde_json::to_string(&Node::group(NodeId(1), vec![])).unwrap(); + assert!(!plain.contains("intertwine_tiers"), "an object without tiers writes none: {plain}"); +} + /// #3587: fields read before an object's `type` are buffered value by value within /// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers /// its own fields again without counting them twice. VectorCraft writes `type` first, which diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 76d4c3c14..ed81b1391 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -46,11 +46,13 @@ impl Serialize for Node { e.perspective.is_none(), e.dimension.is_none(), e.intertwine.is_none(), + e.intertwine_tiers.is_empty(), e.live_paint_gaps.is_none(), e.objects_on_path.is_none(), e.colorize.is_none(), is_default(&e.compound_shape), is_default(&e.shape_mode), + e.symbol_overrides.is_empty(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -85,11 +87,13 @@ impl Serialize for Node { field!("perspective", &e.perspective); field!("dimension", &e.dimension); field!("intertwine", &e.intertwine); + field!("intertwine_tiers", &e.intertwine_tiers); field!("live_paint_gaps", &e.live_paint_gaps); field!("objects_on_path", &e.objects_on_path); field!("colorize", &e.colorize); field!("compound_shape", &e.compound_shape); field!("shape_mode", &e.shape_mode); + field!("symbol_overrides", &e.symbol_overrides); st.end() } } @@ -123,11 +127,39 @@ pub fn with_node_budget(max: usize, f: impl FnOnce() -> T) -> T { /// each counts once, and the id usually comes first, before the node's children are read. fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result { let id = NodeId::deserialize(d)?; + count_one()?; + Ok(id) +} + +/// Count one object against [`with_node_budget`]. +fn count_one() -> Result<(), E> { if let Some((left, max)) = NODES_LEFT.get() { - let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?; + let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("the document has more than {max} objects")))?; NODES_LEFT.set(Some((left, max))); } - Ok(id) + Ok(()) +} + +/// 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> { + struct Items(std::marker::PhantomData); + impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(item) = seq.next_element::()? { + count_one()?; + out.push(item); + } + Ok(out) + } + } + d.deserialize_seq(Items(std::marker::PhantomData)) } /// How a [`Node`] is read: the flat field list of the file format. Fields move into the node @@ -186,6 +218,8 @@ pub(super) struct NodeWire { dimension: Option>, #[serde(default)] intertwine: Option>, + #[serde(default, deserialize_with = "counted_list")] + intertwine_tiers: Vec, #[serde(default)] live_paint_gaps: Option>, #[serde(default)] @@ -196,6 +230,8 @@ pub(super) struct NodeWire { compound_shape: bool, #[serde(default)] shape_mode: crate::ShapeMode, + #[serde(default, deserialize_with = "counted_list")] + symbol_overrides: Vec, } impl From for Node { @@ -215,11 +251,13 @@ impl From for Node { perspective: w.perspective, dimension: w.dimension, intertwine: w.intertwine, + intertwine_tiers: w.intertwine_tiers, live_paint_gaps: w.live_paint_gaps, objects_on_path: w.objects_on_path, colorize: w.colorize, compound_shape: w.compound_shape, shape_mode: w.shape_mode, + symbol_overrides: w.symbol_overrides, }; Node { id: w.id, diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index ca2ec7dd9..779e2eced 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -337,7 +337,7 @@ mod tests { // The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square // around it that instances are measured by without the symbols. let mut d = crate::Document::new(400.0, 400.0); - d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) }); let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default()); let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY }); let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index 43c67d86f..114a15290 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -200,7 +200,7 @@ mod tests { fn oriented_bounds_hug_a_rotated_symbol_instances_art() { let mut d = Document::new(500.0, 500.0); let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art()); - d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(bar) }); let (l, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap(); let c = Point::new(120.0, 105.0); diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 4de3823db..6ec18bcfa 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -58,12 +58,14 @@ impl Node { } } - /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). + /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An + /// invertible `a` that the projection cannot follow removes it, and the art is drawn flat. pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) - && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) + && let Some(h) = p.homography() + && a.determinant().abs() >= 1e-15 { - p.projection = Some(h.to_array()); + p.projection = h.conjugated(a).map(|h| h.to_array()); } } } diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 9b00da57e..310d7b784 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -667,7 +667,7 @@ mod tests { pixels: None, })), ); - d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Star".into(), art: Arc::new(art) }); d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3])); let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))]; if bytes.starts_with(b"nest") { @@ -702,7 +702,11 @@ mod tests { fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() { set_loader(stub_loader); let mut d = Document::new(200.0, 200.0); - d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) }); + d.symbols.push(Symbol { + dynamic: false, + name: "Star".into(), + art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })), + }); d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec())); let p = placed("doc1-1"); let layer = d.layers[0].id; diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs index b13a1e955..a6988f502 100644 --- a/crates/doc/src/records.rs +++ b/crates/doc/src/records.rs @@ -1,4 +1,4 @@ -use super::{Arc, Deserialize, Node, NodeId, Point, Serialize}; +use super::{Affine, Arc, Deserialize, Node, NodeId, Point, Serialize}; /// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in /// place, so a selection change that sets it doesn't replace the document. @@ -44,6 +44,29 @@ pub struct MaskEdit { pub layer: NodeId, } +/// Symbol editing mode: the art of symbol `symbol` lives on the temporary `layer` while editing, +/// placed by `xf` (from the symbol's coordinates to the document's), and goes back into the symbol +/// after each edit, so every instance follows it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SymbolEdit { + pub symbol: String, + pub layer: NodeId, + /// The instance editing was entered from (a double-click on it), selected again on leaving after an edit; + /// `None` when it was entered from the Symbols panel. + pub instance: Option, + pub xf: Affine, + /// The symbol's art is a group whose members are on the layer: the group takes them back. + pub group: bool, + /// An edit changed the art since editing was entered. + pub changed: bool, + /// The isolated object from before editing was entered, isolated again on leaving. + #[serde(default)] + pub back_isolation: Option, + /// The current layer from before editing was entered, current again on leaving. + #[serde(default)] + pub back_layer: Option, +} + /// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SavedView { @@ -108,6 +131,10 @@ impl SavedSelection { pub struct Symbol { pub name: String, pub art: Arc, + /// Symbol Options › Symbol Type › Dynamic Symbol: each instance may change the appearance of + /// objects in the art and stay linked (see [`crate::symbol_overrides`]). Static by default. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub dynamic: bool, } /// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 875fcf3a0..c26918153 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -107,14 +107,17 @@ impl Quotes { /// Single-quote pairs offered by Document Setup. pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']]; - /// Is `c` one of these opening quotes? + /// Is `c` one of these opening quotes? A typographic glyph that is also a closing quote does not + /// count; a straight quote has no direction, so it still opens. fn is_open(&self, c: char) -> bool { - c == self.double[0] || c == self.single[0] + (c == self.double[0] || c == self.single[0]) && (c.is_ascii() || (c != self.double[1] && c != self.single[1])) } /// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote. - fn opens(&self, prev: Option) -> bool { - prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p)) + /// `quote_opened` says whether `prev` is a quote this conversion just produced, and if so whether + /// it opened; that settles glyphs which serve as both opening and closing quote. + fn opens(&self, prev: Option, quote_opened: Option) -> bool { + prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p))) } /// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by @@ -122,11 +125,16 @@ impl Quotes { /// and is updated to its last character, so text can be converted in pieces. pub fn apply(&self, text: &str, prev: &mut Option) -> String { let mut out = String::with_capacity(text.len()); + let mut quote_opened = None; for c in text.chars() { - let r = match c { - '"' => self.double[usize::from(!self.opens(*prev))], - '\'' => self.single[usize::from(!self.opens(*prev))], - c => c, + quote_opened = match c { + '"' | '\'' => Some(self.opens(*prev, quote_opened)), + _ => None, + }; + let r = match (c, quote_opened) { + ('"', Some(open)) => self.double[usize::from(!open)], + ('\'', Some(open)) => self.single[usize::from(!open)], + (c, _) => c, }; out.push(r); *prev = Some(r); @@ -135,34 +143,74 @@ impl Quotes { } } -/// Document languages and the quote characters each uses (choosing a language in Document Setup -/// also picks its quotes). -pub const LANGUAGES: &[(&str, Quotes)] = &[ - ("English: USA", Quotes::ENGLISH), - ("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }), - ("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), - ("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }), - ("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }), - ("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }), - ("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }), - ("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }), - ("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }), - ("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }), - ("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), - ("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }), - ("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }), - ("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }), - ("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }), +/// Document and character languages: the name, the BCP 47 tag text is shaped in (so that fonts use +/// their language's forms, OpenType `locl`) and the quote characters each uses (choosing a +/// language in Document Setup also picks its quotes). The first is the default. +pub const LANGUAGES: &[(&str, &str, Quotes)] = &[ + ("English: USA", "en-US", Quotes::ENGLISH), + ("English: UK", "en-GB", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("German", "de", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("German: Swiss", "de-CH", Quotes { double: ['«', '»'], single: ['‹', '›'] }), + ("French", "fr", Quotes { double: ['«', '»'], single: ['‹', '›'] }), + ("Italian", "it", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Spanish", "es", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Catalan", "ca", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Portuguese", "pt", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Dutch", "nl", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("Danish", "da", Quotes { double: ['»', '«'], single: ['›', '‹'] }), + ("Swedish", "sv", Quotes { double: ['”', '”'], single: ['’', '’'] }), + ("Finnish", "fi", Quotes { double: ['”', '”'], single: ['’', '’'] }), + ("Norwegian", "nb", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }), + ("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }), + // „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN + // 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR + // (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`). + ("Serbian: Cyrillic", "sr-Cyrl", Quotes { double: ['„', '”'], single: ['’', '’'] }), + ("Serbian: Latin", "sr-Latn", Quotes { double: ['„', '”'], single: ['’', '’'] }), + // „…“ outside, ‚…‘ inside: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml, + // `delimiters`). + ("Croatian", "hr", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }), + ("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("Hindi", "hi", Quotes::ENGLISH), + ("Marathi", "mr", Quotes::ENGLISH), + ("Gujarati", "gu", Quotes::ENGLISH), + ("Bengali", "bn", Quotes::ENGLISH), + ("Punjabi", "pa", Quotes::ENGLISH), + ("Tamil", "ta", Quotes::ENGLISH), + ("Telugu", "te", Quotes::ENGLISH), + ("Kannada", "kn", Quotes::ENGLISH), + ("Malayalam", "ml", Quotes::ENGLISH), + ("Odia", "or", Quotes::ENGLISH), + ("Urdu", "ur", Quotes::ENGLISH), ]; /// The quotes of a [`LANGUAGES`] entry (case-insensitive). pub fn language_quotes(language: &str) -> Option { - LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q) + LANGUAGES.iter().find(|(l, _, _)| l.eq_ignore_ascii_case(language)).map(|(_, _, q)| *q) +} + +/// The [`LANGUAGES`] entry named or tagged `language` (case-insensitive) as a character's +/// language ([`crate::CharStyle::language`]) stores it: its tag, or `None` for the default (the +/// first entry); `Err` for a language not in the list. +pub fn char_language(language: &str) -> Result, String> { + let i = LANGUAGES + .iter() + .position(|(n, t, _)| n.eq_ignore_ascii_case(language) || t.eq_ignore_ascii_case(language)) + .ok_or_else(|| format!("unknown language `{language}` (one of {})", LANGUAGES.iter().map(|(n, _, _)| *n).collect::>().join(", ")))?; + Ok(LANGUAGES.get(i).filter(|_| i > 0).map(|(_, t, _)| *t)) +} + +/// The name of the language a character's stored `language` tag stands for: the default's for +/// none, `None` for a tag not in [`LANGUAGES`] (from a file). +pub fn char_language_name(language: Option<&str>) -> Option<&'static str> { + match language { + None => LANGUAGES.first().map(|(n, _, _)| *n), + Some(tag) => LANGUAGES.iter().find(|(_, t, _)| t.eq_ignore_ascii_case(tag)).map(|(n, _, _)| *n), + } } /// The transparency flattener preset Document Setup uses unless another is chosen. @@ -300,9 +348,19 @@ impl DocSetup { pub fn keep_in_range(&mut self) { self.bleed = bleed_in_range(self.bleed); for m in [&mut self.superscript, &mut self.subscript] { + // `clamp` passes NaN through, so a non-finite value falls back to the default first. + if !m.size.is_finite() { + m.size = ScriptMetrics::DEFAULT.size; + } + if !m.position.is_finite() { + m.position = ScriptMetrics::DEFAULT.position; + } m.size = m.size.clamp(1.0, MAX_SCRIPT); m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT); } + if !self.small_caps_size.is_finite() { + self.small_caps_size = DocSetup::default().small_caps_size; + } self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS); } } @@ -311,6 +369,31 @@ impl DocSetup { mod tests { use super::*; + /// Characters store a language of the list by its tag, the default as none. + #[test] + fn char_languages_are_stored_by_tag() { + assert_eq!(char_language("Turkish"), Ok(Some("tr"))); + assert_eq!(char_language("MR"), Ok(Some("mr"))); + assert_eq!(char_language("english: usa"), Ok(None)); + assert_eq!(char_language("en-US"), Ok(None)); + assert!(char_language("Klingon").is_err() && char_language("").is_err()); + assert_eq!( + (char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))), + (Some("English: USA"), Some("Gujarati"), None) + ); + // Serbian in either script, and Croatian. + assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl"))); + assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn"))); + assert_eq!(char_language("Croatian"), Ok(Some("hr"))); + assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic")); + // Every entry has a tag of its own, and a name of its own. + for (i, (n, t, _)) in LANGUAGES.iter().enumerate() { + assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}"); + } + } + + /// Straight quotes become the language's: opening or closing by what comes before, inner + /// quotes as single ones, and the context carried across pieces converted apart. #[test] fn quotes_open_and_close_by_context() { let mut prev = None; @@ -319,6 +402,13 @@ mod tests { let mut prev = Some('('); assert_eq!(german.apply("\"a\"", &mut prev), "„a“"); assert_eq!(prev, Some('“')); + // Serbian closes high (”), with ’ on both sides of an inner quote; Croatian as German. + for name in ["Serbian: Cyrillic", "Serbian: Latin"] { + let mut prev = None; + assert_eq!(language_quotes(name).unwrap().apply("\"a 'b' c\"", &mut prev), "„a ’b’ c”", "{name}"); + } + let mut prev = None; + assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“"); // Converting in pieces keeps the context. let mut prev = None; let a = Quotes::ENGLISH.apply("\"", &mut prev); @@ -326,6 +416,18 @@ mod tests { assert_eq!((a.as_str(), b.as_str()), ("“", "“")); } + #[test] + fn closing_quote_equal_to_other_kinds_opening_quote_still_closes() { + let q = Quotes { double: ['”', '”'], single: ['‘', '’'] }; + let mut prev = None; + assert_eq!(q.apply("'\"a\"'", &mut prev), "‘”a”’"); + let q = Quotes { double: ['"', '"'], single: ['‘', '’'] }; + let mut prev = None; + assert_eq!(q.apply("'\"a\"'", &mut prev), "‘\"a\"’"); + let mut prev = None; + assert_eq!(q.apply("\"'a'\"", &mut prev), "\"‘a’\""); + } + #[test] fn bleed_grows_the_artboard() { let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() }; @@ -333,6 +435,20 @@ mod tests { assert!(s.has_bleed() && !DocSetup::default().has_bleed()); } + #[test] + fn keep_in_range_replaces_non_finite_type_values() { + let mut s = DocSetup::default(); + s.superscript.size = f64::NAN; + s.superscript.position = f64::INFINITY; + s.subscript.size = f64::NEG_INFINITY; + s.subscript.position = f64::NAN; + s.small_caps_size = f64::NAN; + s.keep_in_range(); + for v in [s.superscript.size, s.superscript.position, s.subscript.size, s.subscript.position, s.small_caps_size] { + assert!(v.is_finite(), "{v}"); + } + } + #[test] fn presets_and_defaults() { let d = DocSetup::default(); diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs new file mode 100644 index 000000000..b4e3a726f --- /dev/null +++ b/crates/doc/src/symbol_overrides.rs @@ -0,0 +1,190 @@ +//! Dynamic symbols (Symbol Options › Symbol Type › Dynamic Symbol): an instance of a dynamic +//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays +//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops +//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and +//! applied wherever an instance is drawn or exported ([`dynamic_art`]). + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use vectorcraft_color::{BlendMode, Paint}; + +use crate::{Node, NodeKind}; + +/// Deepest object an override may name: groups nested this deep in the symbol's art. +pub const MAX_PATH_DEPTH: usize = 64; + +/// One instance's appearance changes to one object of its dynamic symbol's art. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SymbolOverride { + /// The object: child indices (paint order) from the art's root down through its groups; empty + /// for the root itself. + #[serde(deserialize_with = "short_path")] + pub path: Vec, + /// The object's (top) fill paint; type takes it as its characters' fill. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill: Option, + /// The object's (top) stroke paint; type takes it as its characters' stroke. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke: Option, + /// The object's opacity, 0..1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// The object's blending mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blend: Option, +} + +impl SymbolOverride { + /// Does this override change nothing? + pub fn is_empty(&self) -> bool { + self.fill.is_none() && self.stroke.is_none() && self.opacity.is_none() && self.blend.is_none() + } + + /// Put this override's changes on object `n`. + fn apply(&self, n: &mut Node) { + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + if let Some(p) = &self.fill { + r.style.fill = p.clone(); + } + if let Some(p) = &self.stroke { + r.style.stroke = p.clone(); + } + } + } else { + if let Some(p) = &self.fill { + n.appearance.set_fill(p.clone()); + } + if let Some(p) = &self.stroke { + n.appearance.set_stroke(p.clone()); + } + } + if let Some(o) = self.opacity.filter(|o| o.is_finite()) { + n.opacity = o.clamp(0.0, 1.0); + } + if let Some(b) = self.blend { + n.blend = b; + } + } +} + +/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file +/// of one endless path stays small once read. +fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct Path; + impl<'de> serde::de::Visitor<'de> for Path { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of child indices") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(i) = seq.next_element::()? { + if out.len() == MAX_PATH_DEPTH { + return Err(serde::de::Error::custom(format!("a symbol override path is deeper than {MAX_PATH_DEPTH}"))); + } + out.push(i); + } + Ok(out) + } + } + d.deserialize_seq(Path) +} + +/// The children a path steps through: a group's (clip groups included) or a layer's. +fn groups_children(n: &Node) -> Option<&Vec>> { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }).then(|| n.children()).flatten() +} + +/// The object `path` names in symbol art `art`, if there is one. +pub fn part<'a>(art: &'a Node, path: &[u32]) -> Option<&'a Node> { + if path.len() > MAX_PATH_DEPTH { + return None; + } + path.iter().try_fold(art, |n, &i| groups_children(n)?.get(i as usize).map(|c| &**c)) +} + +/// [`part`], to change (the nodes on the way are copied when shared). +fn part_mut<'a>(art: &'a mut Node, path: &[u32]) -> Option<&'a mut Node> { + let Some((&i, rest)) = path.split_first() else { return Some(art) }; + if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) { + return None; + } + let c = art.children_mut()?.get_mut(i as usize)?; + part_mut(Arc::make_mut(c), rest) +} + +/// Put `overrides` on symbol art `art`; an override naming no object of it is left out. +pub fn apply(art: &mut Node, overrides: &[SymbolOverride]) { + for o in overrides.iter().filter(|o| o.path.len() <= MAX_PATH_DEPTH) { + if let Some(n) = part_mut(art, &o.path) { + o.apply(n); + } + } +} + +/// Symbol art `art` as instance `inst` shows it: a copy with the instance's overrides applied. +pub fn dynamic_art(art: &Node, inst: &Node) -> Node { + let mut a = art.clone(); + apply(&mut a, &inst.extras().symbol_overrides); + a +} + +/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest. +pub fn retain_parts(overrides: &mut Vec, art: &Node) { + overrides.retain(|o| part(art, &o.path).is_some()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Appearance, NodeId}; + use vectorcraft_color::Color; + use vectorcraft_geom::{Rect, shapes}; + + fn square(id: u64) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())) + } + + fn art() -> Node { + let inner = Node::group(NodeId(3), vec![square(4), square(5)]); + Node::group(NodeId(1), vec![square(2), Arc::new(inner)]) + } + + #[test] + fn paths_name_objects_through_groups_only() { + let a = art(); + assert_eq!(part(&a, &[]).map(|n| n.id), Some(NodeId(1))); + assert_eq!(part(&a, &[1, 0]).map(|n| n.id), Some(NodeId(4))); + assert!(part(&a, &[2]).is_none()); + assert!(part(&a, &[0, 0]).is_none(), "a path has no children"); + assert!(part(&a, &[0; MAX_PATH_DEPTH + 1]).is_none()); + } + + #[test] + fn overrides_paint_their_object_and_skip_missing_ones() { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let o = vec![ + SymbolOverride { path: vec![1, 1], fill: Some(red.clone()), opacity: Some(0.5), ..Default::default() }, + SymbolOverride { path: vec![9], fill: Some(red.clone()), ..Default::default() }, + ]; + let mut a = art(); + apply(&mut a, &o); + let n = part(&a, &[1, 1]).unwrap(); + assert_eq!(n.appearance.fill_paint(), red); + assert_eq!(n.opacity, 0.5); + assert_eq!(part(&a, &[1, 0]).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE), "its sibling keeps the symbol's look"); + let mut kept = o; + retain_parts(&mut kept, &a); + assert_eq!(kept.len(), 1); + } + + #[test] + fn a_path_deeper_than_the_cap_does_not_read() { + let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]); + assert!(serde_json::from_str::(&deep).is_err()); + let ok: SymbolOverride = serde_json::from_str("{\"path\":[1,0],\"opacity\":0.25}").unwrap(); + assert_eq!(ok, SymbolOverride { path: vec![1, 0], opacity: Some(0.25), ..Default::default() }); + } +} diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 8f095566b..bb3cbf11f 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -410,7 +410,7 @@ mod tests { /// Symbol `name` holding `art` in a group. fn symbol(d: &mut Document, name: &str, art: Vec) { let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect()); - d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) }); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) }); } #[test] diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a13fb2168..37e2d39bc 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -55,6 +55,29 @@ fn doc_with_rects() -> (Document, NodeId, NodeId) { (d, a, b) } +/// Output draws the background colors of the artboards asked for, and only theirs, as rectangles +/// of those artboards on a layer below the art; without a color there is nothing to draw. +#[test] +fn artboard_fills_go_below_the_art() { + let (mut d, a, _) = doc_with_rects(); + assert!(d.with_artboard_fills([0]).is_none(), "no color, no fill"); + let second = Rect::new(700.0, 0.0, 800.0, 100.0); + d.artboards.push(Artboard { id: 2, rect: second, ..d.artboards[0].clone() }); + let blue = vectorcraft_color::Color::cmyk(1.0, 0.5, 0.0, 0.0); + d.artboards[1].color = Some(blue); + assert!(d.with_artboard_fills([0]).is_none(), "only the artboards asked for"); + let out = d.with_artboard_fills([0, 1]).unwrap(); + assert_eq!(out.layers.len(), d.layers.len() + 1); + assert_eq!(out.layers[1..], d.layers[..], "the art stays as it was, above the fills"); + let fills = out.layers[0].children().unwrap(); + assert_eq!(out.layers[0].display_name(), "Artboard Fill"); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].geometric_bounds(), Some(second)); + assert_eq!(fills[0].appearance.fill_paint(), vectorcraft_color::Paint::solid(blue), "in the color's own model"); + assert!(out.node(a).is_some() && out.node(fills[0].id).is_some() && fills[0].id != a); + assert_eq!(d.artboards, out.artboards); +} + #[test] fn new_document_has_layer_and_artboard() { let d = Document::new(612.0, 792.0); @@ -202,7 +225,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { fn art_on_artboard_measures_a_symbol_instance_by_its_art() { let mut d = Document::new(612.0, 792.0); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) }); let (layer, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap(); // The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't. @@ -388,6 +411,18 @@ fn char_position_round_trips() { assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); } +#[test] +fn optical_kerning_round_trips_and_old_style_defaults_to_auto() { + let st = CharStyle { optical_kerning: true, kerning: Some(120.0), ..CharStyle::default() }; + let value = serde_json::to_value(&st).unwrap(); + assert_eq!(value["opticalKerning"], true); + assert_eq!(serde_json::from_value::(value.clone()).unwrap(), st); + let mut old = value; + old.as_object_mut().unwrap().remove("opticalKerning"); + assert!(!serde_json::from_value::(old).unwrap().optical_kerning); + assert!(serde_json::to_value(CharStyle::default()).unwrap().get("opticalKerning").is_none()); +} + #[test] fn units() { assert_eq!(Unit::Inches.parse("1"), Some(72.0)); diff --git a/crates/doc/src/tests/units.rs b/crates/doc/src/tests/units.rs index 909a0adf3..88075a5dc 100644 --- a/crates/doc/src/tests/units.rs +++ b/crates/doc/src/tests/units.rs @@ -144,3 +144,10 @@ fn mixed_mul_div() { assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0)); assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0))); } + +#[test] +fn readout_has_no_negative_zero() { + assert_eq!(Unit::Points.readout(-0.001), "0.00 pt"); + assert_eq!(Unit::Points.readout(-0.0), "0.00 pt"); + assert_eq!(Unit::Points.readout(-12.5), "-12.50 pt"); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a3ab2ccc5..675245b56 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -13,9 +13,9 @@ mod para_style; mod runs; mod tabs_lists; -pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextWrap, VerticalAlign, WrapShape}; +pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index b30afd412..f53a48857 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -29,6 +29,19 @@ impl VerticalAlign { } } +/// Area Type Options › Text Flow: the order text fills the cells of area type with several rows +/// and columns. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextFlow { + /// Down each column's rows, then on to the next column (the default, and what a document + /// from before the option reads as). + #[default] + Columns, + /// Across each row's columns, then on to the next row. + Rows, +} + /// Text Wrap Options of a wrap object (Object → Text Wrap). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -135,6 +148,9 @@ pub struct AreaOptions { pub first_baseline_min: f64, /// Vertical alignment of the lines in each row/column. pub vertical_align: VerticalAlign, + /// Text Flow: by columns (the default, left out when saved) or by rows. + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub flow: TextFlow, /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). #[serde(skip_serializing_if = "crate::skip::is_default")] pub fit: AreaFit, @@ -150,6 +166,7 @@ impl Default for AreaOptions { first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, vertical_align: VerticalAlign::Top, + flow: TextFlow::Columns, fit: AreaFit::None, } } diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 0e3aea70e..b5bc7e065 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -25,9 +25,12 @@ pub struct CharStyle { /// Tracking in 1/1000 em. #[serde(default)] pub tracking: f64, - /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. + /// Manual pair spacing in 1/1000 em; 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. + #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] + pub optical_kerning: bool, #[serde(default)] pub baseline_shift: f64, #[serde(default = "hundred")] @@ -101,21 +104,55 @@ pub struct CharStyle { /// #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] pub tate_chu_yoko: bool, + /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): in vertical + /// type the half-width characters among these (Latin letters, digits and other characters that + /// would lie on their side) stand upright one below the other instead of being rotated + /// individually. Spaces and tate-chu-yoko blocks do not change, and horizontal type keeps the + /// mark and does not change. + /// + #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")] + pub standard_vertical_roman: bool, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub digits: Digits, + /// Font height reference (Character panel menu › Show Font Height Options): the height of the + /// font that [`Self::size`] measures, the em box by default. + #[serde(default, rename = "fontHeightReference", skip_serializing_if = "crate::skip::is_default")] + pub font_height_reference: FontHeightReference, /// No Break (Character panel menu): these characters are not broken across lines, including /// at a space or a hyphenation point inside the run. A run longer than the line stays together /// and can overflow it. /// #[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")] pub no_break: bool, + /// Language (Character panel): the BCP 47 tag of one of [`crate::setup::LANGUAGES`] these + /// characters are shaped in, so that the font uses that language's forms (OpenType `locl`). + /// `None`: the default language, English: USA, shaped without a language. New type takes the + /// document's language (Document Setup). Saved only when set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, +} + +/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square +/// of the font, the height of its capitals or of its lowercase letters (the cap height and x +/// height of its OS/2 table), or its ideographic character face (ICF, OpenType's `icfb` and +/// `icft` baselines). Layout sets the characters at the em size at which the chosen height equals +/// the font size; with a font that has no such value, the size measures the em box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FontHeightReference { + #[default] + EmBox, + CapHeight, + XHeight, + IcfBox, } /// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of -/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps -/// the digits that were typed; only the glyphs change (like All Caps). +/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits +/// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like +/// All Caps). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Digits { @@ -128,9 +165,68 @@ pub enum Digits { Hindi, /// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu. Farsi, + /// Devanagari digits ०–९ (U+0966–U+096F), used in Hindi, Marathi, Nepali and Sanskrit. + Devanagari, + /// Bengali digits ০–৯ (U+09E6–U+09EF), used in Bengali and Assamese. + Bengali, + /// Gurmukhi digits ੦–੯ (U+0A66–U+0A6F), used in Punjabi. + Gurmukhi, + /// Gujarati digits ૦–૯ (U+0AE6–U+0AEF). + Gujarati, + /// Odia digits ୦–୯ (U+0B66–U+0B6F). + Odia, + /// Tamil digits ௦–௯ (U+0BE6–U+0BEF). + Tamil, + /// Telugu digits ౦–౯ (U+0C66–U+0C6F). + Telugu, + /// Kannada digits ೦–೯ (U+0CE6–U+0CEF). + Kannada, + /// Malayalam digits ൦–൯ (U+0D66–U+0D6F). + Malayalam, } impl Digits { + /// Every numeral system, in menu order. + pub const ALL: [Digits; 13] = [ + Digits::Default, + Digits::Arabic, + Digits::Hindi, + Digits::Farsi, + Digits::Devanagari, + Digits::Bengali, + Digits::Gurmukhi, + Digits::Gujarati, + Digits::Odia, + Digits::Tamil, + Digits::Telugu, + Digits::Kannada, + Digits::Malayalam, + ]; + + /// The name files and commands give this numeral system (as it is saved). + pub fn id(self) -> &'static str { + match self { + Digits::Default => "default", + Digits::Arabic => "arabic", + Digits::Hindi => "hindi", + Digits::Farsi => "farsi", + Digits::Devanagari => "devanagari", + Digits::Bengali => "bengali", + Digits::Gurmukhi => "gurmukhi", + Digits::Gujarati => "gujarati", + Digits::Odia => "odia", + Digits::Tamil => "tamil", + Digits::Telugu => "telugu", + Digits::Kannada => "kannada", + Digits::Malayalam => "malayalam", + } + } + + /// The numeral system [`Digits::id`] names; `None` for any other name. + pub fn from_id(id: &str) -> Option { + Digits::ALL.into_iter().find(|d| d.id() == id) + } + /// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c` /// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set. pub fn map(self, c: char) -> char { @@ -145,6 +241,15 @@ impl Digits { Digits::Arabic => '0' as u32, Digits::Hindi => 0x660, Digits::Farsi => 0x6F0, + Digits::Devanagari => 0x966, + Digits::Bengali => 0x9E6, + Digits::Gurmukhi => 0xA66, + Digits::Gujarati => 0xAE6, + Digits::Odia => 0xB66, + Digits::Tamil => 0xBE6, + Digits::Telugu => 0xC66, + Digits::Kannada => 0xCE6, + Digits::Malayalam => 0xD66, }; char::from_u32(base + value).unwrap_or(c) } @@ -266,6 +371,7 @@ impl Default for CharStyle { leading: None, tracking: 0.0, kerning: None, + optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, v_scale: 100.0, @@ -290,9 +396,12 @@ impl Default for CharStyle { char_direction: None, proportional_metrics: false, tate_chu_yoko: false, + standard_vertical_roman: false, small_caps: None, digits: Digits::Default, + font_height_reference: FontHeightReference::EmBox, no_break: false, + language: None, } } } diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 47106d788..b66795e89 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -223,6 +223,7 @@ impl ParaList { position: CharPosition::Normal, tracking: 0.0, kerning: None, + optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, strikethrough: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 54142efd7..04deae8ee 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -60,6 +60,69 @@ fn digits_map_between_the_numeral_systems() { assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); } +/// The font height reference is the em box by default and is saved only when it is another. +#[test] +fn font_height_reference_is_saved_only_when_not_the_em_box() { + let st = CharStyle::default(); + assert_eq!(st.font_height_reference, FontHeightReference::EmBox); + assert!(serde_json::to_value(&st).unwrap().get("fontHeightReference").is_none()); + for (r, key) in + [(FontHeightReference::CapHeight, "capHeight"), (FontHeightReference::XHeight, "xHeight"), (FontHeightReference::IcfBox, "icfBox")] + { + let set = CharStyle { font_height_reference: r, ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["fontHeightReference"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + // Styles written before the attribute existed read as the em box. + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().font_height_reference, FontHeightReference::EmBox); +} + +/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its +/// script's block; every system saves under its id, which reads back, and other names are refused. +#[test] +fn indic_digits_map_to_their_scripts_block() { + let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; + for (d, zero) in [ + (Digits::Devanagari, 0x966), + (Digits::Bengali, 0x9E6), + (Digits::Gurmukhi, 0xA66), + (Digits::Gujarati, 0xAE6), + (Digits::Odia, 0xB66), + (Digits::Tamil, 0xBE6), + (Digits::Telugu, 0xC66), + (Digits::Kannada, 0xCE6), + (Digits::Malayalam, 0xD66), + ] { + let block: String = (zero..zero + 10).map(|u| char::from_u32(u).unwrap()).collect(); + assert!(block.chars().all(char::is_numeric), "{d:?}: {block}"); + for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { + assert_eq!(map(d, s), block, "{d:?}"); + } + assert_eq!(map(d, "a-Z ٪"), "a-Z ٪"); + } + assert_eq!(map(Digits::Devanagari, "page 12"), "page १२"); + for d in Digits::ALL { + assert_eq!(serde_json::to_value(d).unwrap(), d.id()); + assert_eq!(serde_json::from_value::(d.id().into()).unwrap(), d); + assert_eq!(Digits::from_id(d.id()), Some(d)); + } + assert_eq!(Digits::from_id("roman"), None); + assert!(serde_json::from_value::("roman".into()).is_err()); +} + +/// Language is saved only when set; styles written before it existed read as the default. +#[test] +fn language_is_saved_only_when_set() { + let st = CharStyle::default(); + let old = serde_json::to_value(&st).unwrap(); + assert!(old.get("language").is_none()); + assert_eq!(serde_json::from_value::(old).unwrap().language, None); + let turkish = CharStyle { language: Some("tr".into()), ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&turkish).unwrap()["language"], "tr"); + assert_eq!(serde_json::from_value::(serde_json::to_value(&turkish).unwrap()).unwrap(), turkish); +} + /// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. #[test] fn paragraph_effective_leading_follows_auto_leading() { @@ -248,6 +311,19 @@ fn paragraph_styles_split_merge_and_normalize() { assert_eq!(back.para_at(3).space_before, 4.0); } +/// Text Flow is saved only when by rows: area type by columns (the default) saves as it did before +/// the option, and files from before it read by columns; other values don't read. +#[test] +fn text_flow_is_saved_only_when_by_rows() { + let by_columns = serde_json::to_value(AreaOptions::default()).unwrap(); + assert!(by_columns.get("flow").is_none()); + assert_eq!(serde_json::from_value::(by_columns).unwrap().flow, TextFlow::Columns); + let by_rows = serde_json::to_value(AreaOptions { flow: TextFlow::Rows, ..AreaOptions::default() }).unwrap(); + assert_eq!(by_rows["flow"], "rows"); + assert_eq!(serde_json::from_value::(by_rows).unwrap().flow, TextFlow::Rows); + assert!(serde_json::from_value::(serde_json::json!({"flow": "diagonal"})).is_err()); +} + /// 120 × 40 area type at (40, 40), its text drawn at twice its size. fn area_type() -> TextObject { let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs index 2c1fc8653..6a5edf75a 100644 --- a/crates/doc/src/units.rs +++ b/crates/doc/src/units.rs @@ -161,7 +161,10 @@ impl Unit { /// A point value as the canvas measurement labels show it: two decimals and the suffix /// (`12.50 mm`). pub fn readout(self, pt: f64) -> String { - format!("{:.2} {}", self.from_pt(pt), self.suffix()) + let s = format!("{:.2}", self.from_pt(pt)); + // A small negative value rounds to `-0.00`; show it as `0.00`. + let s = if s == "-0.00" { "0.00" } else { &s }; + format!("{} {}", s, self.suffix()) } } diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index 93601680f..a656f11e8 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -473,8 +473,8 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> 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| s.art.clone()); - art = crate::reshape::as_art(Some(d), n, symbol.as_deref())?; + let symbol = 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 } _ => n, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index e713e0ffa..ee8aba6c2 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -122,10 +122,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return Some(bake_pieces(d, m)); } let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)) + } _ => None, }; - let m = reshape(Some(d), n, symbol.as_deref())?; + let m = reshape(Some(d), n, symbol.as_ref())?; return Some(bake_node(d, &m).unwrap_or(m)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index aad8d8edd..769eae6b5 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -24,7 +24,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option { match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?; - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); Some(Node::group(n.id, vec![Arc::new(art)])) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 198da329e..cff94b0f4 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1191,10 +1191,10 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { let instance = |name: &str| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })); let mut d = Document::new(100.0, 100.0); // A 500 × 500 grid as a symbol's art, directly and inside a group; a symbol holding itself. - d.symbols.push(Symbol { name: "Grid".into(), art: repeat(grid(unit(), 500)) }); - d.symbols.push(Symbol { name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); - d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); - d.symbols.push(Symbol { name: "Small".into(), art: repeat(grid(unit(), 3)) }); + d.symbols.push(Symbol { dynamic: false, name: "Grid".into(), art: repeat(grid(unit(), 500)) }); + d.symbols.push(Symbol { dynamic: false, name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); + d.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); + d.symbols.push(Symbol { dynamic: false, name: "Small".into(), art: repeat(grid(unit(), 3)) }); // A 3 × 3 grid of an instance of each: each copy draws 250,000 instances, so one copy fits. for name in ["Grid", "Grouped"] { let outer = Node::new(NodeId(0), NodeKind::Repeat(grid(instance(name), 3))); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f495697ce..9d4ad7399 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,9 +36,12 @@ vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } +hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } flate2 = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). roxmltree = "0.21" +# The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). +cfb = "0.15" kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index dc047d9d6..31d0ca2cb 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -84,9 +84,11 @@ mod style; pub mod stylelib; mod swatch; pub mod swatchlib; +pub(crate) mod symboledit; pub mod symbollib; pub(crate) mod tabs; mod textedit; +pub use textedit::effective_kerning; pub mod textstyles; pub(crate) mod textwrap; pub(crate) mod threads; @@ -182,20 +184,26 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } } -/// A document is open and it is not in isolation mode: Object › Lock's and Object › Hide's items, -/// Unlock All and Show All are off in isolation mode. Pattern editing mode and opacity mask -/// editing isolate their art too, and do not count as isolation mode here while their own layer -/// is the isolated one. An object isolated inside them counts. -pub fn outside_isolation(s: &Session) -> std::result::Result<(), String> { - let st = s.active().ok_or("no document open")?; - let editing_layer = |l| st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == l) || st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == l); - if st.isolation.is_some_and(|l| !editing_layer(l)) { Err("exit isolation mode first".into()) } else { Ok(()) } +/// The isolated group, layer or object in isolation mode. Pattern editing mode, opacity mask +/// editing and symbol editing mode isolate their own layer, which does not count here; an object +/// isolated inside them counts. +pub(crate) fn ordinary_isolation(st: &crate::DocState) -> Option { + st.isolation.filter(|l| { + !st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l) + && !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l) + && !st.doc.symbol_edit.as_ref().is_some_and(|e| e.layer == *l) + }) } -/// Objects are selected ([`has_selection`]) and the document is not in isolation mode -/// ([`outside_isolation`]). -pub fn has_selection_outside_isolation(s: &Session) -> std::result::Result<(), String> { - outside_isolation(s)?; - has_selection(s) +/// Objects are selected ([`has_selection`]), and in isolation mode ([`ordinary_isolation`]) at +/// least one of them is inside the isolated object: Object › Lock's and Object › Hide's items. +pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + let Some(root) = ordinary_isolation(st) else { return has_selection(s) }; + if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) } +} +/// Whether `id` lies inside the isolated object `root` (the root itself does not). +pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool { + id != root && d.ancestry(id).is_some_and(|a| a.contains(&root)) } pub fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index ca6625540..21d73cc4f 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -1,8 +1,10 @@ //! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own //! with commands under `addon..*` (see `docs/mcp.md`). +mod arrange; mod cleanup; mod color; +mod document; mod pathtools; mod pen; @@ -10,8 +12,10 @@ use super::CommandSpec; /// The commands of every built-in add-on. pub fn specs() -> Vec { - let mut v = cleanup::specs(); + let mut v = arrange::specs(); + v.extend(cleanup::specs()); v.extend(color::specs()); + v.extend(document::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); v diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs new file mode 100644 index 000000000..735d72133 --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -0,0 +1,535 @@ +//! Arrange Tools (`addon.arrange.*`), a built-in add-on: Arrange in Grid lays the selected objects +//! out in rows and columns, a row or a column; Swap Objects swaps the places (and sizes) of two; +//! Replace with Top Object puts a copy of the topmost selected object in place of each of the +//! others; and Set Size of Each gives every selected object an exact width and height. Places and +//! sizes are measured and set as `object.setBounds` does. Group Overlapping Objects groups the +//! objects whose visual bounds overlap, Randomize Opacity gives each selected object a random +//! opacity, and Sort Objects sorts the selected objects by one attribute and gives them another in +//! that order. Its submenu (Object › Plug-ins › Arrange Tools), its dialogs and Zoom to Selection +//! (a view command) are the UI's. + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::recolor::Rng; +use vectorcraft_doc::{Node, NodeId, OrientedBox}; +use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_render::effects; + +use super::super::edit::selected_roots; +use super::super::menucmds::whole_or; +use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; +use super::super::typecmd::refresh_bounds_of; +use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_multi, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// The largest gap, width or height (pt) the commands take. +const LIMIT: f64 = 1e6; +/// The smallest width or height (pt) Set Size of Each takes. +const LEAST: f64 = 0.001; +/// The most columns Arrange in Grid takes. +const MAX_COLUMNS: u64 = 10_000; +/// The most objects Group Overlapping Objects and Sort Objects look at. +const MAX_OBJECTS: usize = 20_000; +/// The attributes Sort Objects sorts by and gives (`addon.arrange.sort`). +pub const SORT_KEYS: [&str; 7] = ["width", "height", "area", "x", "y", "opacity", "stacking"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.arrange.grid", + "Arrange in Grid", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{layout?: \"grid\" (rows and columns) | \"row\" (one row) | \"column\" (one column), columns?: 1..10000 (grid; default ⌈√count⌉; never more than the count), gapX?: pt −1000000..1000000 (10), gapY?: pt −1000000..1000000 (gapX), centerOnArtboard?: false (true: the grid is centred on the artboard under the selection's centre, else the first; false: it starts at the selection's top-left)} lay the selected objects (the top-level ones) out in their stacking order from the back, left to right and top to bottom, as one undo step. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, each object is centred in its cell, and everything is measured by visual bounds (strokes and effects as drawn) → {count}", + has_selection, + grid + ), + cmd!( + "addon.arrange.swap", + "Swap Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{size?: false (true: each also takes the other's width and height), scaleStrokes?: the Scale Strokes & Effects preference} swap the places (bounds centres) of the two selected objects, measured as object.setBounds measures them (Use Preview Bounds), as one undo step. Off unless exactly two objects are selected", + exactly_two, + swap + ), + cmd!( + "addon.arrange.replace", + "Replace with Top Object", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{fit?: \"keep\" (the copy keeps its size) | \"bounds\" (scaled to the replaced object's width and height) | \"proportional\" (scaled to fit inside them, in proportion) (\"keep\"), scaleStrokes?: the Scale Strokes & Effects preference, keep?: false (true: the replaced objects stay, under their copies)} put a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order, as one undo step; sizes and centres are measured as object.setBounds measures them, and the copies are selected → {ids}", + has_multi, + replace + ), + cmd!( + "addon.arrange.size", + "Set Size of Each", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{width?: pt, height?: pt (at least one; each 0.001..1000000), proportional?: false (with only one of them given, the other follows in proportion; else it stays), reference?: 0..8 (the 9-point grid row by row from the top-left; 4, the centre: the point of each object that stays put), scaleStrokes?: the Scale Strokes & Effects preference} give each selected object (the top-level ones) that width and height, measured as object.setBounds measures them (Use Preview Bounds), as one undo step → {count}", + has_selection, + size + ), + cmd!( + "addon.arrange.groupOverlapping", + "Group Overlapping Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{} group the selected objects (the top-level ones, at most 20000) wherever their visual bounds overlap or touch, chains included (when A overlaps B and B overlaps C, all three go in one group), as one undo step. Only objects in the same layer or group are grouped together, objects that overlap nothing stay as they are, and each new group goes where its frontmost member was in the stacking order; the new groups and the objects left alone stay selected → {groups: [id…], grouped (how many objects went into groups)}", + has_multi, + group_overlapping + ), + cmd!( + "addon.arrange.randomizeOpacity", + "Randomize Opacity", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}", + has_selection, + randomize_opacity + ), + cmd!( + "addon.arrange.sort", + "Sort Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + has_multi, + sort + ), + ] +} + +/// Exactly two objects are selected (Swap Objects). +fn exactly_two(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let n = selected_roots(s).map_err(|e| e.to_string())?.len(); + if n == 2 { Ok(()) } else { Err("select exactly two objects".into()) } +} + +/// Number param `key` (`default` when left out) within `lo..=hi`. +fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str) -> Result { + let v = number_or(p, key, default, cmd)?; + if (lo..=hi).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// Whole-number param `key` within `lo..=hi`, `None` when left out. +pub(super) fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?; + if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// A width or height param of Set Size of Each, `None` when left out. +fn size_param(p: &Value, key: &str, cmd: &str) -> Result> { + p.get(key).filter(|v| !v.is_null()).map(|_| number_in(p, key, 0.0, (LEAST, LIMIT), cmd)).transpose() +} + +/// `object.setBounds` params that centre an object on `at`, with Scale Strokes & Effects `strokes` +/// and, when given, a width and height. +fn place(at: Point, size: Option<(f64, f64)>, strokes: bool) -> Value { + let mut q = json!({ "x": at.x, "y": at.y, "reference": 4, "strokes": strokes }); + if let Some((w, h)) = size { + q["width"] = json!(w); + q["height"] = json!(h); + } + q +} + +/// Transform each object of `xfs` by its own transform as undo step `label`, strokes and effects +/// scaling with Scale Strokes & Effects `strokes`, pattern tiles as Transform Pattern Tiles says. +/// When every transform is (within rounding) the identity, nothing changes. +fn transform_each(s: &mut Session, label: &str, strokes: bool, xfs: &[(NodeId, Affine)]) -> Result<()> { + let still = |xf: &Affine| xf.as_coeffs().iter().zip(Affine::IDENTITY.as_coeffs()).all(|(a, b)| (a - b).abs() <= 1e-9); + if xfs.iter().all(|(_, xf)| still(xf)) { + return Ok(()); + } + let p = json!({ "strokes": strokes }); + let ids: Vec = xfs.iter().map(|(id, _)| *id).collect(); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &ids)?; + s.edit(label, |d, _| { + for (id, xf) in xfs { + if let Some(n) = d.node_mut(*id) { + transform_node(n, *xf, sc, true); + } + } + Ok(()) + }) +} + +/// The box of object `id` as `object.setBounds` measures it. +fn box_of(s: &Session, id: NodeId) -> Result { + s.transform_box(&[id]).ok_or_else(|| EngineError::Other("no bounds".into())) +} + +fn grid(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.grid"; + let layout = choice(p, C, "layout", &["grid", "row", "column"])?.unwrap_or("grid"); + let columns = whole_in(p, "columns", (1, MAX_COLUMNS), C)?; + let gap_x = number_in(p, "gapX", 10.0, (-LIMIT, LIMIT), C)?; + let gap_y = number_in(p, "gapY", gap_x, (-LIMIT, LIMIT), C)?; + let center = switch_or(p, "centerOnArtboard", false, C)?; + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let items: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], true)?))).collect(); + let Some(all) = items.iter().map(|(_, b)| *b).reduce(|a, b| a.union(b)) else { return Ok(json!({ "count": 0 })) }; + let n = items.len(); + let cols = match layout { + "row" => n, + "column" => 1, + _ => columns.map_or_else(|| (n as f64).sqrt().ceil() as usize, |c| usize::try_from(c).unwrap_or(n)), + } + .clamp(1, n); + let mut widths = vec![0.0_f64; cols]; + let mut heights = vec![0.0_f64; n.div_ceil(cols)]; + for (i, (_, b)) in items.iter().enumerate() { + if let Some(w) = widths.get_mut(i % cols) { + *w = w.max(b.width()); + } + if let Some(h) = heights.get_mut(i / cols) { + *h = h.max(b.height()); + } + } + let total = |sizes: &[f64], gap: f64| sizes.iter().sum::() + gap * sizes.len().saturating_sub(1) as f64; + let origin = if center { + let board = d.artboards.get(d.artboard_at(all.center()).unwrap_or(0)).map_or(all, |a| a.rect); + Point::new(board.center().x - total(&widths, gap_x) / 2.0, board.center().y - total(&heights, gap_y) / 2.0) + } else { + Point::new(all.x0, all.y0) + }; + // The middle of each column (row): the sizes and gaps before it, and half its own size. + let middles = |sizes: &[f64], gap: f64, from: f64| -> Vec { + sizes + .iter() + .scan(from, |at, size| { + let mid = *at + size / 2.0; + *at += size + gap; + Some(mid) + }) + .collect() + }; + let (xs, ys) = (middles(&widths, gap_x, origin.x), middles(&heights, gap_y, origin.y)); + let xfs: Vec<(NodeId, Affine)> = items + .iter() + .enumerate() + .filter_map(|(i, (id, b))| Some((*id, Affine::translate(Point::new(*xs.get(i % cols)?, *ys.get(i / cols)?) - b.center())))) + .collect(); + transform_each(s, "Arrange in Grid", false, &xfs)?; + Ok(json!({ "count": n })) +} + +fn swap(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.swap"; + let size = switch_or(p, "size", false, C)?; + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let roots = selected_roots(s)?; + let &[a, b] = roots.as_slice() else { return Err(bad(C, "select exactly two objects")) }; + let (ba, bb) = (box_of(s, a)?, box_of(s, b)?); + let to = |o: &OrientedBox| place(o.center(), size.then(|| (o.rect.width(), o.rect.height())), strokes); + let xfs = [(a, object_bounds_transform(s, &to(&bb), a)?), (b, object_bounds_transform(s, &to(&ba), b)?)]; + transform_each(s, "Swap Objects", strokes, &xfs)?; + Ok(json!({})) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.replace"; + let fit = choice(p, C, "fit", &["keep", "bounds", "proportional"])?.unwrap_or("keep"); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let keep = switch_or(p, "keep", false, C)?; + let roots = selected_roots(s)?; + let Some((&top, others)) = roots.split_last().filter(|(_, others)| !others.is_empty()) else { + return Err(bad(C, "select at least two objects")); + }; + let tb = box_of(s, top)?; + let (tw, th) = (tb.rect.width(), tb.rect.height()); + let mut xfs = Vec::with_capacity(others.len()); + for &id in others { + let Some(b) = s.transform_box(&[id]) else { continue }; + let (w, h) = (b.rect.width(), b.rect.height()); + let size = match fit { + "bounds" => Some((w, h)), + // The smaller of the two scales that fit; a side the copy doesn't have doesn't count. + "proportional" => { + [(w, tw), (h, th)].into_iter().filter(|(_, t)| *t > 1e-9).map(|(v, t)| v / t).reduce(f64::min).map(|k| (tw * k, th * k)) + } + _ => None, + }; + xfs.push((id, object_bounds_transform(s, &place(b.center(), size, strokes), top)?)); + } + let p = json!({ "strokes": strokes }); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &[top])?; + let ids = s.edit("Replace with Top Object", |d, sel| { + let Some(n) = d.node(top).cloned() else { return Ok(vec![]) }; + let mut items = Vec::with_capacity(xfs.len()); + for (id, xf) in &xfs { + let mut c = d.reid(&n); + transform_node(&mut c, *xf, sc, true); + items.push((*id, vec![c])); + } + let out: Vec = items.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect(); + d.insert_beside(items, !keep)?; + refresh_bounds_of(d, &out); + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn size(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.size"; + let width = size_param(p, "width", C)?; + let height = size_param(p, "height", C)?; + if width.is_none() && height.is_none() { + return Err(bad(C, "give `width` or `height`")); + } + let proportional = switch_or(p, "proportional", false, C)?; + let reference = whole_in(p, "reference", (0, 8), C)?.unwrap_or(4); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let mut q = json!({ "reference": reference, "strokes": strokes, "proportional": proportional && (width.is_none() || height.is_none()) }); + if let Some(w) = width { + q["width"] = json!(w); + } + if let Some(h) = height { + q["height"] = json!(h); + } + let mut xfs = vec![]; + for id in selected_roots(s)? { + if s.transform_box(&[id]).is_some() { + xfs.push((id, object_bounds_transform(s, &q, id)?)); + } + } + transform_each(s, "Set Size of Each", strokes, &xfs)?; + Ok(json!({ "count": xfs.len() })) +} + +/// The sets of two or more of `items` (each in the order given) whose rects overlap or touch, +/// chains included, among items of the same key (parent). A sweep along x: only the rects still +/// open where each one starts are compared with it. +fn overlapping(items: &[(K, Rect)]) -> Vec> { + fn root(up: &mut [usize], mut i: usize) -> usize { + while up[i] != i { + up[i] = up[up[i]]; + i = up[i]; + } + i + } + let mut up: Vec = (0..items.len()).collect(); + let mut order: Vec = (0..items.len()).collect(); + order.sort_by(|a, b| items[*a].1.x0.total_cmp(&items[*b].1.x0)); + let mut open: Vec = vec![]; + for i in order { + let (key, r) = (&items[i].0, items[i].1); + open.retain(|&j| items[j].1.x1 >= r.x0); + for &j in &open { + let (k, o) = (&items[j].0, items[j].1); + if k == key && o.y0 <= r.y1 && r.y0 <= o.y1 { + let (a, b) = (root(&mut up, i), root(&mut up, j)); + up[a] = b; + } + } + open.push(i); + } + let mut sets: Vec> = vec![vec![]; items.len()]; + for i in 0..items.len() { + let r = root(&mut up, i); + sets[r].push(i); + } + sets.retain(|s| s.len() > 1); + sets.sort_by_key(|s| s.first().copied()); + sets +} + +fn group_overlapping(s: &mut Session, _: &Value) -> Result { + const C: &str = "addon.arrange.groupOverlapping"; + let roots = selected_roots(s)?; + if roots.len() > MAX_OBJECTS { + return Err(bad(C, "too many objects selected (at most 20000)")); + } + let d = &s.doc()?.doc; + let items: Vec<(NodeId, (Option, Rect))> = + roots.iter().filter_map(|id| Some((*id, (d.parent_of(*id), effects::preview::bounds_of(d, &[*id], true)?)))).collect(); + let keyed: Vec<(Option, Rect)> = items.iter().map(|(_, k)| *k).collect(); + let sets: Vec> = overlapping(&keyed).into_iter().map(|set| set.iter().filter_map(|i| Some(items.get(*i)?.0)).collect()).collect(); + if sets.is_empty() { + return Ok(json!({ "groups": [], "grouped": 0 })); + } + let grouped: std::collections::HashSet = sets.iter().flatten().copied().collect(); + let groups = s.edit("Group Overlapping Objects", |d, sel| { + // Every group at once: the members behind each front one go, then each front one is + // replaced by its group (one pass over the tree each, however many groups). + let (mut places, mut behind, mut groups) = (vec![], vec![], vec![]); + for set in &sets { + let Some((&front, rest)) = set.split_last() else { continue }; + let children: Vec> = set.iter().filter_map(|id| d.node(*id).cloned().map(Arc::new)).collect(); + let gid = d.alloc_id(); + places.push((front, vec![Node::group(gid, children)])); + behind.extend_from_slice(rest); + groups.push(gid); + } + d.remove_many(&behind)?; + d.insert_beside(places, true)?; + sel.set(groups.iter().copied().chain(roots.iter().copied().filter(|id| !grouped.contains(id)))); + Ok(groups) + })?; + Ok(json!({ "groups": groups.iter().map(|i| i.0).collect::>(), "grouped": grouped.len() })) +} + +fn randomize_opacity(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.randomizeOpacity"; + let min = number_in(p, "min", 30.0, (0.0, 100.0), C)?; + let max = number_in(p, "max", 100.0, (0.0, 100.0), C)?; + if min > max { + return Err(bad(C, "`min` is greater than `max`")); + } + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let ids = checked_ids_or_selection(s, p, C)?; + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } + let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); + s.edit("Randomize Opacity", |d, _| { + for id in &ids { + let o = rng.range(lo, hi); + if let Some(n) = d.node_mut(*id) { + n.opacity = o; + } + } + Ok(()) + })?; + Ok(json!({ "count": ids.len() })) +} + +/// An object Sort Objects sorts: its layer or group, its place in it, its box as +/// `object.setBounds` measures it and its opacity (%). +struct Sorted { + id: NodeId, + parent: Option, + slot: usize, + b: OrientedBox, + opacity: f64, +} + +/// Attribute `key` (one of [`SORT_KEYS`]) of `o`, the `i`th of the objects in stacking order. +fn attribute(key: &str, o: &Sorted, i: usize) -> f64 { + let r = o.b.rect; + // The top-left corner where `object.setBounds` places it (reference point 0). + let corner = o.b.to_doc() * Point::new(r.x0, r.y0); + match key { + "width" => r.width(), + "height" => r.height(), + "area" => r.width() * r.height(), + "x" => corner.x, + "y" => corner.y, + "opacity" => o.opacity, + _ => i as f64, + } +} + +fn sort(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.sort"; + let by = choice(p, C, "by", &SORT_KEYS)?.unwrap_or("area"); + let apply = choice(p, C, "apply", &SORT_KEYS)?.unwrap_or("stacking"); + let order = choice(p, C, "order", &["ascending", "descending", "random"])?.unwrap_or("ascending"); + let distribute = switch_or(p, "distribute", false, C)?; + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let ids = checked_ids_or_selection(s, p, C)?; + if ids.len() > MAX_OBJECTS { + return Err(bad(C, "too many objects selected (at most 20000)")); + } + let d = &s.doc()?.doc; + // The objects in stacking order, from the back (their index paths, found in one walk). + let paths = d.index_paths(ids.iter().copied()); + let mut placed: Vec<(&Vec, NodeId)> = ids.iter().filter_map(|id| Some((paths.get(id)?, *id))).collect(); + placed.sort_by(|a, b| a.0.cmp(b.0)); + let items: Vec = placed + .into_iter() + .filter_map(|(path, id)| { + let (&slot, up) = path.split_last()?; + let parent = if up.is_empty() { None } else { Some(d.node_at(up)?.id) }; + Some(Sorted { id, parent, slot, b: s.transform_box(&[id])?, opacity: f64::from(d.node(id)?.opacity) * 100.0 }) + }) + .collect(); + let keys: Vec = items.iter().enumerate().map(|(i, o)| attribute(by, o, i)).collect(); + // The objects in sorted order; equal keys keep their stacking order. + let mut rank: Vec = (0..items.len()).collect(); + match order { + "random" => rng.shuffle(&mut rank), + "descending" => rank.sort_by(|a, b| keys[*b].total_cmp(&keys[*a])), + _ => rank.sort_by(|a, b| keys[*a].total_cmp(&keys[*b])), + } + let count = json!({ "count": items.len() }); + if apply == "stacking" { + // Each layer or group's objects take its objects' places, from the back, in sorted order. + let mut groups: HashMap, Vec> = HashMap::new(); + for o in rank.iter().filter_map(|i| items.get(*i)) { + groups.entry(o.parent).or_default().push(o.slot); + } + if groups.values().all(|slots| slots.is_sorted()) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (parent, from) in groups { + let ch = d.children_mut(parent)?; + let nodes: Vec> = from.iter().filter_map(|i| ch.get(*i).cloned()).collect(); + let mut to = from; + to.sort_unstable(); + for (slot, n) in to.into_iter().zip(nodes) { + if let Some(c) = ch.get_mut(slot) { + *c = n; + } + } + } + Ok(()) + })?; + return Ok(count); + } + // The values the objects get, smallest first: theirs, or evenly from the smallest to the largest. + let mut values: Vec = items.iter().enumerate().map(|(i, o)| attribute(apply, o, i)).collect(); + values.sort_by(f64::total_cmp); + if let (true, Some(&lo), Some(&hi)) = (distribute && values.len() > 1, values.first(), values.last()) { + let steps = (values.len() - 1) as f64; + values = (0..values.len()).map(|j| lo + (hi - lo) * j as f64 / steps).collect(); + } + let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); + if apply == "opacity" { + if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (o, v) in &wanted { + if let Some(n) = d.node_mut(o.id) { + n.opacity = (v / 100.0) as f32; + } + } + Ok(()) + })?; + return Ok(count); + } + let mut xfs = Vec::with_capacity(wanted.len()); + for (o, v) in wanted { + let q = match apply { + "x" => json!({ "x": v, "reference": 0 }), + "y" => json!({ "y": v, "reference": 0 }), + "width" => json!({ "width": v, "strokes": strokes }), + "height" => json!({ "height": v, "strokes": strokes }), + // An area: both sides in proportion; an object without one keeps its size. + _ => { + let (w, h) = (o.b.rect.width(), o.b.rect.height()); + let k = if w * h > 1e-12 { (v / (w * h)).sqrt() } else { 1.0 }; + json!({ "width": w * k, "height": h * k, "strokes": strokes }) + } + }; + xfs.push((o.id, object_bounds_transform(s, &q, o.id)?)); + } + transform_each(s, "Sort Objects", strokes, &xfs)?; + Ok(count) +} diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs new file mode 100644 index 000000000..85191935b --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -0,0 +1,564 @@ +//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off, +//! and bad params refused without a change. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Rect; + +use crate::Session; + +/// A new document whose new art has no stroke, so visual and geometric bounds agree. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool { + [(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Each of `bad` is refused and leaves the document as it was. +fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) { + let (before, n) = (s.doc().unwrap().doc.clone(), steps(s)); + for p in bad { + assert!(s.execute(cmd, p).is_err(), "{cmd} {p}"); + } + assert_eq!(steps(s), n, "{cmd}"); + assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document"); +} + +fn enabled(s: &Session, cmd: &str) -> bool { + s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled +} + +#[test] +fn grid_lays_objects_out_in_rows_and_columns_from_the_back() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0); + let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0); + let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0); + let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0); + select(&mut s, &[d, c, b, a]); + let n = steps(&s); + // Four objects: two columns. The grid starts at the selection's top-left (10, 5). + let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap(); + assert_eq!(r["count"], json!(4)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred. + assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0))); +} + +#[test] +fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap(); + assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10"); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap(); + assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX"); + // 20 × 44 centred on the 400 × 300 artboard. + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a)); + // In place already: no step. + let n = steps(&s); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert_eq!(steps(&s), n); + // More columns than objects: one row. + s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap(); + assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn grid_measures_visual_bounds() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap(); + // Visual boxes 24 wide, touching. + assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_trades_places_and_optionally_sizes() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.swap")); + select(&mut s, &[a, b, c]); + assert!(!enabled(&s, "addon.arrange.swap")); + refuses(&mut s, "addon.arrange.swap", &[json!({})]); + select(&mut s, &[a, b]); + assert!(enabled(&s, "addon.arrange.swap")); + let n = steps(&s); + s.execute("addon.arrange.swap", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + // Centres (10, 5) and (120, 120) swap; sizes stay. + assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_with_sizes_scales_strokes_as_asked() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap(); + assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b)); +} + +#[test] +fn replace_puts_a_copy_of_the_top_object_in_each_place() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[top]); + assert!(!enabled(&s, "addon.arrange.replace")); + refuses(&mut s, "addon.arrange.replace", &[json!({})]); + select(&mut s, &[a, b, top]); + let n = steps(&s); + let r = s.execute("addon.arrange.replace", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let st = s.doc().unwrap(); + assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted"); + assert!(st.doc.node(top).is_some(), "the top object stays"); + assert_eq!(st.selection.objects, ids, "the copies are selected"); + // Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle). + assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0])); + assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1])); + let layer = &s.doc().unwrap().doc.layers[0]; + let order: Vec = layer.children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [ids[0], ids[1], top]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).is_some()); +} + +#[test] +fn replace_fits_the_copies_and_can_keep_the_originals() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy)); + let order: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [a, copy, top], "kept: the copy goes right above it"); + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + // 10 × 5 into 20 × 40: twice as large, centred on (10, 20). + assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy)); +} + +#[test] +fn size_gives_each_object_a_width_and_height() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + let n = steps(&s); + s.execute("addon.arrange.size", &json!({"width": 30})).unwrap(); + assert_eq!(steps(&s), n + 1); + // About each centre; the height stays. + assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap(); + assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a)); +} + +#[test] +fn bad_params_are_refused() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + refuses( + &mut s, + "addon.arrange.grid", + &[ + json!({"layout": "spiral"}), + json!({"columns": 0}), + json!({"columns": 1.5}), + json!({"columns": 1e12}), + json!({"gapX": 1e308}), + json!({"gapY": "10"}), + json!({"centerOnArtboard": "yes"}), + ], + ); + refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]); + refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]); + refuses( + &mut s, + "addon.arrange.size", + &[ + json!({}), + json!({"width": 0}), + json!({"height": -5}), + json!({"width": 1e308}), + json!({"width": "10"}), + json!({"width": 10, "reference": 9}), + json!({"width": 10, "proportional": "yes"}), + ], + ); +} + +#[test] +fn off_without_a_selection() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 20.0, 10.0); + s.execute("select.none", &json!({})).unwrap(); + for c in [ + "addon.arrange.grid", + "addon.arrange.swap", + "addon.arrange.replace", + "addon.arrange.size", + "addon.arrange.groupOverlapping", + "addon.arrange.randomizeOpacity", + "addon.arrange.sort", + ] { + assert!(!enabled(&s, c), "{c}"); + } + assert!(!enabled(&Session::new(), "addon.arrange.swap")); +} + +fn ids_of(v: &Value) -> Vec { + v.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +fn children(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().map(|n| n.id).collect() +} + +#[test] +fn group_overlapping_groups_chains_and_leaves_loners() { + let mut s = session(); + // a–b–c a chain (c only touches b), d alone, e over f. + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let d = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 15.0, 15.0, 20.0, 20.0); + let e = rect(&mut s, 100.0, 100.0, 30.0, 30.0); + let c = rect(&mut s, 35.0, 0.0, 10.0, 20.0); + let f = rect(&mut s, 110.0, 110.0, 5.0, 5.0); + select(&mut s, &[a, b, c, d, e, f]); + let n = steps(&s); + let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert_eq!(r["grouped"], json!(5)); + let groups = ids_of(&r["groups"]); + assert_eq!(groups.len(), 2); + assert_eq!(children(&s, groups[0]), [a, b, c], "members keep their stacking order"); + assert_eq!(children(&s, groups[1]), [e, f]); + // Each group where its frontmost member was: a d [abc] [ef] → d, group(abc) above it, then (ef). + let layer: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, [d, groups[0], groups[1]]); + let sel = &s.doc().unwrap().selection.objects; + assert!(sel.contains(&groups[0]) && sel.contains(&groups[1]) && sel.contains(&d) && sel.len() == 3, "{sel:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 6); +} + +#[test] +fn group_overlapping_keeps_layers_apart_and_measures_visual_bounds() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let gap = rect(&mut s, 22.0, 0.0, 20.0, 20.0); + s.execute("layer.new", &json!({})).unwrap(); + let other = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + select(&mut s, &[a, gap, other]); + // 2 pt apart: nothing overlaps within a layer, and a and `other` are in different layers. + let n = steps(&s); + let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap(); + assert_eq!((r["grouped"].clone(), steps(&s)), (json!(0), n), "no groups, no step"); + // A 4 pt stroke reaches 2 pt out: the two in the first layer now touch. + select(&mut s, &[a, gap]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + select(&mut s, &[a, gap, other]); + let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap(); + let groups = ids_of(&r["groups"]); + assert_eq!(groups.len(), 1); + assert_eq!(children(&s, groups[0]), [a, gap]); + let doc = &s.doc().unwrap().doc; + assert_ne!(doc.layer_of(other), doc.layer_of(a), "left in its own layer"); +} + +#[test] +fn group_overlapping_is_off_with_one_object_and_scales_to_many() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.groupOverlapping")); + // A long row of pairs: each pair overlaps, neighbouring pairs don't. + let rects: Vec<(usize, Rect)> = + (0..4000).map(|i| (0, Rect::new(i as f64 * 10.0, 0.0, i as f64 * 10.0 + if i % 2 == 0 { 12.0 } else { 3.0 }, 5.0))).collect(); + let sets = super::overlapping(&rects); + assert_eq!(sets.len(), 2000); + assert!(sets.iter().all(|s| s.len() == 2 && s[1] == s[0] + 1)); +} + +fn opacity(s: &Session, id: NodeId) -> f32 { + s.doc().unwrap().doc.node(id).unwrap().opacity +} + +#[test] +fn randomize_opacity_gives_each_object_its_own_opacity_within_the_range() { + let mut s = session(); + let ids: Vec = (0..20).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect(); + select(&mut s, &ids); + let n = steps(&s); + let r = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap(); + assert_eq!(r["count"], json!(20)); + assert_eq!(steps(&s), n + 1, "one undo step"); + let first: Vec = ids.iter().map(|id| opacity(&s, *id)).collect(); + assert!(first.iter().all(|o| (0.3..=0.6).contains(o)), "{first:?}"); + assert!(first.windows(2).any(|w| w[0] != w[1]), "each its own: {first:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(ids.iter().all(|id| opacity(&s, *id) == 1.0), "undone"); + // The same seed repeats them; another seed doesn't. + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap(); + assert_eq!(ids.iter().map(|id| opacity(&s, *id)).collect::>(), first); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 8})).unwrap(); + assert_ne!(ids.iter().map(|id| opacity(&s, *id)).collect::>(), first); + // Defaults 30 to 100; min = max gives every object that opacity. + s.execute("addon.arrange.randomizeOpacity", &json!({})).unwrap(); + assert!(ids.iter().all(|id| (0.3..=1.0).contains(&opacity(&s, *id)))); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 45, "max": 45})).unwrap(); + assert!(ids.iter().all(|id| opacity(&s, *id) == 0.45)); +} + +#[test] +fn randomize_opacity_takes_ids_and_leaves_the_rest() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0); + let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0); + select(&mut s, &[a, b]); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let c = rect(&mut s, 20.0, 0.0, 5.0, 5.0); + s.execute("select.none", &json!({})).unwrap(); + assert!(!enabled(&s, "addon.arrange.randomizeOpacity")); + s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [c.0], "min": 10, "max": 10})).unwrap(); + assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (1.0, 1.0, 0.1)); + // A group takes one opacity of its own; the objects inside keep theirs. + select(&mut s, &[g]); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 20, "max": 20})).unwrap(); + assert_eq!((opacity(&s, g), opacity(&s, a), opacity(&s, b)), (0.2, 1.0, 1.0)); +} + +#[test] +fn randomize_opacity_refuses_bad_params() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + select(&mut s, &[a]); + refuses( + &mut s, + "addon.arrange.randomizeOpacity", + &[ + json!({"min": -1}), + json!({"max": 101}), + json!({"min": 80, "max": 20}), + json!({"min": 1e308}), + json!({"max": "50"}), + json!({"seed": -1}), + json!({"seed": 1.5}), + json!({"seed": "x"}), + json!({"ids": [999_999]}), + json!({"ids": "x"}), + ], + ); + let e = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 80, "max": 20})).unwrap_err().to_string(); + assert!(e.contains("`min` is greater than `max`"), "{e}"); +} + +fn layer_order(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +/// Three squares: `a` 30 wide at x 0, `b` 10 wide at x 100, `c` 20 wide at x 200, all selected. +fn squares(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = rect(s, 0.0, 0.0, 30.0, 30.0); + let b = rect(s, 100.0, 0.0, 10.0, 10.0); + let c = rect(s, 200.0, 0.0, 20.0, 20.0); + select(s, &[a, b, c]); + (a, b, c) +} + +#[test] +fn sort_by_area_lines_objects_up_along_x() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + let n = steps(&s); + let r = s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x"})).unwrap(); + assert_eq!(r["count"], json!(3)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Smallest first: b, c, a take the left edges 0, 100, 200; y stays. + assert!(near(bounds(&s, b), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (100.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, a), (200.0, 0.0, 30.0, 30.0)), "{:?}", bounds(&s, a)); + // Large to small: a, c, b. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, c).x0, bounds(&s, b).x0), (0.0, 100.0, 200.0)); + // Already in order: no step. + let n = steps(&s); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!(steps(&s), n); +} + +#[test] +fn sort_distributes_values_evenly() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 10.0, 50.0, 10.0, 50.0); + let c = rect(&mut s, 200.0, 0.0, 10.0, 20.0); + select(&mut s, &[a, b, c]); + // Left edges 0, 10, 200 step evenly: 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "x", "distribute": true})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, b).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0)); + // Heights that step evenly with x (10 to 50), each about its centre. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "height", "distribute": true})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 60.0, 10.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (200.0, -15.0, 10.0, 50.0)), "{:?}", bounds(&s, c)); +} + +#[test] +fn sort_restacks_within_each_layer() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Largest at the back. + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "stacking", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + s.execute("addon.arrange.sort", &json!({})).unwrap(); + assert_eq!(layer_order(&s), [b, c, a], "by area, small to large, into the stacking order by default"); + // An object in another layer keeps its layer; the others trade places among themselves. + s.execute("layer.new", &json!({})).unwrap(); + let big = rect(&mut s, 0.0, 100.0, 50.0, 50.0); + select(&mut s, &[a, b, c, big]); + s.execute("addon.arrange.sort", &json!({"order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + let doc = &s.doc().unwrap().doc; + assert_ne!(doc.layer_of(big), doc.layer_of(a), "left in its own layer"); +} + +#[test] +fn sort_by_stacking_and_opacity() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Back to front a, b, c; reversed: c, b, a take the left edges 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "stacking", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, c).x0, bounds(&s, b).x0, bounds(&s, a).x0), (0.0, 100.0, 200.0)); + for (id, o) in [(a, 20), (b, 80), (c, 50)] { + s.execute("object.setProps", &json!({"ids": [id.0], "opacity": o})).unwrap(); + } + select(&mut s, &[a, b, c]); + let opacity = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().opacity; + // By x, large to small: a (x 200) gets the least opacity, then b, then c. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "opacity", "order": "descending"})).unwrap(); + assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (0.2, 0.5, 0.8)); + // By opacity into the stacking order, large to small: the most opaque at the back. + s.execute("addon.arrange.sort", &json!({"by": "opacity", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [c, b, a]); +} + +#[test] +fn sort_by_area_into_areas_scales_in_proportion() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Areas 900, 100, 400 by x: a (x 0) 100, b 500, c 900. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "area", "distribute": true})).unwrap(); + let side = |s: &Session, id| bounds(s, id).width(); + assert!((side(&s, a) - 10.0).abs() < 1e-6 && (side(&s, b) - 500f64.sqrt()).abs() < 1e-6 && (side(&s, c) - 30.0).abs() < 1e-6); + assert!((bounds(&s, b).height() - 500f64.sqrt()).abs() < 1e-6, "in proportion"); +} + +#[test] +fn sort_in_random_order_repeats_with_a_seed() { + let mut s = session(); + let ids: Vec = (0..12).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect(); + select(&mut s, &ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + let first = layer_order(&s); + assert_ne!(first, ids, "shuffled"); + let mut sorted = first.clone(); + sorted.sort_by_key(|i| i.0); + assert_eq!(sorted, ids, "the same objects"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_order(&s), ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + assert_eq!(layer_order(&s), first, "the same seed, the same order"); +} + +#[test] +fn sort_takes_ids_and_refuses_bad_params() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.sort")); + s.execute("addon.arrange.sort", &json!({"ids": [b.0, a.0], "apply": "x"})).unwrap(); + assert_eq!((bounds(&s, b).x0, bounds(&s, a).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0), "c is left alone"); + select(&mut s, &[a, b, c]); + refuses( + &mut s, + "addon.arrange.sort", + &[ + json!({"by": "color"}), + json!({"apply": 3}), + json!({"order": "up"}), + json!({"distribute": "yes"}), + json!({"seed": -1}), + json!({"seed": 0.5}), + json!({"scaleStrokes": 1}), + json!({"ids": "x"}), + json!({"ids": [999_999]}), + ], + ); +} diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index 4f96c2634..b65fc75ac 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Health Check", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found", + "{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one, or type on the same type within 1 pt), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found", has_doc, check ), diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 46c81293d..c3abd029b 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -235,17 +235,46 @@ fn is_plain(n: &Node) -> bool { && *n.extras() == NodeExtras::default() } -/// Is `n` a plain path (full opacity, normal blending) identical to the object just below it? +/// Is `n` a plain path or type object (full opacity, normal blending) that doubles the object just +/// below it? fn duplicates_the_one_below(d: &Document, n: &Node) -> bool { - if !matches!(n.kind, NodeKind::Path { .. }) || n.opacity < 1.0 || n.blend != BlendMode::Normal { + if n.opacity < 1.0 || n.blend != BlendMode::Normal { return false; } let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned()); below.is_some_and(|b| { - b.visible && b.kind == n.kind && b.appearance == n.appearance && b.opacity == n.opacity && b.blend == n.blend && b.extras() == n.extras() + b.visible + && same_art(&b.kind, &n.kind) + && b.appearance == n.appearance + && b.opacity == n.opacity + && b.blend == n.blend + && b.extras() == n.extras() }) } +/// Distance (pt) within which doubled type counts as stacked: text set twice from a PDF or an old +/// file, or for a fake bold, is often a fraction of a point off. +const DOUBLED_TYPE: f64 = 1.0; + +/// The same path, or the same type (text, styles, size and turn) within [`DOUBLED_TYPE`] of it. +fn same_art(a: &NodeKind, b: &NodeKind) -> bool { + match (a, b) { + (NodeKind::Path { .. }, NodeKind::Path { .. }) => a == b, + (NodeKind::Text(a), NodeKind::Text(b)) => { + let ([a0, a1, a2, a3, ax, ay], [b0, b1, b2, b3, bx, by]) = (a.xf.as_coeffs(), b.xf.as_coeffs()); + [a0, a1, a2, a3] == [b0, b1, b2, b3] && (ax - bx).hypot(ay - by) <= DOUBLED_TYPE && { + // `b` placed on `a`, with `a`'s layout caches (they are in document space). + let mut moved = (**b).clone(); + moved.xf = a.xf; + moved.cached_bounds = a.cached_bounds; + moved.cached_baselines.clone_from(&a.cached_baselines); + **a == moved + } + } + _ => false, + } +} + fn inside(a: Rect, b: Rect) -> bool { a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1 } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 3351dd755..d02e1e4c0 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -226,6 +226,26 @@ fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() { } } +/// Type set twice in the same place (from a PDF, or for a fake bold) is a duplicate within a point: +/// Show selects the upper copy and Fix removes it. Other text, or the same text further off, stays. +#[test] +fn doubled_type_within_a_point_is_a_duplicate() { + let mut s = session(); + let text = |s: &mut Session, x: f64, text: &str| id_of(&s.execute("text.create", &json!({"x": x, "y": 100, "text": text})).unwrap()); + let base = text(&mut s, 20.0, "Total"); + let doubled = text(&mut s, 20.4, "Total"); + let other = text(&mut s, 20.0, "Subtotal"); + let far = text(&mut s, 23.0, "Subtotal"); + assert_eq!(found(&mut s, "duplicateObjects", "document"), 1); + let shown = s.execute("addon.cleanup.show", &json!({"kind": "duplicateObjects", "scope": "document"})).unwrap(); + assert_eq!(shown["ids"], json!([doubled.0])); + s.execute("addon.cleanup.fix", &json!({"kinds": ["duplicateObjects"], "scope": "document"})).unwrap(); + assert!(node(&s, doubled).is_none()); + for id in [base, other, far] { + assert!(node(&s, id).is_some(), "{id:?} stays"); + } +} + #[test] fn fix_all_is_one_undo_step() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 26a37efbf..dd7cb6788 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -1,5 +1,5 @@ //! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point -//! Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset +//! Text, Join Point Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset //! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters' //! styles and stay where the layout drew them: each is moved so the glyph of its first character //! lands where it was. @@ -57,6 +57,15 @@ pub fn specs() -> Vec { has_selection, combine_text ), + cmd!( + "addon.cleanup.joinText", + "Join Point Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", + has_selection, + join_text + ), cmd!( "addon.cleanup.removeBreaks", "Remove Breaks", @@ -641,6 +650,85 @@ fn combine_text(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids, "combined": combined })) } +fn join_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.joinText"; + let d = &s.doc()?.doc; + let pieces: Vec = + texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); + // The piece top-left on the page sets the turn; the others turned like it are read in that frame. + let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at; + let first = pieces.iter().min_by(|x, y| { + let (a, b) = (on_page(x), on_page(y)); + a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)) + }); + let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default(); + kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x))); + // Lines, top to bottom, by baseline; each line's pieces left to right. + let mut lines: Vec> = vec![]; + for piece in kept { + match lines.last_mut().filter(|l| l.first().is_some_and(|f| (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))) { + Some(l) => l.push(piece), + None => lines.push(vec![piece]), + } + } + for line in &mut lines { + line.sort_by(|x, y| x.at.x.total_cmp(&y.at.x)); + } + let joined: usize = lines.iter().map(Vec::len).sum(); + let (Some(top), true) = (lines.first().and_then(|l| l.first()).copied(), joined >= 2) else { + return Err(EngineError::Other("Select two or more one-line point type objects to join".into())); + }; + let mut runs: Vec = vec![]; + let mut prev_y = None; + for line in &lines { + let y = line.first().map_or(top.at.y, |p| p.at.y); + // The line keeps the distance from the baseline above as its leading. + let leading = prev_y.map(|above| y - above).filter(|l: &f64| l.is_finite() && *l > 0.0); + prev_y = Some(y); + let last = |runs: &[vectorcraft_doc::TextRun]| runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default(); + if !runs.is_empty() { + runs.push(vectorcraft_doc::TextRun::new("\n", last(&runs))); + } + let start = runs.len(); + for (k, piece) in line.iter().enumerate() { + let blank = runs.last().is_some_and(|r| r.text.ends_with(char::is_whitespace)) || piece.t.plain_text().starts_with(char::is_whitespace); + if k > 0 && !blank { + runs.push(vectorcraft_doc::TextRun::new(" ", last(&runs))); + } + runs.extend(piece.t.runs.iter().cloned()); + } + if let Some(l) = leading { + for r in runs.iter_mut().skip(start) { + r.style.leading = Some(l); + } + } + } + let mut m = top.t.clone(); + m.set_all_paras(ParaStyle { justify: Justify::Left, ..top.t.para.clone() }); + m.runs = runs; + edit::normalize(&mut m.runs); + m.ruby.clear(); + // The top-left piece's first glyph stays where it was. + let [a, b, c, dd] = top.turn; + let base = Affine::new([a, b, c, dd, 0.0, 0.0]) * Affine::translate(top.at.to_vec2()); + m.xf = base; + let x0 = glyph_starting(&layout(&m), 0).map_or(0.0, |g| g.x); + m.xf = base * Affine::translate((-x0, 0.0)); + refresh_bounds(&mut m); + let id = top.id; + let gone: Vec = lines.iter().flatten().map(|p| p.id).filter(|g| *g != id).collect(); + let count = lines.len(); + s.edit("Join Point Text", |d, sel| { + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(m)); + } + d.remove_many(&gone)?; + sel.set([id]); + Ok(()) + })?; + Ok(json!({ "id": id.0, "joined": joined, "lines": count })) +} + /// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many /// went. fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize { diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index a2fa3225f..228628866 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -176,6 +176,40 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() { assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); } +/// 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. +#[test] +fn join_stacks_lines_into_one_object_with_their_spacing() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20})); + let b = text(&mut s, json!({"x": 120, "y": 101, "text": "line", "size": 20})); + let c = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20})); + let d = text(&mut s, json!({"x": 50, "y": 166, "text": "Third", "size": 20})); + let turned = text(&mut s, json!({"x": 300, "y": 300, "text": "Turned", "size": 20, "rotation": 45})); + let was = glyphs(&s, &[a, c, d]); + select(&mut s, &[d, b, turned, c, a]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.joinText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), r["lines"].as_u64()), (Some(a.0), Some(4), Some(3))); + let t = obj(&s, a); + assert_eq!(t.plain_text(), "First line\nSecond\nThird"); + assert_eq!(t.runs.iter().filter(|r| r.text.contains("Third")).map(|r| r.style.leading).collect::>(), [Some(36.0)]); + // Each line's first glyph is where it was. + let now = glyphs(&s, &[a]); + for ch in ['F', 'S', 'T'] { + let (p, q) = (was.iter().find(|g| g.0 == ch).unwrap().1, now.iter().find(|g| g.0 == ch).unwrap().1); + assert!((p - q).hypot() < 0.05, "{ch}: {p:?} moved to {q:?}"); + } + let doc = &s.doc().unwrap().doc; + assert!(doc.node(b).is_none() && doc.node(c).is_none() && doc.node(d).is_none() && doc.node(turned).is_some()); + select(&mut s, &[turned]); + assert!(other(s.execute("addon.cleanup.joinText", &json!({}))).contains("join")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(c).is_some(), "undo brings the pieces back"); +} + #[test] fn remove_line_and_paragraph_breaks() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index 4426624cc..3dc037a2f 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -2,9 +2,11 @@ //! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as //! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the //! objects that use spot colours, carry a live colour adjustment, or are images of a colour model, -//! and Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print. Its panel and -//! its submenu, Object › Plug-ins › Color Effects, are the UI's. +//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, and Find Nearest +//! Swatch ([`nearest`]) matches colours to a swatch library's. Its panel and its submenu, Object › +//! Plug-ins › Color Effects, are the UI's. +mod nearest; mod print_fixes; #[cfg(test)] mod tests; @@ -50,6 +52,24 @@ pub fn specs() -> Vec { has_doc, print_fixes::run ), + cmd!( + query "addon.color.nearestSwatch", + "Find Nearest Swatch", + ["Object", "Plug-ins", "Color Effects"], + None, + "{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}", + has_doc, + nearest::find + ), + cmd!( + "addon.color.applyNearestSwatch", + "Apply Nearest Swatch", + ["Object", "Plug-ins", "Color Effects"], + None, + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + has_selection, + nearest::apply + ), ] } diff --git a/crates/engine/src/cmd/addons/color/nearest.rs b/crates/engine/src/cmd/addons/color/nearest.rs new file mode 100644 index 000000000..5a9d0e7c7 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/nearest.rs @@ -0,0 +1,198 @@ +//! Find Nearest Swatch (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`; Object › +//! Plug-ins › Color Effects › Find Nearest Swatch…): the swatches of a library, a colour book the +//! user loaded or the document closest to each colour of the selection (CIEDE2000), and the +//! colours replaced by them as one undo step (#4923). + +use serde_json::{Value, json}; +use vectorcraft_color::cms::{Lab, delta_e2000}; +use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_doc::swatches::color_name; +use vectorcraft_doc::{Document, NodeId, NodeKind}; + +use super::super::super::colorcmds::recolor_links; +use super::super::super::menucmds::squash; +use super::super::super::swatchlib::{DOCUMENT_SWATCHES, library_swatches}; +use super::super::super::{bad, checked_ids_or_selection, color_value, index, switch_or, text_param}; +use super::super::arrange::whole_in; +use crate::{Result, Session}; + +const FIND: &str = "addon.color.nearestSwatch"; +const APPLY: &str = "addon.color.applyNearestSwatch"; +/// The most colours of the selection looked up. +const MAX_COLORS: usize = 100; + +/// The `library` param ([`DOCUMENT_SWATCHES`] when left out) and its colour swatches with their Lab +/// values. +fn candidates(s: &Session, p: &Value, cmd: &str) -> Result<(String, Vec<(Swatch, Lab)>)> { + let key = text_param(p, "library", cmd)?.unwrap_or(DOCUMENT_SWATCHES).to_string(); + let swatches = library_swatches(s, &key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?; + let labs: Vec<(Swatch, Lab)> = swatches + .into_iter() + .filter(|w| !w.is_reserved()) + .filter_map(|w| { + let lab = w.paint.color()?.to_lab(); + Some((w, lab)) + }) + .collect(); + if labs.is_empty() { + return Err(bad(cmd, format!("`{key}` has no colours"))); + } + Ok((key, labs)) +} + +/// The `count` swatches of `among` closest to `c`, closest first, with their difference (ΔE 2000). +fn nearest(among: &[(Swatch, Lab)], c: Color, count: usize) -> Vec<(&Swatch, f32)> { + let lab = c.to_lab(); + let mut v: Vec<(&Swatch, f32)> = among.iter().map(|(w, l)| (w, delta_e2000(*l, lab))).collect(); + v.sort_by(|a, b| a.1.total_cmp(&b.1)); + v.truncate(count); + v +} + +/// The solid colours and gradient stops of the fills (`fill`) and strokes (`stroke`) of `ids`, of +/// everything inside them and of their type, once each and at most [`MAX_COLORS`]. +fn art_colors(d: &Document, ids: &[NodeId], fill: bool, stroke: bool) -> Vec { + let mut out: Vec = vec![]; + let mut add = |p: &Paint| { + let colors: Vec = match p { + Paint::Solid { color, .. } => vec![*color], + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(), + _ => vec![], + }; + for c in colors { + if out.len() < MAX_COLORS && !out.contains(&c) { + out.push(c); + } + } + }; + for n in ids.iter().filter_map(|id| d.node(*id)) { + n.walk(&mut |m| { + for it in &m.appearance.items { + if if it.is_fill() { fill } else { stroke } { + add(it.paint()); + } + } + if let NodeKind::Text(t) = &m.kind { + for r in &t.runs { + if fill { + add(&r.style.fill); + } + if stroke { + add(&r.style.stroke); + } + } + } + }); + } + out +} + +/// A swatch's name and values as the results give them. +fn described(w: &Swatch) -> Value { + let c = w.paint.color().unwrap_or_default(); + json!({ "name": w.name, "values": color_name(c), "hex": c.to_hex(), "spot": w.spot }) +} + +pub(super) fn find(s: &mut Session, p: &Value) -> Result { + let count = whole_in(p, "count", (1, 50), FIND)?.map_or(5, index); + let (fill, stroke) = (switch_or(p, "fill", true, FIND)?, switch_or(p, "stroke", true, FIND)?); + let colors = match p.get("color").filter(|v| !v.is_null()) { + Some(v) => vec![color_value(v).ok_or_else(|| bad(FIND, "`color` is not a colour"))?], + None => { + let ids = checked_ids_or_selection(s, p, FIND)?; + if ids.is_empty() { + return Err(bad(FIND, "select objects or give `color`")); + } + art_colors(&s.doc()?.doc, &ids, fill, stroke) + } + }; + let (library, among) = candidates(s, p, FIND)?; + let found: Vec = colors + .iter() + .map(|c| { + let matches: Vec = nearest(&among, *c, count) + .into_iter() + .map(|(w, de)| { + let mut m = described(w); + m["deltaE"] = json!((f64::from(de) * 100.0).round() / 100.0); + m + }) + .collect(); + json!({ "values": color_name(*c), "hex": c.to_hex(), "matches": matches }) + }) + .collect(); + Ok(json!({ "library": library, "colors": found })) +} + +pub(super) fn apply(s: &mut Session, p: &Value) -> Result { + let (fill, stroke) = (switch_or(p, "fill", true, APPLY)?, switch_or(p, "stroke", true, APPLY)?); + let ids = checked_ids_or_selection(s, p, APPLY)?; + if ids.is_empty() { + return Err(bad(APPLY, "nothing selected")); + } + let (library, among) = candidates(s, p, APPLY)?; + // The swatch each colour takes, once each. + let colors = art_colors(&s.doc()?.doc, &ids, fill, stroke); + let mut used: Vec<&Swatch> = vec![]; + for c in &colors { + if let Some((w, _)) = nearest(&among, *c, 1).first() + && !used.iter().any(|u| u.name == w.name) + { + used.push(w); + } + } + if used.is_empty() { + return Ok(json!({ "changed": 0, "swatches": [], "added": [] })); + } + let undo = s.doc()?.history.undo.len(); + // A library's swatches join the document's first, as Add to Swatches adds them, so spot and + // global colours stay linked to theirs. + let mut added = vec![]; + let mut names: Vec = used.iter().map(|w| w.name.clone()).collect(); + if library != DOCUMENT_SWATCHES { + let r = s.execute("swatch.library.add", &json!({ "library": library, "names": names }))?; + let listed = |k: &str| r.get(k).and_then(Value::as_array).cloned().unwrap_or_default(); + let in_doc: Vec = listed("added").iter().chain(&listed("existing")).filter_map(Value::as_str).map(str::to_string).collect(); + let d = &s.doc()?.doc; + // Each library swatch's copy: the document swatch with its paint among those added or kept. + names = used + .iter() + .map(|w| in_doc.iter().find(|n| d.swatch(n).is_some_and(|x| x.paint == w.paint)).cloned().unwrap_or_else(|| w.name.clone())) + .collect(); + added = listed("added"); + } + // Each used swatch's paint as art takes it: linked to a global or spot swatch. + let d = &s.doc()?.doc; + let paints: Vec<(Lab, Paint)> = used + .iter() + .zip(&names) + .filter_map(|(w, name)| { + let lab = w.paint.color()?.to_lab(); + let paint = d.swatch(name).filter(|x| x.global).and_then(|_| d.tint_paint(name, 1.0)).unwrap_or_else(|| w.paint.clone()); + Some((lab, paint)) + }) + .collect(); + // shortcut: a colour past the MAX_COLORS looked up takes the closest of the swatches those + // took; look up every colour if documents with more colours need their own matches. + let to = |c: Color| -> Option<&Paint> { + let lab = c.to_lab(); + paints.iter().min_by(|a, b| delta_e2000(a.0, lab).total_cmp(&delta_e2000(b.0, lab))).map(|(_, p)| p) + }; + let links = |c: &mut Color, link: &mut Option, tint: &mut f32| { + let Some(Paint::Solid { color, swatch, .. }) = to(*c) else { return false }; + if *c == *color && *link == *swatch { + return false; + } + (*c, *link, *tint) = (*color, swatch.clone(), 1.0); + true + }; + let given = json!({ + "ids": ids.iter().map(|i| i.0).collect::>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false, + }); + let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; + squash(s, undo, "Apply Nearest Swatch"); + Ok(json!({ "changed": r["changed"], "swatches": names, "added": added })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/addons/color/nearest/tests.rs b/crates/engine/src/cmd/addons/color/nearest/tests.rs new file mode 100644 index 000000000..0436d19c8 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/nearest/tests.rs @@ -0,0 +1,115 @@ +//! Find Nearest Swatch. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +const FIND: &str = "addon.color.nearestSwatch"; +const APPLY: &str = "addon.color.applyNearestSwatch"; + +/// A document with a global swatch "Brand" and a selected rectangle filled `fill`, stroked `stroke`. +fn doc(fill: &str, stroke: Option<&str>) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff8000", "global": true})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": fill})).unwrap(); + match stroke { + Some(c) => s.execute("paint.setStroke", &json!({"color": c})).unwrap(), + None => s.execute("paint.setStroke", &json!({"none": true})).unwrap(), + }; + (s, id) +} + +fn paints(s: &Session, id: NodeId) -> (Paint, Paint) { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + (n.appearance.fill_paint(), n.appearance.items.iter().find(|i| !i.is_fill()).unwrap().paint().clone()) +} + +fn names(matches: &Value) -> Vec<&str> { + matches.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap()).collect() +} + +#[test] +fn the_closest_swatches_of_a_library_are_listed_closest_first() { + let (mut s, _) = doc("#ff6634", None); + let v = s.execute(FIND, &json!({"library": "web-safe-216", "count": 3})).unwrap(); + assert_eq!(v["library"], "web-safe-216"); + let colors = v["colors"].as_array().unwrap(); + assert_eq!(colors.len(), 1, "the fill; no stroke"); + assert_eq!((colors[0]["hex"].as_str(), colors[0]["values"].as_str()), (Some("#ff6634"), Some("R=255 G=102 B=52"))); + let m = &colors[0]["matches"]; + assert_eq!(names(m)[0], "#FF6633"); + let de: Vec = m.as_array().unwrap().iter().map(|x| x["deltaE"].as_f64().unwrap()).collect(); + assert_eq!(de.len(), 3); + assert!(de[0] < 1.0 && de.windows(2).all(|w| w[0] <= w[1]), "{de:?}"); + // A colour given is looked up instead of the selection's. + let v = s.execute(FIND, &json!({"library": "web-safe-216", "color": "#010203", "count": 1})).unwrap(); + assert_eq!(names(&v["colors"][0]["matches"]), ["#000000"]); +} + +#[test] +fn the_document_swatches_are_the_default_library() { + let (mut s, _) = doc("#ff8010", Some("#ff8010")); + let v = s.execute(FIND, &json!({})).unwrap(); + assert_eq!(v["library"], "document"); + assert_eq!(v["colors"].as_array().unwrap().len(), 1, "fill and stroke share a colour: looked up once"); + assert_eq!(names(&v["colors"][0]["matches"])[0], "Brand"); + assert!(!names(&v["colors"][0]["matches"]).contains(&"[Registration]")); +} + +#[test] +fn applying_links_each_colour_to_its_nearest_document_swatch_as_one_undo_step() { + let (mut s, id) = doc("#ff8010", Some("#ff8010")); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute(APPLY, &json!({"stroke": false})).unwrap(); + assert_eq!((v["changed"].as_u64(), v["swatches"].clone(), v["added"].clone()), (Some(1), json!(["Brand"]), json!([]))); + let (fill, stroke) = paints(&s, id); + assert!(matches!(&fill, Paint::Solid { swatch: Some(n), color, .. } if n == "Brand" && color.to_hex() == "#ff8000"), "{fill:?}"); + assert_eq!(stroke.color().unwrap().to_hex(), "#ff8010", "strokes left alone"); + let st = s.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Apply Nearest Swatch")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paints(&s, id).0.color().unwrap().to_hex(), "#ff8010"); +} + +#[test] +fn a_library_spot_colour_joins_the_document_and_stays_spot() { + let (mut s, id) = doc("#e01525", None); + // A colour book of one spot colour, loaded as a user would load one they own. + s.execute("swatch.new", &json!({"name": "Ink Red", "color": "#e01020", "spot": true})).unwrap(); + let data = s.execute("swatch.library.save", &json!({"names": ["Ink Red"]})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute("swatch.delete", &json!({"names": ["Ink Red"]})).unwrap(); + let lib = s.execute("swatch.library.load", &json!({"data": data, "name": "Book.vcswatches"})).unwrap()["library"].as_str().unwrap().to_string(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute(APPLY, &json!({"library": lib})).unwrap(); + assert_eq!((v["swatches"].clone(), v["added"].clone()), (json!(["Ink Red"]), json!(["Ink Red"]))); + let d = &s.doc().unwrap().doc; + assert!(d.swatch("Ink Red").is_some_and(|w| w.spot && w.global)); + assert!(matches!(paints(&s, id).0, Paint::Solid { swatch: Some(n), .. } if n == "Ink Red")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "adding and applying undo together"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("Ink Red").is_none()); + assert_eq!(paints(&s, id).0, Paint::solid(Color::from_hex("#e01525").unwrap())); +} + +#[test] +fn bad_requests_are_refused() { + let (mut s, _) = doc("#ff8010", None); + for (p, why) in [ + (json!({"library": "nope"}), "no swatch library `nope`"), + (json!({"library": "spectrum-gradients"}), "has no colours"), + (json!({"count": 0}), "`count` must be from 1 to 50"), + (json!({"color": "zzz"}), "`color` is not a colour"), + (json!({"fill": "yes"}), "`fill` must be true or false"), + ] { + let e = s.execute(FIND, &p).unwrap_err().to_string(); + assert!(e.contains(why), "{p}: {e}"); + } + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute(FIND, &json!({})).unwrap_err().to_string().contains("select objects or give `color`")); + assert!(s.execute(APPLY, &json!({})).is_err(), "nothing selected"); +} diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs new file mode 100644 index 000000000..30548b80a --- /dev/null +++ b/crates/engine/src/cmd/addons/document.rs @@ -0,0 +1,230 @@ +//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of +//! the document named with the date and time through File › Save a Copy's save; New Symbols from +//! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a +//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins › +//! Document Tools) is the UI's. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeId, NodeKind, SymbolRegistration}; + +use super::super::brushsym::{instance_in_place, normalize, place_xf, store_symbol}; +use super::super::fileio::{self, SaveMode}; +use super::super::{CommandSpec, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or}; +use crate::{Result, Session}; + +/// The most numbers (`-2`, `-3`…) tried after a timestamped name that is taken. +const MAX_NUMBER: u32 = 999; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.document.saveTimestampedCopy", + "Save a Timestamped Copy", + ["Object", "Plug-ins", "Document Tools"], + None, + "{folder? (default: the document's own folder; needed for a document never saved), format?: vectorcraft|pdf|svg|svgz|ai|eps (default: the document's own, else vectorcraft), time?: Unix seconds (default: now)} write a copy of the document named _YYYY-MM-DD_HH-MM. (the date and time in UTC; -2, -3… added when that name is taken) as File › Save a Copy writes it: the document keeps its path, title and modified state, and no undo step is made. Off where there is no clock (the web) → {path, format, bytes, warnings}", + has_clock, + save_timestamped_copy + ), + cmd!( + "addon.document.exportAllOpen", + "Export All Open Documents", + ["Object", "Plug-ins", "Document Tools"], + None, + "{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (.; -2, -3… added when that name is taken, so no file is replaced), one self-contained file each with all of its art (images embedded or included, every artboard; no remembered save options apply): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}", + has_doc, + export_all_open + ), + cmd!( + "addon.document.symbolsFromLayers", + "New Symbols from Layers", + ["Object", "Plug-ins", "Document Tools"], + None, + "{layers?: [layer id…] (top-level layers; default: those highlighted in the Layers panel when two or more are, else every one), replace?: bool (false: the art stays; true: each layer's art is replaced by an instance of its symbol in the same place)} make one symbol of each layer's art as symbol.new makes one (registration in the centre), named after the layer (with a number added when a symbol has that name), as one undo step. Layers with no art are skipped, and with replace so are locked layers; with replace the instances are selected → {symbols: [name…], ids: [instance id…] (with replace), skipped: [{layer, name, reason: \"empty\"|\"locked\"}…]}", + has_doc, + symbols_from_layers + ), + ] +} + +/// A document is open and the time can be told. +fn has_clock(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + vectorcraft_doc::metadata::output_time().map(|_| ()).ok_or_else(|| "there is no clock here".into()) +} + +/// `.`, then `-2.`… in `folder`: the first that `taken` doesn't hold. +fn free_name(folder: &str, base: &str, ext: &str, taken: impl Fn(&str) -> bool) -> Option { + (1..=MAX_NUMBER) + .map(|i| { + let name = if i == 1 { format!("{base}.{ext}") } else { format!("{base}-{i}.{ext}") }; + std::path::Path::new(folder).join(name).to_string_lossy().into_owned() + }) + .find(|path| !taken(path)) +} + +fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.document.saveTimestampedCopy"; + let st = s.doc()?; + let format = match str_param(p, "format") { + Some(f) => fileio::save_format(Some(f), None), + // A document opened from a format Save doesn't write (a picture, a drawing) copies as native. + None => fileio::save_format(Some(st.format), None).or_else(|_| fileio::save_format(None, None)), + } + .map_err(|e| bad(C, e))?; + let folder = match str_param(p, "folder").filter(|f| !f.trim().is_empty()) { + Some(f) => f.to_string(), + None => st + .path + .as_deref() + .and_then(|path| std::path::Path::new(path).parent()) + .map(|f| f.to_string_lossy().into_owned()) + .filter(|f| !f.is_empty()) + .ok_or_else(|| bad(C, "the document has never been saved: give `folder`"))?, + }; + let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); + let given = date_param(p, "time", C)?; + let t = clock_date(s, "time", given).ok_or_else(|| bad(C, "there is no clock here: give `time` (Unix seconds)"))?; + let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); + let base = format!("{stem}_{y:04}-{mo:02}-{d:02}_{h:02}-{mi:02}"); + // Only whether something is there: the write itself goes through the automation roots. + let path = free_name(&folder, &base, format.extensions[0], |p| std::path::Path::new(p).exists()) + .ok_or_else(|| bad(C, "every numbered name for the copy is taken"))?; + fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) +} + +/// The save options of every copy, given in full so that no document's remembered options (linked +/// SVG images, a PDF page range, separate artboard files) apply: one self-contained file per +/// document with all of its art. Each format reads only the options it has, and `range` is left +/// out of SVG, where it would pick artboards over `useArtboards`. +fn copy_options(format: &str) -> Value { + match format { + "svg" | "svgz" => json!({ "images": "embed", "useArtboards": false }), + "pdf" => json!({ "range": "all" }), + "eps" => json!({ "includeLinkedFiles": true, "useArtboards": false }), + "ai" | "ait" => json!({ "range": "all", "includeLinked": true, "separateArtboards": false }), + _ => json!({ "includeLinked": true, "separateArtboards": false }), + } +} + +fn export_all_open(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.document.exportAllOpen"; + let format = fileio::save_format(Some(str_param(p, "format").unwrap_or("pdf")), None).map_err(|e| bad(C, e))?; + let folder = str_param(p, "folder").map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "give `folder`"))?.to_string(); + let ext = format.extensions.first().copied().ok_or_else(|| bad(C, "the format has no file extension"))?; + let (mut files, mut failed, mut written) = (vec![], vec![], Vec::::new()); + let active = s.active; + for i in 0..s.docs.len() { + // Each document in turn is the one Save a Copy writes; switching tabs this way leaves every + // document's tool and interaction alone, and the active one comes back below. + s.active = Some(i); + let Ok(st) = s.doc() else { continue }; + let title = st.doc.title.clone(); + let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&title)); + // Names the copies already took count as taken, as do files on disk. + let Some(path) = free_name(&folder, &stem, ext, |p| written.iter().any(|w| w == p) || std::path::Path::new(p).exists()) else { + failed.push(json!({ "title": title, "error": "every numbered name for the copy is taken" })); + continue; + }; + let options = copy_options(format.id); + match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id, "options": options })) { + Ok(r) => { + written.push(path.clone()); + // Any other file the save wrote is taken for the copies still to come. + for key in ["files", "linked"] { + for f in r.get(key).and_then(Value::as_array).into_iter().flatten() { + if let Some(f) = f.as_str().or_else(|| f.get("path").and_then(Value::as_str)) { + written.push(f.to_string()); + } + } + } + files.push(json!({ "title": title, "path": r.get("path").cloned().unwrap_or(json!(path)), "warnings": r.get("warnings").cloned().unwrap_or(json!([])) })); + } + Err(e) => failed.push(json!({ "title": title, "error": e.to_string() })), + } + } + s.active = active; + Ok(json!({ "files": files, "failed": failed, "format": format.id })) +} + +/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds +/// no layers). +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 }; + } + if let Some(children) = n.children_mut() { + for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) { + *c = Arc::new(without_layers(c)); + } + } + n +} + +fn symbols_from_layers(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.document.symbolsFromLayers"; + let replace = switch_or(p, "replace", false, C)?; + let st = s.doc()?; + let d = &st.doc; + let layers: Vec = match p.get("layers").filter(|v| !v.is_null()) { + // The layers highlighted in the Layers panel when two or more are (one is mostly just the + // current layer), else every one. + None => { + let rows = st.highlighted_rows(); + let picked: Vec = d.layers.iter().map(|l| l.id).filter(|id| rows.contains(id)).collect(); + if picked.len() > 1 { picked } else { d.layers.iter().map(|l| l.id).collect() } + } + Some(_) => { + let ids = checked_ids_param(s, p, "layers", C)?; + if let Some(NodeId(id)) = ids.iter().find(|id| !d.layers.iter().any(|l| l.id == **id)) { + return Err(bad(C, format!("{id} is no top-level layer"))); + } + d.layers.iter().map(|l| l.id).filter(|id| ids.contains(id)).collect() + } + }; + // Each layer with art: its id and name, the art's roots, and the art ready for a symbol. + 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() { + [] => None, + [one] => Some(without_layers(one)), + many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))), + }; + let reason = if replace && layer.locked { Some("locked") } else { None }; + let ready = art.and_then(|mut art| { + crate::cmd::threads::keep_shown_text(d, &mut art); + normalize(art).ok() + }); + match (reason, ready) { + (None, Some(ready)) => made.push((name, roots, ready)), + (reason, _) => skipped.push(json!({ "layer": layer.id.0, "name": name, "reason": reason.unwrap_or("empty") })), + } + } + if made.is_empty() { + return Ok(json!({ "symbols": [], "ids": [], "skipped": skipped })); + } + let (names, ids) = s.edit("New Symbols from Layers", |d, sel| { + let (mut names, mut ids) = (vec![], vec![]); + for (name, roots, (art, size, center)) in &made { + let name = d.unique_symbol_name(name); + store_symbol(d, art, &name, *size); + if replace { + ids.push(instance_in_place(d, roots, &name, place_xf(*center, *size, SymbolRegistration::CENTER))?); + } + names.push(name); + } + if replace { + sel.set(ids.iter().copied()); + } + Ok((names, ids)) + })?; + Ok(json!({ "symbols": names, "ids": ids.iter().map(|i| i.0).collect::>(), "skipped": skipped })) +} diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs new file mode 100644 index 000000000..562a389e8 --- /dev/null +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -0,0 +1,278 @@ +//! Document Tools' commands: the timestamped copy's name and place with the document left as it +//! was, symbols made of layers, and bad params refused. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// 2026-10-05 14:03:00 UTC. +const TIME: i64 = 1_791_208_980; + +/// A new, empty folder of its own for test `name`. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-addon-document-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s +} + +fn stamp(s: &mut Session, p: Value) -> crate::Result { + s.execute("addon.document.saveTimestampedCopy", &p) +} + +#[test] +fn a_timestamped_copy_goes_next_to_the_document_which_stays_as_it_was() { + let dir = folder("next-to"); + let mut s = session(); + let own = dir.join("poster.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let (steps, title) = (s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.title.clone()); + let r = stamp(&mut s, json!({"time": TIME})).unwrap(); + let copy = dir.join("poster_2026-10-05_14-03.vectorcraft"); + assert_eq!(Path::new(r["path"].as_str().unwrap()), copy); + assert!(copy.is_file()); + let st = s.doc().unwrap(); + assert_eq!(st.path.as_deref().map(Path::new), Some(own.as_path()), "the document keeps its path"); + assert_eq!((st.history.undo.len(), &st.doc.title, st.is_dirty()), (steps, &title, true), "no step; still modified"); + // The copy holds the document as it is now: the ellipse too. + let mut other = Session::new(); + other.execute("document.open", &json!({"path": copy})).unwrap(); + assert_eq!(other.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // The same minute again: numbered. + let r = stamp(&mut s, json!({"time": TIME})).unwrap(); + assert!(r["path"].as_str().unwrap().ends_with("poster_2026-10-05_14-03-2.vectorcraft"), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_unsaved_document_needs_a_folder_and_other_formats_take_their_extension() { + let dir = folder("unsaved"); + let mut s = session(); + let before = s.doc().unwrap().doc.clone(); + assert!(stamp(&mut s, json!({"time": TIME})).is_err(), "no folder to put it in"); + let r = stamp(&mut s, json!({"time": TIME, "folder": dir, "format": "svg"})).unwrap(); + let name = Path::new(r["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned(); + assert!(name.ends_with("_2026-10-05_14-03.svg") && dir.join(&name).is_file(), "{name}"); + let st = s.doc().unwrap(); + assert!(st.path.is_none() && *st.doc == *before, "the document is unchanged"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bad_params_are_refused_and_writes_stay_inside_the_automation_roots() { + let dir = folder("bad"); + let mut s = session(); + for p in [ + json!({"folder": dir, "format": "png"}), + json!({"folder": dir, "format": "dxf"}), + json!({"folder": dir, "time": "now"}), + json!({"folder": dir, "time": 1.5}), + json!({"folder": dir.join("missing").join("deeper"), "time": TIME}), + ] { + assert!(stamp(&mut s, p.clone()).is_err(), "{p}"); + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing written"); + let allowed = dir.join("allowed"); + std::fs::create_dir_all(&allowed).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&dir], &[&allowed]).unwrap(); + let outside = crate::file_access::confine(roots.as_ref(), || stamp(&mut s, json!({"folder": dir, "time": TIME}))); + assert!(outside.is_err(), "outside the write root"); + let inside = crate::file_access::confine(roots.as_ref(), || stamp(&mut s, json!({"folder": allowed, "time": TIME}))); + assert!(inside.is_ok(), "{inside:?}"); + assert!(!enabled_without_document(), "off with no document"); + let _ = std::fs::remove_dir_all(&dir); +} + +fn enabled_without_document() -> bool { + Session::new().commands().into_iter().find(|c| c.id == "addon.document.saveTimestampedCopy").unwrap().enabled +} + +fn layer_ids(s: &Session) -> Vec { + s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect() +} + +fn symbol_names(s: &Session) -> Vec { + s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect() +} + +/// A document of three layers: `Layer 1` with two rectangles, an empty one, and `Icons` with one +/// ellipse. +fn layered() -> Session { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 50, "height": 50})).unwrap(); + s.execute("layer.new", &json!({})).unwrap(); + s.execute("layer.new", &json!({"name": "Icons"})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 40, "height": 20})).unwrap(); + s +} + +#[test] +fn a_symbol_is_made_of_each_layer_and_empty_layers_are_reported() { + let mut s = layered(); + let before = symbol_names(&s).len(); + let n = s.doc().unwrap().history.undo.len(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "one undo step"); + let names: Vec = serde_json::from_value(r["symbols"].clone()).unwrap(); + assert_eq!(names.len(), 2, "{r}"); + assert!(names.contains(&"Icons".to_string())); + assert_eq!(symbol_names(&s).len(), before + 2); + assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "the empty layer: {r}"); + assert_eq!(r["skipped"][0]["reason"], json!("empty")); + assert_eq!(r["ids"], json!([]), "without replace the art stays"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // Again: the names are taken, so they get numbers. + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert!(r["symbols"].as_array().unwrap().contains(&json!("Icons 2")), "{r}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(symbol_names(&s).len(), before); +} + +#[test] +fn replace_puts_an_instance_where_the_layers_art_was() { + let mut s = layered(); + let first = layer_ids(&s)[0]; + let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + let layer = NodeId(first); + let art = bounds(&s, layer); + let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [first], "replace": true})).unwrap(); + let ids = r["ids"].as_array().unwrap(); + assert_eq!(ids.len(), 1); + let inst = NodeId(ids[0].as_u64().unwrap()); + let st = s.doc().unwrap(); + let kids = st.doc.layers[0].children().unwrap(); + assert_eq!(kids.len(), 1); + assert!(matches!(&kids[0].kind, NodeKind::SymbolInstance { .. })); + assert_eq!(st.selection.objects, [inst]); + let now = bounds(&s, layer); + assert!((now.x0 - art.x0).abs() < 1e-6 && (now.y1 - art.y1).abs() < 1e-6 && (now.width() - art.width()).abs() < 1e-6, "{art:?} {now:?}"); + assert_eq!(s.doc().unwrap().doc.layers.len(), 3, "the other layers untouched"); +} + +#[test] +fn locked_layers_keep_their_art_and_bad_layers_are_refused() { + let mut s = layered(); + let ids = layer_ids(&s); + s.execute("layer.setProps", &json!({"id": ids[2], "locked": true})).unwrap(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({"replace": true})).unwrap(); + let skipped: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|k| k["reason"].as_str().unwrap()).collect(); + assert_eq!(skipped, ["empty", "locked"]); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0; + for p in [json!({"layers": [rect]}), json!({"layers": [u64::MAX]}), json!({"layers": "all"}), json!({"layers": [-1]}), json!({"replace": "yes"})] + { + assert!(s.execute("addon.document.symbolsFromLayers", &p).is_err(), "{p}"); + } + assert!(*s.doc().unwrap().doc == *before.0 && s.doc().unwrap().history.undo.len() == before.1); + // Only an empty layer: nothing made, no step. + let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [ids[1]]})).unwrap(); + assert_eq!((r["symbols"].clone(), s.doc().unwrap().history.undo.len()), (json!([]), before.1)); +} + +#[test] +fn the_layers_highlighted_in_the_layers_panel_are_the_default() { + let mut s = layered(); + let ids = layer_ids(&s); + // One highlighted row is just the current layer: every layer. + s.execute("layer.setCurrent", &json!({"id": ids[0]})).unwrap(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert_eq!(r["symbols"].as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("layer.highlight", &json!({"ids": [ids[1], ids[2]]})).unwrap(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1)); +} + +/// #4920: Export All Open Documents writes an SVG of each open document; a folder that can't be +/// written lists every document in `failed`, and either way the active document stays active. +#[test] +fn export_all_open_documents_writes_each_and_reports_failures() { + let dir = std::env::temp_dir().join(format!("vc-export-all-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "name": "one"})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100, "name": "two"})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + let r = s.execute("addon.document.exportAllOpen", &json!({"folder": dir, "format": "svg"})).unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); + assert!(dir.join("one.svg").exists() && dir.join("two.svg").exists()); + assert_eq!(s.active_index(), Some(0)); + assert!(s.documents().iter().all(|d| d.path.is_none()), "the documents stay unsaved"); + let missing = dir.join("missing").join("deeper"); + let r = s.execute("addon.document.exportAllOpen", &json!({"folder": missing, "format": "svg"})).unwrap(); + assert_eq!((r["files"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (0, 2), "{r}"); + assert_eq!(s.active_index(), Some(0)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An SVG document last saved with linked images exports as one SVG with its images embedded: no +/// image file is written beside the copies (#4920 review). +#[test] +fn export_all_open_documents_writes_one_file_each() { + let dir = std::env::temp_dir().join(format!("vc-export-all-one-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("out")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "name": "pic"})).unwrap(); + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("document.save", &json!({"path": dir.join("pic.svg"), "format": "svg", "options": {"images": "link"}})).unwrap(); + let saved: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().is_file()).collect(); + assert!(saved.len() > 1, "the linked save wrote its image beside it: {saved:?}"); + let out = dir.join("out"); + let r = s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap(); + let names: Vec = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["pic.svg"], "{r}"); + assert!(std::fs::read_to_string(out.join("pic.svg")).unwrap().contains("data:image/png;base64,"), "the image is embedded"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A PDF document last saved with a page range exports with every page, and an SVG document with +/// two artboards exports as one SVG holding the art of both (#4920 review). +#[test] +fn export_all_open_documents_keeps_every_page_and_artboard() { + let dir = std::env::temp_dir().join(format!("vc-export-all-pages-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("out")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2, "name": "pages"})).unwrap(); + s.execute("document.save", &json!({"path": dir.join("pages.pdf"), "format": "pdf", "options": {"range": "1"}})).unwrap(); + let pages = |b: &[u8]| { + String::from_utf8_lossy(b).matches("/Type /Page\n").count() + + String::from_utf8_lossy(b).matches("/Type/Page/").count() + + String::from_utf8_lossy(b).matches("/Type /Page/").count() + + String::from_utf8_lossy(b).matches("/Type /Page ").count() + }; + let first = pages(&std::fs::read(dir.join("pages.pdf")).unwrap()); + let out = dir.join("out"); + s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "pdf"})).unwrap(); + let copy = pages(&std::fs::read(out.join("pages.pdf")).unwrap()); + assert!(copy > first, "the copy has every page: {first} saved, {copy} exported"); + // SVG: one file with the art on both artboards. + let a = s.doc().unwrap().doc.artboards[1].rect; + s.execute("shape.rectangle", &json!({"x": a.x0 + 10.0, "y": a.y0 + 10.0, "width": 20, "height": 20})).unwrap(); + s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap(); + let svgs: Vec = + std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".svg")).collect(); + assert_eq!(svgs, ["pages.svg"]); + let svg = std::fs::read_to_string(out.join("pages.svg")).unwrap(); + // Not the first artboard alone (its 100 × 100 box at the origin): the art's own bounds. + let view = svg.split("viewBox=\"").nth(1).and_then(|v| v.split('"').next()).unwrap(); + let w: f64 = view.split_whitespace().nth(2).unwrap().parse().unwrap(); + assert!(w > 19.0 && w < 30.0, "the SVG shows the art (the square on the second artboard), not the first artboard: viewBox {view}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index b78eb0976..5d0e5723f 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -1,8 +1,9 @@ //! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke, //! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's //! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners -//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), and the Shared Edge Merge Tool's -//! merges with Keep Shared Points Together ([`shared_edge`]). +//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's +//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes +//! into Paths. //! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's //! and the UI's. @@ -17,7 +18,7 @@ mod tests; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; +use vectorcraft_doc::{AnchorRef, Appearance, AppearanceItem, Document, LineCap, NodeId, NodeKind}; use vectorcraft_geom::{PathData, Point, SubPath}; use vectorcraft_pathops::addons::{self as po, pathtools as pt}; @@ -40,6 +41,8 @@ const MIN_RADIUS: f64 = 0.05; const MAX_DIAMETER: f64 = 2500.0; /// The farthest an end is extended at once (pt). const MAX_EXTEND: f64 = 1e6; +/// Most pieces Divide into Dashes and Expand Dashes into Paths make at once. +const MAX_PIECES: usize = 100_000; /// Most anchors Add Points puts on each segment. const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64; @@ -153,6 +156,24 @@ pub fn specs() -> Vec { has_doc, slide_anchor ), + cmd!( + "addon.pathtools.divideDashes", + "Divide into Dashes", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{dash?: pt 0.01..1000000 (10), gap?: pt 0..1000000 (0: the pieces touch), fit?: bool (false: exact lengths, the last piece of each run what is left; true: fit to corners and ends, every run between corners and ends holding whole dashes, all stretched or squeezed alike so it starts and ends on a dash), removeGaps?: bool (true: the gaps are left empty; false: they stay as pieces too)} cut the selected paths into separate open paths along the curve, each dash long with gap between them and keeping the path's appearance, as one undo step. Only the direct-selected segments (or those between two direct-selected anchors) are divided when there are any, the dash pattern running on across segments that follow one another; the rest of such a path stays, as pieces of its own. The pieces are selected; at most 100000 → {ids, dashes}", + has_selection, + divide_dashes + ), + cmd!( + "addon.pathtools.expandDashes", + "Expand Dashes into Paths", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{ids?: [id…] (default: the selection)} turn the dashed stroke of each path and compound path under the objects into separate open paths, one per dash, laid out as the stroke draws them (its dash pattern, offset and Align Dashes to Corners and Path Ends), each keeping the path's appearance with the dashes off, so the gaps are real gaps: a dash running all the way round a closed path stays closed, and with a round or projecting cap a dash of no length (a dot) becomes a path on its point. The top stroke's dashes are the ones expanded; its arrowheads go, and the path's fills stay on it, below its dashes. Paths without a dashed stroke stay as they are. One undo step; the dashes are selected; at most 100000 → {ids, dashes}", + has_selection, + expand_dashes + ), ]; v.extend(shape::specs()); v.extend(corners::specs()); @@ -583,6 +604,16 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result { Ok(json!({ "removed": removed })) } +/// The segments of subpath `si` (`sp`) picked: the direct-selected segments `segments` of its +/// path, else those between two of its selected `anchors`. +fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet) -> BTreeSet { + let n = sp.anchors.len().max(1); + match segments.filter(|set| !set.is_empty()) { + Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), + None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(), + } +} + fn add_points(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.addPoints"; let count = number_or(p, "count", 1.0, C)?; @@ -597,16 +628,10 @@ fn add_points(s: &mut Session, p: &Value) -> Result { let mut changed = vec![]; for (id, by_sub) in &picked { let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue }; - let picked_segments = st.selection.segments.get(id); let mut np = path.clone(); for (si, sp) in np.subpaths.iter_mut().enumerate() { let Some(anchors) = by_sub.get(&si) else { continue }; - let n = sp.anchors.len(); - // Picked segments, else those between two selected anchors. - let segs: BTreeSet = match picked_segments { - Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), - None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n.max(1)))).collect(), - }; + let segs = picked_segments(st.selection.segments.get(id), si, sp, anchors); let (out, marks) = pt::add_points(sp, Some(&segs), count as usize); added += marks.len(); *sp = out; @@ -721,3 +746,157 @@ fn crossings(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>(), "crossings": count })) } + +// ---------- Divide into Dashes ---------- + +fn divide_dashes(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.divideDashes"; + let spec = pt::DashSpec { + dash: given(p, "dash", Some((0.01, 1e6)), C)?.unwrap_or(10.0), + gap: given(p, "gap", Some((0.0, 1e6)), C)?.unwrap_or(0.0), + fit: switch_or(p, "fit", false, C)?, + }; + let remove_gaps = switch_or(p, "removeGaps", true, C)?; + let picked = picked_anchors(s, true)?; + let st = s.doc()?; + let d = &st.doc; + check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?; + let (mut made, mut dashes) = (0, 0); + // Each path divided: what stays of it (the subpaths not divided, as one path) and its pieces. + let mut changed: Vec<(NodeId, Vec)> = vec![]; + for (id, by_sub) in &picked { + let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue }; + let (mut kept, mut pieces) = (vec![], vec![]); + for (si, sp) in path.subpaths.iter().enumerate() { + let segs = by_sub.get(&si).map(|anchors| picked_segments(st.selection.segments.get(id), si, sp, anchors)).unwrap_or_default(); + if segs.is_empty() { + kept.push(sp.clone()); + 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"))?; + 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); + pieces.push(PathData::single(piece)); + } + } + if !pieces.is_empty() { + changed.push((*id, (!kept.is_empty()).then(|| PathData::new(kept)).into_iter().chain(pieces).collect())); + } + } + if changed.is_empty() { + return Err(bad(C, "select paths, or segments, to divide")); + } + let ids = s.edit("Divide into Dashes", |d, sel| { + let mut all = vec![]; + let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in changed { + all.extend(replace_with_pieces(d, &mut beside, id, pieces)?); + } + beside.finish(d)?; + // The pieces join the objects selected (whole, in one go: there may be many). + let before = std::mem::take(&mut sel.objects); + sel.set(before.into_iter().chain(all.iter().copied())); + Ok(all) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "dashes": dashes })) +} + +// ---------- Expand Dashes into Paths ---------- + +/// The paths and compound paths under `roots` whose top stroke is dashed: a compound path's +/// members stand for it, which holds their paint. Clipping paths are left alone. +fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec { + let editable = d.editable(); + let mut seen = BTreeSet::new(); + let mut out = vec![]; + for id in paths_under(d, roots) { + let compound = d.parent_of(id).and_then(|p| d.node(p)).filter(|p| matches!(p.kind, NodeKind::Compound { .. })); + let Some(n) = compound.or_else(|| d.node(id)) else { continue }; + let dashed = n.appearance.stroke().and_then(|st| st.dash.as_ref()).is_some_and(|dash| dash.is_dashed()); + if dashed && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) && editable(n.id) && seen.insert(n.id) { + out.push(n.id); + } + } + out +} + +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 roots = checked_ids_or_selection(s, p, C)?; + let d = &s.doc()?.doc; + // Each dashed path and its dashes, as the canvas draws them. + let mut changed: Vec<(NodeId, Vec)> = vec![]; + let mut count = 0; + for id in dashed_paths(d, &roots) { + let Some(n) = d.node(id) else { continue }; + let (Some(st), Some(bp)) = (n.appearance.stroke(), n.stroke_path()) else { continue }; + let Some(dash) = &st.dash else { continue }; + // The line the stroke draws: the path, its ends trimmed under any arrowheads. + let line = vectorcraft_render::effects::stroke::stroke_pieces(&bp, st).line; + // A pattern too fine to draw is drawn solid: it has too many dashes to expand. + let dashed = vectorcraft_render::effects::stroke::dash(&line, dash).ok_or_else(too_many)?; + let mut pieces = PathData::from_bezpath(&dashed.path).subpaths; + // A dot paints its cap; a path that stays on its point is drawn so too. Under a butt cap it + // paints nothing. + if st.cap != LineCap::Butt { + pieces.extend(dashed.dots.iter().map(|dot| SubPath::polyline(&[dot.at, dot.at], false))); + } + count += pieces.len(); + if count > MAX_PIECES { + return Err(too_many()); + } + if !pieces.is_empty() { + changed.push((id, pieces)); + } + } + if changed.is_empty() { + return Err(bad(C, "select paths with dashed strokes to expand")); + } + let ids = s.edit("Expand Dashes into Paths", |d, sel| { + let mut all = vec![]; + let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in changed { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &n.kind else { continue }; + let rule = *rule; + let filled = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none()); + // The dashes: open paths with the strokes (the top one's dashes and arrowheads off). + let mut look = Appearance::clone(&n.appearance); + look.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); + if let Some(st) = look.stroke_mut() { + (st.dash, st.start_arrow, st.end_arrow) = (None, None, None); + } + let mut dash = n.clone(); + dash.kind = NodeKind::Path { path: PathData::default(), rule, live: None, clipping: false, guide: false }; + dash.appearance = look.into(); + // The fills stay on the path, below its dashes; without fills it goes. + let mut parts = vec![]; + if n.appearance.items.iter().any(filled) { + let mut fill = n.clone(); + let mut look = Appearance::clone(&n.appearance); + look.items.retain(filled); + fill.appearance = look.into(); + parts.push(fill); + } + for sp in pieces { + let mut piece = d.reid(&dash); + if let NodeKind::Path { path, .. } = &mut piece.kind { + *path = PathData::single(sp); + } + all.push(piece.id); + parts.push(piece); + } + beside.put(d, id, parts)?; + } + beside.finish(d)?; + // The dashes join the objects selected (whole, in one go: there may be many). + let before = std::mem::take(&mut sel.objects); + sel.set(before.into_iter().filter(|id| d.node(*id).is_some()).chain(all.iter().copied())); + Ok(all) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "dashes": ids.len() })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index 52f0c7078..32bcc0c3b 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -674,3 +674,200 @@ fn the_slide_tool_previews_and_commits_one_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(at(&s), Point::new(200.0, 100.0)); } + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +#[test] +fn divide_into_dashes_cuts_a_path_into_open_pieces_keeping_its_look() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false); + select(&mut s, &[l]); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + // 30 + 10 + 30 + 10 + 20: the gaps are left empty. + let pieces = ids(&r); + assert_eq!((pieces.len(), r["dashes"].clone()), (3, json!(3))); + let st = s.doc().unwrap(); + let spans: Vec<(f64, f64)> = pieces.iter().map(|id| path(&s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect(); + let want = [(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]; + assert!(spans.iter().zip(want).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{spans:?}"); + assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.stroke_width() == 3.0), "the stroke stays"); + assert_eq!(st.selection.objects, pieces); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, l).anchor_count(), 2); + // Kept gaps; fitted 25s with no gap. + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10, "removeGaps": false})).unwrap(); + assert_eq!((ids(&r).len(), r["dashes"].clone()), (5, json!(3))); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "fit": true})).unwrap(); + let spans: Vec = ids(&r).iter().map(|id| path(&s, *id).bounds().unwrap().width()).collect(); + assert_eq!(spans.len(), 3); + assert!(spans.iter().all(|w| (w - 100.0 / 3.0).abs() < 1e-6), "{spans:?}"); +} + +#[test] +fn divide_into_dashes_divides_only_the_selected_segments() { + let mut s = session(); + let u = line(&mut s, &[(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)], false); + // The middle side, between anchors 1 and 2. + select_anchors(&mut s, u, &[1, 2]); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 10})).unwrap(); + let pieces = ids(&r); + assert_eq!((pieces.len(), r["dashes"].clone()), (6, json!(4)), "the first side, four dashes, the last side"); + assert_eq!(path(&s, pieces[0]).bounds().unwrap().width(), 40.0); + let layer: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, pieces, "in the path's place, in order"); +} + +#[test] +fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false); + select(&mut s, &[l]); + refuses( + &mut s, + "addon.pathtools.divideDashes", + &[ + json!({"dash": 0}), + json!({"dash": -5}), + json!({"dash": 1e308}), + json!({"dash": "10"}), + json!({"gap": -1}), + json!({"gap": 2e6}), + json!({"fit": "yes"}), + json!({"removeGaps": 1}), + json!({"dash": 0.5}), + ], + ); + // 200000 pieces refused above; 1000 is fine. + assert_eq!(s.execute("addon.pathtools.divideDashes", &json!({"dash": 100})).unwrap()["dashes"], json!(1000)); + s.execute("select.none", &json!({})).unwrap(); + refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]); +} + +/// The dashes made of `r`'s ids: (first x, last x) of each, by its anchors. +fn dash_spans(s: &Session, r: &Value) -> Vec<(f64, f64)> { + ids(r).iter().map(|id| path(s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect() +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6) +} + +#[test] +fn expand_dashes_makes_an_open_path_of_each_dash_keeping_the_stroke() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false); + select(&mut s, &[l]); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("stroke.set", &json!({"weight": 3, "cap": "round", "dash": [30, 10]})).unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert!(near(&dash_spans(&s, &r), &[(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]), "{:?}", dash_spans(&s, &r)); + assert_eq!(r["dashes"], json!(3)); + let st = s.doc().unwrap(); + for id in ids(&r) { + let node = st.doc.node(id).unwrap(); + let stroke = node.appearance.stroke().unwrap(); + assert_eq!((stroke.width, stroke.cap, stroke.dash.clone()), (3.0, vectorcraft_doc::LineCap::Round, None), "the stroke stays, dashes off"); + assert!(!path(&s, id).subpaths[0].closed); + } + assert_eq!(st.selection.objects, ids(&r)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(l).unwrap().appearance.stroke().unwrap().dash.is_some()); + // The offset shifts the pattern as the stroke draws it: 20 into the first dash. + s.execute("stroke.set", &json!({"dashOffset": 20})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({"ids": [l.0]})).unwrap(); + assert!(near(&dash_spans(&s, &r), &[(0.0, 10.0), (20.0, 50.0), (60.0, 90.0)]), "{:?}", dash_spans(&s, &r)); +} + +#[test] +fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() { + let mut s = session(); + let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap()); + select(&mut s, &[rect]); + s.execute("stroke.set", &json!({"dash": [20, 20]})).unwrap(); + let had_fill = !s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint().is_none(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + // 320 round: eight dashes of 20, a dash bending round each corner it meets. + let pieces = ids(&r); + assert_eq!(pieces.len(), 8); + let st = s.doc().unwrap(); + let layer: Vec = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + if had_fill { + let kept = st.doc.node(rect).unwrap(); + assert!(kept.appearance.stroke().is_none() && kept.appearance.fill().is_some(), "the fill stays on the rectangle"); + assert_eq!(layer, [vec![rect], pieces.clone()].concat(), "the fill below its dashes"); + } else { + assert_eq!(layer, pieces); + } + assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.fill().is_none()), "an open dash isn't filled"); + s.execute("edit.undo", &json!({})).unwrap(); + // Aligned to corners: a dash centred on each corner, the four sides each starting and ending + // on half a dash; 12 dashes along the sides. + s.execute("stroke.set", &json!({"dash": [10, 10], "alignDashes": true})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)]; + let mut centred = 0; + for id in ids(&r) { + let sp = path(&s, id).subpaths[0].clone(); + if let [a, b, c] = sp.anchors.as_slice() + && corners.iter().any(|k| b.p.distance(*k) < 1e-6) + { + assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}"); + centred += 1; + } + } + assert_eq!((ids(&r).len(), centred), (16, 4)); +} + +#[test] +fn expand_dashes_turns_dots_into_paths_on_their_point_and_releases_compound_paths() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false); + select(&mut s, &[l]); + s.execute("stroke.set", &json!({"dash": [0, 20], "cap": "round"})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + assert_eq!(ids(&r).len(), 6, "a dot every 20 from 0 to 100"); + assert!(ids(&r).iter().all(|id| path(&s, *id).bounds().unwrap().width() == 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + // Under a butt cap a dot paints nothing, so there is nothing to expand. + s.execute("stroke.set", &json!({"cap": "butt"})).unwrap(); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]); + // A compound path's dashes are paths of their own with its stroke. + let a = line(&mut s, &[(0.0, 100.0), (50.0, 100.0)], false); + let b = line(&mut s, &[(0.0, 120.0), (50.0, 120.0)], false); + select(&mut s, &[a, b]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + s.execute("stroke.set", &json!({"weight": 2, "dash": [20, 10], "cap": "butt"})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + // Each 50 line: 20, gap, 20. + let st = s.doc().unwrap(); + assert_eq!(ids(&r).len(), 4); + for id in ids(&r) { + let n = st.doc.node(id).unwrap(); + assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "a plain path"); + assert_eq!(n.appearance.stroke_width(), 2.0); + assert!(n.appearance.stroke().unwrap().dash.is_none()); + } + assert!(st.doc.node(c).is_none_or(|n| n.appearance.stroke().is_none()), "the compound path's dashes are gone from it"); +} + +#[test] +fn expand_dashes_refuses_without_dashes_and_past_the_limit() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]); + select(&mut s, &[l]); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({}), json!({"ids": "x"}), json!({"zzz": 1})]); + // 125000 dashes refused; 1000 are fine. + s.execute("stroke.set", &json!({"dash": [0.4, 0.4]})).unwrap(); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]); + s.execute("stroke.set", &json!({"dash": [50, 50]})).unwrap(); + assert_eq!(s.execute("addon.pathtools.expandDashes", &json!({})).unwrap()["dashes"], json!(1000)); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index f6aecc32d..33379e752 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -27,7 +27,7 @@ use vectorcraft_doc::appearance::SharedStack; use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; use vectorcraft_geom::Rect; -use super::edit::{outermost, selected_roots}; +use super::edit::{outermost, roots_of, selected_roots}; use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids` and no `target: \"contents\"`, the new fill is a copy of that row, right above it, and becomes the active row", has_doc, |s, p| add_item(s, p, true) ), @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", has_doc, reduce_basic ), @@ -255,17 +255,36 @@ impl ItemTarget { if fits == Some(true) { self } else { ItemTarget::Top } } - /// This target for each of `ids` in `d` (in their order): an aligned item (a row of the first - /// target's stack) is, in each other target, the item equal to it there (see + /// This target for each of `ids` (in their order): an aligned item (a row of the first + /// target's stack, or for the active item of the first selected object's, see + /// [`ItemTarget::anchor`]) is, in each other target, the item equal to it there (see /// [`SharedStack`]); `None` for a target that has none, which the edit leaves alone. A row the /// targets don't share, and any other target, stands for itself in every target. - pub(crate) fn per_target(self, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec> { + pub(crate) fn per_target(self, s: &Session, ids: &[NodeId]) -> Vec> { let ItemTarget::Item { index, explicit, aligned: true } = self else { return vec![Some(self); ids.len()] }; - let Some(shared) = shared_stack(d, ids) else { return vec![Some(self); ids.len()] }; + let Some(st) = s.active().filter(|_| ids.len() >= 2) else { return vec![Some(self); ids.len()] }; + let k = self.anchor(s, ids); + let mut order = ids.to_vec(); + order.swap(0, k); + let Some(shared) = shared_stack(&st.doc, &order) else { return vec![Some(self); ids.len()] }; if !shared.has_item(index) { return vec![Some(self); ids.len()]; } - (0..ids.len()).map(|k| shared.item_in(index, k).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect() + let mut per: Vec> = + (0..order.len()).map(|j| shared.item_in(index, j).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect(); + per.swap(0, k); + per + } + + /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of + /// is: the first selected object (or the compound path holding it) for the active item, which + /// need not paint first; the first target otherwise, or when that object is not among them. + pub(crate) fn anchor(self, s: &Session, ids: &[NodeId]) -> usize { + let ItemTarget::Item { explicit: false, .. } = self else { return 0 }; + let Some(st) = s.active() else { return 0 }; + let Some(first) = st.selection.subjects().first() else { return 0 }; + let root = roots_of(&st.doc, vec![*first]).first().copied().unwrap_or(*first); + ids.iter().position(|id| *id == root).unwrap_or(0) } /// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one. @@ -407,7 +426,7 @@ pub(crate) fn edit_items_then( return Ok(()); } let before = s.doc()?.doc.clone(); - let per = item.per_target(&before, ids); + let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); for (id, item) in ids.iter().zip(&per) { @@ -429,7 +448,7 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or /// null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; @@ -439,7 +458,7 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo let ItemTarget::Item { index, .. } = item else { return Ok(default) }; let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; - Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`index`]. @@ -470,12 +489,45 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result { fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?; let defaults = s.paint.clone(); + // The panel's active Fill row, when the new fill goes on the selection: the new fill is a copy + // of that row, right above it in each target that has it (others get a copy of their top fill). // Where the new item went in on the active item's object, when it is an image. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); + // The active row is a row of the active object's stack, which need not come first in `ids` + // (they are in paint order): match the others against it. + let anchor = active.and_then(|a| ids.iter().position(|id| *id == a)); + let selected = match (s.appearance_item(), anchor) { + (Some(index), Some(_)) if fill && p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => { + ItemTarget::Item { index, explicit: false, aligned: true }.of_kind(s, true) + } + _ => ItemTarget::Top, + }; + let per: Vec> = match (selected, anchor) { + (ItemTarget::Item { index, .. }, Some(k)) => { + let mut order = ids.clone(); + order.swap(0, k); + let mut per: Vec> = + items_per_target(&s.doc()?.doc, &order, &[index], true).into_iter().map(|v| v.first().copied()).collect(); + per.swap(0, k); + per + } + _ => vec![None; ids.len()], + }; let mut inserted = None; + // Where the new fill went in on the active item's object, when it went above the active row. + let mut above = None; s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { - for id in &ids { + for (id, row) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { + if let Some(i) = n.appearance.item_of_kind(*row, true) + && let Some(paint) = n.appearance.paint_at(Some(i), true).cloned() + { + n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint))); + if active == Some(*id) { + above = Some(i + 1); + } + continue; + } // A copy of the top fill (stroke); without one, type's characters' and otherwise the // default paint, so a new fill on a group or type shows. let paint = match (n.appearance.paint_at(None, fill), &n.kind) { @@ -509,8 +561,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } Ok(()) })?; - // The rows above the new item move up by one. - s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a)))); + // A fill added above the active row becomes the active row; otherwise the rows above the new + // item move up by one. + s.remap_appearance_item(&ids, |a| above.or(Some(a + usize::from(inserted.is_some_and(|k| k <= a))))); ok() } @@ -580,11 +633,30 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { let ids = require_targets(s, p, "appearance.reduceToBasic")?; // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); + // The Appearance panel's active row (a row of the first selected object's stack, and in each + // other target the row equal to it there), when the command acts on that selection. The targets + // are in paint order, so the first selected object can be any of them. + let first = s.doc()?.selection.subjects().first().and_then(|f| ids.iter().position(|id| id == f)); + let active = match (s.appearance_item(), first) { + (Some(index), Some(k)) if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => { + let mut from = ids.clone(); + from.swap(0, k); + let mut rows = items_per_target(&before, &from, &[index], true); + rows.swap(0, k); + rows + } + _ => vec![Vec::new(); ids.len()], + }; super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| { - for id in &ids { + for (id, at) in ids.iter().zip(&active) { if let Some(n) = d.node_mut(*id) { - // The topmost visible fill and stroke stay, without their own transparency or effects. - let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(); + // The active row stays when it is a visible fill or stroke; otherwise the topmost + // visible one of each kind does. Both lose their own transparency and effects. + let picked = at.first().and_then(|i| n.appearance.items.get(*i)).filter(|i| i.visible()).cloned(); + let top = |fill: bool| match &picked { + Some(i) if i.is_fill() == fill => Some(i.clone()), + _ => n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(), + }; let (fill, stroke) = (top(true), top(false)); // Other objects always keep a fill row (None when there was none); a group only // keeps the rows it had. diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 9bb04dac9..04498111c 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -335,7 +335,7 @@ fn scatter_shapes() -> BrushLibrary { a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(gold)))); a }); - doc.symbols.push(Symbol { name: "Sparkle".into(), art: Arc::new(sparkle) }); + doc.symbols.push(Symbol { dynamic: false, name: "Sparkle".into(), art: Arc::new(sparkle) }); let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Sparkle".into(), xf: Affine::scale(0.3) }); let random = |a: f64, b: f64| (a, b); let base = Scatter { colorization: Colorization::Tints, ..Default::default() }; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8ef171d32..a1f03b6a3 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -25,6 +25,7 @@ use super::{ use crate::EngineError; mod brushes; +mod dynamic; mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; @@ -32,10 +33,12 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -pub(crate) use symbols::{natural_size, normalize, place_xf, set_natural_size}; +use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +use symbols::selected_instances; +pub(crate) use symbols::{fit_instances_of, instance_in_place, instances_of, natural_size, normalize, place_xf, set_natural_size, store_symbol}; use symbols::{ - symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place, - symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update, + symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, + symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, }; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; @@ -125,7 +128,7 @@ pub fn specs() -> Vec { "Select All Unused", [], None, - "{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles and graphic styles count, and so do strokes in the art of a brush that is used", + "{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and graphic styles count, and so do strokes in the art of a brush that is used", has_doc, brush_unused ), @@ -134,7 +137,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 for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). 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 for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), 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 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). 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°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). 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 for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), 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 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 ), @@ -157,13 +160,21 @@ pub fn specs() -> Vec { brush_freehand ), // ----- symbols ----- - cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list), + cmd!( + query "symbol.list", + "Symbols", + [], + None, + "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic, nineSlice, type: static|dynamic}], current}", + has_doc, + symbol_list + ), cmd!( "symbol.new", "New Symbol…", [], None, - "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice, type}", has_selection, symbol_new ), @@ -172,7 +183,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", has_doc, symbol_options ), @@ -190,7 +201,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol) → {ids}", has_selection, symbol_break_link ), @@ -199,16 +210,25 @@ pub fn specs() -> Vec { "Edit Symbol", [], None, - "{id?, ids?: [id…] (instead of id)} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}", - has_selection, - symbol_edit + "{id?, ids?: [id…] (instead of id), name?, x?, y?, artboard?: index=0 (the active artboard; the app passes it), viewCenter?: [x, y] (the app passes the view's center)} edit a symbol's art in symbol editing mode: the instance `id` (default: the selected instances, which must share one symbol) where it shows the art, else the symbol `name` (default: the current symbol) centered on (x, y) (default: the center of artboard `artboard`, or of the first artboard when there is no such artboard; in a document without artboards, viewCenter, else (0, 0)); every instance follows each edit and the instance stays linked (symbol.breakLink expands one); refused while a pattern or an opacity mask is edited → {name, layer, id?}", + has_doc, + super::symboledit::enter + ), + cmd!( + "symbol.exitEditing", + "Exit Symbol Editing Mode", + [], + None, + "{} leave symbol editing mode; after an edit, select the instance it was entered from; leaving without having changed the art adds no undo step and restores the selection from before entry (object.exitIsolation also leaves it) → {name?}", + has_doc, + super::symboledit::leave ), cmd!( "symbol.update", "Redefine Symbol", [], None, - "{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}", + "{name, ids?|id?} replace the symbol's art with the selection (instances update, keeping their appearance changes to the objects at paths the new art still has; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}", has_selection, symbol_update ), @@ -217,7 +237,7 @@ pub fn specs() -> Vec { "Delete Symbol", [], None, - "{name, expandInstances?: true} delete a symbol; its instances are expanded (or deleted with false)", + "{name | names: [name…], expandInstances?: true} delete a symbol (or several, in one undo step); its instances are expanded (or deleted with false)", has_doc, symbol_delete ), @@ -227,10 +247,28 @@ pub fn specs() -> Vec { "Replace Symbol", [], None, - "{name, ids?|id?} swap the symbol of the selected instances → {ids}", + "{name, ids?|id?} swap the symbol of the selected instances (they keep their appearance changes to the objects at paths a dynamic new symbol's art has) → {ids}", has_selection, symbol_replace ), + cmd!( + "symbol.instance.setAppearance", + "Symbol Instance Appearance", + [], + None, + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + has_selection, + symbol_instance_set_appearance + ), + cmd!( + "symbol.instance.reset", + "Reset Symbol Instance", + [], + None, + "{ids?|id?} drop the selected dynamic symbol instances' own appearance changes, so they show the symbol's appearance again → {ids: the instances that had changes}", + has_selection, + symbol_instance_reset + ), cmd!( "symbol.setCurrent", "Current Symbol", @@ -240,6 +278,15 @@ pub fn specs() -> Vec { has_doc, symbol_set_current ), + cmd!( + query "symbol.unused", + "Select All Unused", + [], + None, + "{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used", + has_doc, + symbol_unused + ), cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances), cmd!( "symbol.spray", diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 66dd546e9..9cc52c82c 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -362,14 +362,15 @@ fn art_brushes(n: &Node, out: &mut Vec) { } /// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph -/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush -/// that a counted stroke paints with. +/// designs, pattern tiles and the tiles that pattern editing's Cancel puts back, and graphic styles +/// count, and so do the strokes in the art of every brush that a counted stroke paints with. pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; let lib = brush::library(d); let mut todo = vec![]; let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); - trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); + let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); + trees.chain(patterns.flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo)); let mut used = BTreeSet::new(); while let Some(name) = todo.pop() { @@ -389,7 +390,77 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result { let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); if let Some(params) = p.get("params") { + let original = v.clone(); merge(&mut v, params); + if lib[i].kind.type_id() == "pattern" { + let tiles = [ + ("side_tile", "side"), + ("outer_corner_tile", "outer_corner"), + ("inner_corner_tile", "inner_corner"), + ("start_tile", "start"), + ("end_tile", "end"), + ]; + // Resolve all Original choices against the brush before any tile can clear the art + // another corner borrows. Keep the matching frame with each copied tile. + let originals: Vec<_> = tiles + .iter() + .map(|(_, art)| { + let from = if art.ends_with("corner") + && original.get(art).is_none_or(Value::is_null) + && original.get(if *art == "outer_corner" { "outer_source" } else { "inner_source" }).is_none_or(Value::is_null) + { + if *art == "outer_corner" { "inner_corner" } else { "outer_corner" } + } else { + *art + }; + (original.get(from).cloned().unwrap_or(Value::Null), original.get(format!("{from}_frame")).cloned().unwrap_or(Value::Null)) + }) + .collect(); + for ((tile, art), (original_art, original_frame)) in tiles.into_iter().zip(originals) { + let frame = format!("{art}_frame"); + let swatch = format!("{art}_swatch"); + let Some(choice) = params.get(tile) else { + if params.get(art).is_some() { + if params.get(frame.as_str()).is_none() { + v[frame.as_str()] = Value::Null; + } + if params.get(swatch.as_str()).is_none() { + v[swatch.as_str()] = Value::Null; + } + } + continue; + }; + let (choice, swatch_only) = if let Some(name) = choice.as_object() { + (name.get("pattern").and_then(Value::as_str).ok_or_else(|| bad(C, "invalid pattern tile choice"))?, true) + } else { + (choice.as_str().ok_or_else(|| bad(C, "invalid pattern tile choice"))?, false) + }; + if art == "side" && choice == "none" && !swatch_only { + return Err(bad(C, "You must choose a side pattern.")); + } + if choice == "original" && !swatch_only { + v[art] = original_art; + v[frame.as_str()] = original_frame; + v[swatch.as_str()] = Value::Null; + } else if !swatch_only && (choice == "none" || (art.ends_with("corner") && brush::CornerSource::parse(choice).is_some())) { + v[art] = Value::Null; + v[frame.as_str()] = Value::Null; + v[swatch.as_str()] = Value::Null; + if art.ends_with("corner") { + v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = json!(choice); + } + } else { + let pattern = s.doc()?.doc.pattern(choice).ok_or_else(|| bad(C, format!("no pattern named `{choice}`")))?; + let copy = Node::new(NodeId(0), NodeKind::Group { children: pattern.art.clone(), clip: false }); + v[art] = serde_json::to_value(copy).unwrap_or_default(); + v[frame.as_str()] = serde_json::to_value(pattern.tile).unwrap_or_default(); + v[swatch.as_str()] = json!(choice); + if art.ends_with("corner") { + v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = Value::Null; + } + } + } + } } let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); if let Some(nn) = &new_name { diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs new file mode 100644 index 000000000..baa721c45 --- /dev/null +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -0,0 +1,151 @@ +//! Dynamic symbols (Symbol Options › Symbol Type): an instance of a dynamic symbol changes the +//! appearance of objects in the symbol's art and stays linked to the symbol +//! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance +//! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still +//! there ([`fit_overrides`]). + +use std::sync::Arc; + +use vectorcraft_color::BlendMode; +use vectorcraft_doc::symbol_overrides::{self, SymbolOverride}; + +use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, ids_json, json, roots, selected_instances}; + +/// The `type` param, Symbol Options › Symbol Type: `Some(true)` for dynamic. +pub(super) fn type_param(p: &Value, cmd: &str) -> Result> { + let Some(v) = p.get("type").filter(|v| !v.is_null()) else { return Ok(None) }; + match v.as_str() { + Some("static") => Ok(Some(false)), + Some("dynamic") => Ok(Some(true)), + _ => Err(bad(cmd, format!("type must be static or dynamic, not {v}"))), + } +} + +/// The `type` a symbol reports. +pub(super) fn type_id(dynamic: bool) -> &'static str { + if dynamic { "dynamic" } else { "static" } +} + +/// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or +/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art. +pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { + for id in ids { + let art = match d.node(*id) { + Some(n) if !n.extras().symbol_overrides.is_empty() => match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).filter(|s| s.dynamic).map(|s| s.art.clone()), + _ => continue, + }, + _ => continue, + }; + let Some(n) = d.node_mut(*id) else { continue }; + let mut e = n.extras_mut(); + match art { + Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art), + None => e.symbol_overrides.clear(), + } + } +} + +/// The instance `id` names (else the one selected object) and its symbol's art; the symbol must +/// be dynamic. +fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc)> { + let d = &s.doc()?.doc; + let not_one = || EngineError::Other("select a symbol instance".into()); + let [id] = roots(s, p)?[..] else { return Err(not_one()) }; + let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node(id).map(|n| &n.kind) else { return Err(not_one()) }; + let sym = d.symbols.iter().find(|x| x.name == *symbol).ok_or_else(|| bad(cmd, format!("no symbol named `{n}`", n = symbol)))?; + if !sym.dynamic { + let name = &sym.name; + return Err(bad(cmd, format!("`{name}` is a static symbol: set its Symbol Type to Dynamic first"))); + } + Ok((id, sym.art.clone())) +} + +/// The `path` param: child indices from the art's root, naming an object of `art`. +fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { + let path: Option> = + p.get("path").and_then(Value::as_array).and_then(|a| a.iter().map(|i| i.as_u64().and_then(|i| u32::try_from(i).ok())).collect()); + let path = path.ok_or_else(|| bad(cmd, "path must be a list of child indices"))?; + if symbol_overrides::part(art, &path).is_none() { + let shown = json!(path); + return Err(bad(cmd, format!("no object at path {shown} in the symbol's art"))); + } + Ok(path) +} + +/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or +/// an object of `paint.setFill`'s paint params sets it. +fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result>> { + let Some(v) = p.get(key) else { return Ok(None) }; + if v.is_null() { + return Ok(Some(None)); + } + let v = if v.is_string() { json!({ "color": v }) } else { v.clone() }; + let paint = crate::cmd::paint::paint_from(s, &v)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; + Ok(Some(Some(paint))) +} + +/// The change `p` asks of the override `old` (for the object at `path`). +fn changed(s: &Session, p: &Value, old: SymbolOverride, cmd: &str) -> Result { + let mut o = old; + if let Some(fill) = paint_change(s, p, "fill", cmd)? { + o.fill = fill; + } + if let Some(stroke) = paint_change(s, p, "stroke", cmd)? { + o.stroke = stroke; + } + check_numbers(p, &["opacity"], cmd)?; + if let Some(v) = p.get("opacity") { + o.opacity = v.as_f64().map(crate::cmd::opacitymask::percent); + } + if let Some(v) = p.get("blend") { + o.blend = match v { + Value::Null => None, + b => Some(b.as_str().and_then(BlendMode::parse).ok_or_else(|| bad(cmd, format!("unknown blend mode {b}")))?), + }; + } + Ok(o) +} + +pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.setAppearance"; + if !["fill", "stroke", "opacity", "blend"].iter().any(|k| p.get(k).is_some()) { + return Err(bad(C, "give fill, stroke, opacity and/or blend")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + let d = &s.doc()?.doc; + let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); + let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; + let count = s.edit("Symbol Instance Appearance", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + })?; + Ok(json!({ "id": id.0, "overrides": count })) +} + +pub(super) fn symbol_instance_reset(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let d = &s.doc()?.doc; + let changed: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.extras().symbol_overrides.is_empty())).collect(); + if !changed.is_empty() { + s.edit("Reset Symbol Instance", |d, _| { + for id in &changed { + if let Some(n) = d.node_mut(*id) { + n.extras_mut().symbol_overrides.clear(); + } + } + Ok(()) + })?; + } + Ok(json!({ "ids": ids_json(&changed) })) +} diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 8e8ba163e..0b69b7f8a 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -1,3 +1,4 @@ +use super::dynamic::{fit_overrides, type_id, type_param}; use super::{ Affine, AppearanceItem, Arc, CURRENT_SYMBOL, Color, Document, EngineError, FillLayer, HALF, Node, NodeId, NodeKind, Paint, Point, Result, SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_value, ids_json, json, @@ -94,7 +95,14 @@ pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { Ok((art, (w, h), c)) } -fn instances_of(d: &Document, name: Option<&str>) -> Vec { +/// After the art of symbol `name` changed: each of its instances keeps its appearance changes to +/// the objects the art still has. +pub(crate) fn fit_instances_of(d: &mut Document, name: &str) { + let ids = instances_of(d, Some(name)); + fit_overrides(d, &ids); +} + +pub(crate) fn instances_of(d: &Document, name: Option<&str>) -> Vec { let mut v = vec![]; d.walk(|n| { if let NodeKind::SymbolInstance { symbol, .. } = &n.kind @@ -106,6 +114,37 @@ fn instances_of(d: &Document, name: Option<&str>) -> Vec { v } +/// The symbols an instance in `n` (its opacity masks included) places, pushed onto `out`. +fn art_symbols(n: &Node, out: &mut Vec) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { + out.push(symbol.clone()); + } + }); +} + +/// Select All Unused (#4918): the symbols nothing places. Instances in the art (opacity masks +/// included), graph designs, pattern tiles and the tiles that pattern editing's Cancel puts back, +/// and the art of the document's brushes count, and so do the instances in the art of every symbol +/// a counted instance places. A symbol placed only inside an unused symbol is unused too. +pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let mut todo = vec![]; + let lib = brush::library(d); + let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); + let trees = d.layers.iter().map(|l| &**l).chain(d.graph_designs.iter().map(|g| &*g.art)); + trees.chain(patterns.flat_map(|p| p.art.iter().map(|n| &**n))).chain(lib.iter().flat_map(|b| b.art())).for_each(|n| art_symbols(n, &mut todo)); + let mut used = std::collections::BTreeSet::new(); + while let Some(name) = todo.pop() { + if !used.contains(&name) { + d.symbols.iter().filter(|sym| sym.name == name).for_each(|sym| art_symbols(&sym.art, &mut todo)); + used.insert(name); + } + } + let names: Vec<&str> = d.symbols.iter().map(|sym| sym.name.as_str()).filter(|n| !used.contains(*n)).collect(); + Ok(json!({ "names": names })) +} + fn symbol_names(d: &Document) -> Vec { d.symbols.iter().map(|s| s.name.clone()).collect() } @@ -128,12 +167,32 @@ pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result { "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id(), "nineSlice": nine_slice_json(d, &sym.name), + "type": type_id(sym.dynamic), }) }) .collect(); Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) } +/// Store `art` ([`normalize`]d, of natural size `size`) as symbol `name`, which becomes the +/// current symbol. +pub(crate) fn store_symbol(d: &mut Document, art: &Node, name: &str, size: (f64, f64)) { + let art = d.reid(art); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(d, name, Some(size)); + d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); +} + +/// Replace the root nodes `ids` with one instance of symbol `name` (already stored) placed by +/// `xf`. Frames of threaded text among them leave their threads with the text they show. Returns +/// the instance's id. +pub(crate) fn instance_in_place(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { + let threaded = crate::cmd::threads::frames(d); + let id = replace_with_instance(d, ids, name, xf)?; + crate::cmd::threads::leave_threads(d, &threaded); + Ok(id) +} + /// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id. fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; @@ -160,26 +219,29 @@ pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result { let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); let export = export_type_param(p, C)?.unwrap_or_default(); let nine = nine_slice_param(p, C, None)?.flatten(); + let dynamic = type_param(p, C)?.unwrap_or(false); // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); let id = s.edit("New Symbol", |d, sel| { - let art = d.reid(&art); - d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); - set_natural_size(d, &name, Some(size)); + store_symbol(d, &art, &name, size); + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.dynamic = dynamic; + } d.set_symbol_registration(&name, reg); d.set_symbol_export_type(&name, export); d.set_symbol_nine_slice(&name, nine); - d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); - let threaded = crate::cmd::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place)?; - // Frames of threaded text in the symbol leave their threads with the text they show. - crate::cmd::threads::leave_threads(d, &threaded); + let id = instance_in_place(d, &ids, &name, place)?; sel.set([id]); Ok(id) })?; - Ok( - json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), - ) + Ok(json!({ + "name": name, + "id": id.0, + "registration": reg.0, + "exportType": export.id(), + "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value), + "type": type_id(dynamic), + })) } pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { @@ -188,9 +250,10 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { let export = export_type_param(p, C)?; let reg = registration_param(p, C)?; let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; + let dynamic = type_param(p, C)?; let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { - return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); + if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() && dynamic.is_none() { + return Err(bad(C, "pass newName, registration, exportType, nineSlice and/or type")); } if let Some(ref n) = new_name && n != &name @@ -218,6 +281,14 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); } + // Symbol editing mode follows the new name, and so does its layer. + if let Some(e) = d.symbol_edit.as_mut().filter(|e| e.symbol == name) { + e.symbol = n.clone(); + let layer = e.layer; + if let Some(l) = d.node_mut(layer) { + l.name = Some(n.clone()); + } + } } if let Some(r) = reg { d.set_symbol_registration(&final_name, r); @@ -228,6 +299,13 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { if let Some(g) = nine { d.set_symbol_nine_slice(&final_name, g); } + if let Some(dynamic) = dynamic + && let Some(sym) = d.symbols.iter_mut().find(|x| x.name == final_name) + { + // A static symbol's instances drop their own appearance changes. + sym.dynamic = dynamic; + fit_overrides(d, &instances_of(d, Some(&final_name))); + } Ok(()) })?; let d = &s.doc()?.doc; @@ -236,6 +314,7 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { "registration": d.symbol_registration(&final_name).0, "exportType": d.symbol_export_type(&final_name).id(), "nineSlice": nine_slice_json(d, &final_name), + "type": type_id(d.symbols.iter().any(|x| x.name == final_name && x.dynamic)), })) } @@ -266,11 +345,12 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0 })) } -/// The plain art of instance `inst` in document space (fresh ids). -fn expanded_instance(d: &mut Document, inst: &Node) -> Option { +/// The plain art of instance `inst` in document space (fresh ids), with the instance's own +/// appearance changes to a dynamic symbol's art when `own`. +fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; - let mut art = (*sym.art).clone(); + let mut art = if own { vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst) } else { (*sym.art).clone() }; if let Some(f) = inst.appearance.fill() && let Some(c) = f.paint.color() { @@ -289,11 +369,13 @@ fn expanded_instance(d: &mut Document, inst: &Node) -> Option { Some(art) } -fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { +/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the +/// art's ids. +fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { let mut out = vec![]; for id in ids { let Some(inst) = d.node(*id).cloned() else { continue }; - let Some(art) = expanded_instance(d, &inst) else { continue }; + let Some(art) = expanded_instance(d, &inst, own) else { continue }; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; let aid = art.id; d.remove(*id)?; @@ -303,7 +385,7 @@ fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { Ok(out) } -fn selected_instances(s: &Session, p: &Value) -> Result> { +pub(super) fn selected_instances(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; let mut out = vec![]; for id in roots(s, p)? { @@ -324,86 +406,151 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no symbol instances selected".into())); } let out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids)?; + let out = break_links(d, &ids, true)?; sel.set(out.iter().copied()); Ok(out) })?; Ok(json!({ "ids": ids_json(&out) })) } -pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result { - let ids = selected_instances(s, p)?; - let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?; - let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) { - Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), - _ => return Err(EngineError::Other("select a symbol instance".into())), - }; - let out = s.edit("Edit Symbol", |d, sel| { - let out = break_links(d, &[first])?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "name": name, "ids": ids_json(&out) })) -} - pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result { const C: &str = "symbol.update"; let name = name_param(p, C)?.to_string(); if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { return Err(bad(C, format!("no symbol named `{name}`"))); } + // Redefining the symbol being edited leaves symbol editing mode in the same step. + let session = s.doc()?.doc.symbol_edit.clone().filter(|e| e.symbol == name); + if session.is_some() { + crate::cmd::settle_interaction(s)?; + s.end_text_edit(); + } let ids = roots(s, p)?; + // The art on the editing layer can't redefine it: the symbol would hold an instance of itself. + if let Some(se) = &session { + let d = &s.doc()?.doc; + if ids.iter().any(|i| d.ancestry(*i).is_some_and(|a| a.contains(&se.layer))) { + return Err(EngineError::Other("a symbol can't hold an instance of itself".into())); + } + } let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); let (art, size, c) = normalize(art)?; let old = natural_size(&s.doc()?.doc, &name); + let editing = session.is_some(); let id = s.edit("Redefine Symbol", |d, sel| { + if editing { + crate::cmd::symboledit::finish(d, sel); + } let art = d.reid(&art); if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { sym.art = Arc::new(art); } set_natural_size(d, &name, Some(size)); let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); - for i in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { + let instances = instances_of(d, Some(&name)); + for i in &instances { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(*i).map(|n| &mut n.kind) { *xf *= fix; } } + // Each instance keeps its appearance changes to the objects the new art still has. + fit_overrides(d, &instances); let reg = d.symbol_registration(&name); - let threaded = crate::cmd::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; - // Frames of threaded text in the symbol leave their threads with the text they show. - crate::cmd::threads::leave_threads(d, &threaded); + let id = instance_in_place(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; sel.set([id]); Ok(id) })?; + if let Some(se) = &session { + crate::cmd::symboledit::leave_editing(s, se)?; + } Ok(json!({ "name": name, "id": id.0 })) } +/// How many levels of nested instances Delete Symbol expands before it gives up. +const MAX_DELETE_ROUNDS: usize = 256; + pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "symbol.delete"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + // `names` deletes several symbols in one undo step (the Symbols panel's Select All Unused, #4918). + let names: Vec = match p.get("names") { + Some(v) => { + let a = v.as_array().ok_or_else(|| bad(C, "names must be an array of symbol names"))?; + let mut names = Vec::with_capacity(a.len()); + for n in a { + let n = n.as_str().ok_or_else(|| bad(C, "names must be an array of symbol names"))?; + if !names.iter().any(|x: &String| x == n) { + names.push(n.to_string()); + } + } + if names.is_empty() { + return Err(bad(C, "names is empty")); + } + names + } + None => vec![name_param(p, C)?.to_string()], + }; + let d = &s.doc()?.doc; + if let Some(name) = names.iter().find(|n| !d.symbols.iter().any(|x| &x.name == *n)) { return Err(bad(C, format!("no symbol named `{name}`"))); } let expand = switch_or(p, "expandInstances", true, C)?; - s.edit("Delete Symbol", |d, _| { - let inst = instances_of(d, Some(&name)); - if expand { - break_links(d, &inst)?; - } else { - remove_each(d, &inst)?; + // Deleting the symbol being edited leaves symbol editing mode in the same step. + let session = s.doc()?.doc.symbol_edit.clone().filter(|e| names.contains(&e.symbol)); + let editing = session.is_some(); + if editing { + crate::cmd::settle_interaction(s)?; + s.end_text_edit(); + } + let label = if names.len() == 1 { "Delete Symbol" } else { "Delete Symbols" }; + s.edit(label, |d, sel| { + if editing { + crate::cmd::symboledit::finish(d, sel); } - d.symbols.retain(|x| x.name != name); - set_natural_size(d, &name, None); - d.set_symbol_registration(&name, SymbolRegistration::CENTER); - d.clear_symbol_export_type(&name); - d.set_symbol_nine_slice(&name, None); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.remove(CURRENT_SYMBOL); + // Every instance goes before any definition, whatever the order of `names`: expanding an + // instance of one symbol can bring out instances of another being deleted (nested in its + // art), which expand in turn while their definitions are still there. Each round expands + // one level of nesting, so the rounds are capped. + let of_names = |d: &Document| { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && names.contains(symbol) + { + v.push(n.id); + } + }); + v + }; + for _ in 0..=MAX_DELETE_ROUNDS { + let inst = of_names(d); + if inst.is_empty() { + break; + } + if expand { + break_links(d, &inst, true)?; + } else { + remove_each(d, &inst)?; + } + } + if !of_names(d).is_empty() { + return Err(EngineError::Other("the symbols are nested too deeply to expand their instances".into())); + } + for name in &names { + d.symbols.retain(|x| &x.name != name); + set_natural_size(d, name, None); + d.set_symbol_registration(name, SymbolRegistration::CENTER); + d.clear_symbol_export_type(name); + d.set_symbol_nine_slice(name, None); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.remove(CURRENT_SYMBOL); + } } Ok(()) })?; + if let Some(se) = &session { + crate::cmd::symboledit::leave_editing(s, se)?; + } ok() } @@ -420,7 +567,7 @@ pub(super) fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { let nine = d.symbol_nine_slice(&name); s.edit("Duplicate Symbol", |d, _| { let art = d.reid(&sym.art); - d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: sym.dynamic, name: new.clone(), art: Arc::new(art) }); set_natural_size(d, &new, Some(size)); d.set_symbol_registration(&new, reg); d.set_symbol_export_type(&new, export); @@ -452,6 +599,7 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); } } + fit_overrides(d, &ids); Ok(()) })?; Ok(json!({ "ids": ids_json(&ids) })) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index a943ee10d..65a6ee0c3 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -85,7 +85,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?, 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)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{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?, 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 ), @@ -739,7 +739,17 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let made = insert_look_turned(s, "Type", NodeKind::Text(Box::new(t)), NewArt::plain(Appearance::default()), None, true, bbox_turn(rotation))?; let id = made.get("id").and_then(Value::as_u64).map(NodeId); let made = super::typecmd::with_bounds(s, id, made); - Ok(super::typecmd::with_overflow(s, id.as_slice(), made)) + let mut made = super::typecmd::with_overflow(s, id.as_slice(), made); + if p.get("font").is_some() || p.get("style").is_some() { + let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(), + _ => Default::default(), + }; + if let Some(w) = super::fonts::set_font_warning(runs) { + made["warning"] = json!(w); + } + } + Ok(made) } /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of @@ -801,7 +811,8 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result CharStyle { let mut style = CharStyle::default(); if let Some(sz) = p.get("size").and_then(Value::as_f64) { @@ -821,5 +832,7 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(), None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), }; + // In the document's language (Document Setup), none for the default. + style.language = s.doc().ok().and_then(|d| vectorcraft_doc::setup::char_language(&d.doc.setup.language).ok().flatten()).map(str::to_string); style } diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 3da43957c..7a0643ca5 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Move in Perspective", [], None, - "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it", + "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it", has_doc, persp_move ), diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 848177027..eda17fee8 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -52,8 +52,8 @@ pub(super) fn grid_set(s: &mut Session, p: &Value) -> Result { old.adopt_stored_attachments(d); for (id, plane, depth) in persp::attached_roots(d) { let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth)); - let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; - let m = h.then_after(&hi); + let m = h.and_then(|(hi, h)| h.checked_then_after(&hi)); + let m = m.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; warp_checked(d, id, &m)?; } } @@ -98,17 +98,15 @@ pub(super) fn roots(s: &Session, p: &Value) -> Result> { } } -/// Map `n` with `f`, failing if any point would leave the plane's visible side. /// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it), -/// failing if any point would leave the plane's visible side. +/// failing if any point would leave the plane's visible side or a projection would not be finite. fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; let mut pts = vec![]; collect_points(n, &mut pts); - if pts.iter().any(|q| h.apply(*q).is_none()) { + if pts.iter().any(|q| h.apply(*q).is_none()) || !project_node(n, h) { return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); } - project_node(n, h); Ok(()) } @@ -284,12 +282,42 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { } let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?); + 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)?; // Snap to Grid: a correction for the objects on the first one's plane and depth. - let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; - let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { - dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2) + let snap_to = if constrain && !perp { + let doc = &s.doc()?.doc; + let g = grid_of(doc); + g.plane_bounds(doc, &ids) + .and_then(|(plane, depth, _)| { + let h = g.homography_at(plane, depth)?; + let start = h.inverse()?.apply(from)?; + let delta = g.plane_delta(plane, depth, from, to)?; + h.apply(start + vectorcraft_tools::distort::perspective::constrain_move_delta(delta).1) + }) + .unwrap_or(to) + } else { + to + }; + let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) }; + let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| { + let (direction, dv) = + if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) }; + dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| { + if !constrain { + return f.2; + } + if direction.x.abs() < 1e-12 { + return vectorcraft_geom::Vec2::new(0.0, f.2.y); + } + if direction.y.abs() < 1e-12 { + return vectorcraft_geom::Vec2::new(f.2.x, 0.0); + } + let (correction, axis) = + if f.2.x == 0.0 || (f.2.y != 0.0 && f.2.y.abs() < f.2.x.abs()) { (f.2.y, direction.y) } else { (f.2.x, direction.x) }; + direction * (correction / axis) + }) }; // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { @@ -569,11 +597,14 @@ pub(super) fn edit_text(s: &mut Session, p: &Value) -> Result { let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) }; // Shown flat where it is drawn: the type moves there and the projection moves the other // way, so the picture stays. - let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; + let Some(flat) = t.bounds() else { return Ok(()) }; + let cross = || EngineError::Other("the object would cross the horizon of the perspective plane".into()); + let drawn = h.map_rect_bbox(flat).ok_or_else(cross)?; let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); + let moved = h.checked_then_after(&Homography::from_affine(shift.inverse())).ok_or_else(cross)?; t.transform(shift); if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { - rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); + rec.projection = Some(moved.to_array()); rec.editing = true; } Ok(()) diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 3f7b63225..f5cb1d6b7 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -127,8 +127,8 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "discardWhiteOverprint" => s.discard_white_overprint = flag(k, v)?, "language" => { let l = v.as_str().unwrap_or(""); - let (name, quotes) = LANGUAGES.iter().find(|(n, _)| n.eq_ignore_ascii_case(l)).ok_or_else(|| { - bad(C, format!("unknown language `{l}` (one of {})", LANGUAGES.iter().map(|(n, _)| *n).collect::>().join(", "))) + let (name, _, quotes) = LANGUAGES.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(l)).ok_or_else(|| { + bad(C, format!("unknown language `{l}` (one of {})", LANGUAGES.iter().map(|(n, _, _)| *n).collect::>().join(", "))) })?; s.language = name.to_string(); s.quotes = *quotes; diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index d34a79afb..6946463e3 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Blob Brush", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), angle?: ° (0, kept within −180–180: the tip's long axis counter-clockwise from horizontal, kept on the page along the stroke), roundness?: % (100, kept within 0–100: the tip's short axis in percent of its long one, 100 round; 0 is drawn as 0.1), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", has_doc, blob ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 6267c8e2c..95a2cd94b 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -45,7 +45,10 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { if sp.anchors.len() < 3 { return sp; } - let fitted = po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false }); + let fitted = po::simplify_with( + &PathData::single(sp.clone()), + &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() }, + ); fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) } @@ -373,8 +376,22 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } - let pressure = size_pressure(p, &samples, C)?; + let angle = number_or(p, "angle", 0.0, C)?; + let roundness = number_or(p, "roundness", 100.0, C)?; + if !angle.is_finite() || !roundness.is_finite() { + return Err(bad(C, "angle and roundness must be finite")); + } + let angle = angle.clamp(-180.0, 180.0); + let roundness = roundness.clamp(0.0, 100.0); + let tip = vectorcraft_tools::draw2::blob_tip(angle, roundness); + let squash = tip.inverse(); + // Width profiles use distance along the spine, so measure pressure on the same squashed + // points that the outline reads for a non-round tip. + let mapped_samples: Vec<(Point, Option)> = + if roundness < 100.0 { samples.iter().map(|(pt, pressure)| (squash * *pt, *pressure)).collect() } else { samples.clone() }; + let pressure = size_pressure(p, &mapped_samples, C)?; let pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); + let mapped_pts: Vec = mapped_samples.into_iter().map(|(pt, _)| pt).collect(); // The widest the brush gets: the area merges look in. let reach = pressure.as_ref().map_or(size, |(_, v)| size + v); // With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil @@ -384,9 +401,12 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)), }; - let spine = |tol: Option| match tol { - Some(tol) => PathData::single(fit_freehand(&pts, tol, false)), - None => PathData::single(SubPath::polyline(&pts, false)), + let spine = |tol: Option| { + let path = match tol { + Some(tol) => PathData::single(fit_freehand(&pts, tol, false)), + None => PathData::single(SubPath::polyline(&pts, false)), + }; + if roundness < 100.0 { path.transformed(squash) } else { path } }; let region = match (&pressure, pts.len() > 1) { (Some((prof, variation)), true) => { @@ -400,15 +420,16 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { } } // A click: one dab as wide as the pen pressed. - (Some((prof, variation)), false) => brush_region(&pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))), + (Some((prof, variation)), false) => brush_region(&mapped_pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))), (None, _) => { let fitted = fidelity .filter(|_| pts.len() > 1) .map(|tol| po::outline_stroke(&spine(Some(tol)), size, Cap::Round, Join::Round, 4.0)) .filter(|r| !r.is_empty()); - fitted.unwrap_or_else(|| brush_region(&pts, size)) + fitted.unwrap_or_else(|| brush_region(&mapped_pts, size)) } }; + let region = if roundness < 100.0 { region.transformed(tip) } else { region }; if region.is_empty() { return Err(EngineError::Other("empty brush stroke".into())); } diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index fd783aa4f..a4aa1f79a 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -9,7 +9,7 @@ use super::{ /// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() }); let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; let (f, l) = (anchors[0], anchors[anchors.len() - 1]); let last = out.len() - 1; @@ -32,8 +32,10 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio if sp.closed { let Some(k) = marked.iter().position(|m| !m) else { // Everything is in range: smooth the whole closed path. - let f = - po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + let f = po::simplify_with( + &PathData::single(sp.clone()), + &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() }, + ); return f.subpaths.into_iter().next(); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a2cab84a8..a00f90952 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -481,6 +481,7 @@ fn undo(s: &mut Session, _: &Value) -> Result { let label = e.label.clone(); let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); + let editing_symbol = st.doc.symbol_edit.clone(); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.redo.last()); @@ -494,6 +495,7 @@ fn undo(s: &mut Session, _: &Value) -> Result { crate::deselect_hidden(&menu, &st.doc, &mut st.selection); st.sync_new_art(); super::maskedit::follow_history(st, editing); + super::symboledit::follow_history(st, editing_symbol.as_ref()); leave_isolation_if_gone(st); super::globaledit::follow_history(st); Ok(json!({ "undone": label })) @@ -507,6 +509,7 @@ fn redo(s: &mut Session, _: &Value) -> Result { let label = e.label.clone(); let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); + let editing_symbol = st.doc.symbol_edit.clone(); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.undo.last()); @@ -519,6 +522,7 @@ fn redo(s: &mut Session, _: &Value) -> Result { crate::deselect_hidden(&menu, &st.doc, &mut st.selection); st.sync_new_art(); super::maskedit::follow_history(st, editing); + super::symboledit::follow_history(st, editing_symbol.as_ref()); leave_isolation_if_gone(st); super::globaledit::follow_history(st); Ok(json!({ "redone": label })) @@ -566,17 +570,29 @@ pub(crate) fn selected_roots(s: &Session) -> Result> { /// rest still go to the compound path, which holds the paint. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { let st = s.doc()?; - Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true)) + Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_| true)) +} + +/// [`selected_roots`] for the selected objects inside the isolated object `root` +/// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the +/// objects held by another selected object are dropped. A selected path of a compound path stands +/// for its compound unless the compound is `root` or `members` is set (as in +/// [`selected_roots_to_transform`]). +pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { + let st = s.doc()?; + let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); + Ok(roots_within(&st.doc, ids, |compound| members || compound == root)) } /// The top-level objects among `ids` (in paint order): compound-path members stand for their /// compound, and ids whose ancestor is also listed, and layers, are dropped. pub(crate) fn roots_of(doc: &Document, ids: Vec) -> Vec { - roots_within(doc, ids, false) + roots_within(doc, ids, |_| false) } -/// [`roots_of`]; with `members`, a compound path's member listed stays itself. -fn roots_within(doc: &Document, ids: Vec, members: bool) -> Vec { +/// [`roots_of`]; a compound path's member listed stays itself when `members` holds for the +/// compound's id. +fn roots_within(doc: &Document, ids: Vec, members: impl Fn(NodeId) -> bool) -> Vec { // Every id's place in the tree, found in one walk (a lookup per id is a walk per id). let paths = doc.index_paths(ids.iter().copied()); // A compound-path member acts as its compound (compounds are one object). @@ -586,7 +602,7 @@ fn roots_within(doc: &Document, ids: Vec, members: bool) -> Vec let path = paths.get(&id); let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty()); match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) { - Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())), + Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members(n.id) => (n.id, Some(up.to_vec())), _ => (id, path.cloned()), } }) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index ec659abe8..feee6913f 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -108,7 +108,7 @@ fn ids_json(ids: &[NodeId]) -> Value { pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { let roots = appearance_targets(s, p)?; let d = &s.doc()?.doc; - let per = item.per_target(d, &roots); + let per = item.per_target(s, &roots); let shared = if aligned(p) { shared_stack(d, &roots) } else { None }; let mut out = Vec::with_capacity(roots.len()); for (k, (id, item)) in roots.iter().zip(per).enumerate() { @@ -213,15 +213,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } } } - let mut index = 0; let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| { fx.push(effect.clone()); - index = fx.len() - 1; true })?; - // The item the effect landed on in the first object (the active item applies where it fits). - let first = ids.first().and_then(|id| s.doc().ok()?.doc.node(*id)); + // The item the effect landed on in the object whose row the item is (the first selected object + // for the active item, which applies where it fits), and the effect's index in that object. + let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id)); let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); + let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1)); Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } @@ -681,10 +681,12 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // Type, images, symbol instances, live objects (and paths whose Transform effect copies // them with a gradient or changes their fills and strokes): through their outlines. let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, &n)) + } _ => None, }; - effects::expand_outlined(Some(&*d), &v, symbol.as_deref()).map(|mut m| { + effects::expand_outlined(Some(&*d), &v, symbol.as_ref()).map(|mut m| { expand_pieces(d, &mut m, true, brushes, &mut stroke_art); m }) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 5ad8054fa..4745437d4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -447,25 +447,206 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// Expand the pattern fills under `root` that paint a transparent or colorized image into the tiles they paint /// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other /// pattern fills stay. A tile image counts when its key is in `see_through` (it has see-through -/// pixels), when it has a colorize paint, or when it, a group +/// pixels), when it has a colorize paint that `colorize` counts, or when it, a group /// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending -/// mode. Returns how many shapes changed. -pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet) -> Result { - let (_, n) = replace_leaves(d, root, |d, l| { - // Most leaves have no pattern fill: skip the ancestry walk for them. - if !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { - return None; +/// mode. Blends, envelopes, repeats and Intertwine groups that hold such a fill are expanded +/// first ([`expand_live_with_transparent_image_patterns`]). Hidden shapes and shapes in hidden +/// groups or layers and in template layers stay as they are. A pattern fill in tile art counts +/// too, down to the [`PATTERN_LEVELS`] the canvas draws, and the tile art the expansion makes is +/// expanded again for it. A pattern fill stays, and a live object stays live, when its art would +/// take the expanded art past [`MAX_GENERATED`](vectorcraft_doc::live::MAX_GENERATED) objects. +/// Returns how many objects changed. +pub(crate) fn expand_transparent_image_patterns( + d: &mut Document, + root: NodeId, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, +) -> Result { + expand_transparent_image_patterns_within(d, root, see_through, colorize, vectorcraft_doc::live::MAX_GENERATED) +} + +/// [`expand_transparent_image_patterns`] with the expanded art held to `budget` objects. +pub(crate) fn expand_transparent_image_patterns_within( + d: &mut Document, + root: NodeId, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + budget: usize, +) -> Result { + let mut changed = 0; + let made = std::cell::Cell::new(0usize); + // Each pass brings the patterns in the expanded tiles' art up one level. + for levels in (1..=PATTERN_LEVELS).rev() { + let live = expand_live_with_transparent_image_patterns(d, root, see_through, colorize, levels, &made, budget)?; + let (_, n) = replace_leaves(d, root, |d, l| { + // Past the budget, and for most leaves, which have no pattern fill, skip the ancestry walk. + if made.get() >= budget || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + return None; + } + // The expanded art would be shown. + if !d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).all(drawn) { + return None; + } + let which = |d: &Document, i: &AppearanceItem| transparent_pattern_fill(d, l, i, see_through, colorize, levels); + let art = expand_patterns_where(d, l, which, budget - made.get())?; + // The copies of the shape's other items count too. + let count = art.count_with_masks(); + if count > budget - made.get() { + return None; + } + made.set(made.get() + count); + Some(art) + })?; + if live + n == 0 { + break; } - let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); - let transparent = |d: &Document, i: &AppearanceItem| { - pattern_fill(d, i).is_some_and(|(f, def, _)| { - let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal; - def.art.iter().any(|a| transparent_image(a, see_through, on)) - }) - }; - expand_patterns_where(d, l, transparent) - })?; - Ok(n) + changed += live + n; + } + Ok(changed) +} + +/// How many levels of patterns the canvas draws: a pattern fill in the art of a pattern, and so +/// on, as [`vectorcraft_render`] draws them. +const PATTERN_LEVELS: u32 = 3; + +/// Whether a layer of `d` holds a pattern fill that [`expand_transparent_image_patterns`] would +/// expand with `see_through` and `colorize` ([`holds_transparent_image_patterns`]). +pub(crate) fn has_transparent_image_patterns(d: &Document, see_through: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool) -> bool { + d.layers.iter().any(|l| holds_transparent_image_patterns(d, l, see_through, colorize, PATTERN_LEVELS)) +} + +/// Whether `n`, art of `d`, is or holds a shown path or compound path (in groups, blends, +/// envelopes, repeats and Intertwine groups) with a pattern fill that +/// [`transparent_pattern_fill`] counts with `levels`. Guides, clipping paths and template layers +/// don't count. +fn holds_transparent_image_patterns( + d: &Document, + n: &Node, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, +) -> bool { + if !drawn(n) { + return false; + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => false, + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + n.appearance.items.iter().any(|i| transparent_pattern_fill(d, n, i, see_through, colorize, levels)) + } + _ => n.children().is_some_and(|ch| ch.iter().any(|c| holds_transparent_image_patterns(d, c, see_through, colorize, levels))), + } +} + +/// Whether `n` is shown and isn't a template layer. +fn drawn(n: &Node) -> bool { + n.visible && !matches!(n.kind, NodeKind::Layer { template: true, .. }) +} + +/// Expand the shown blends, envelopes, repeats and Intertwine groups under `root` that hold a +/// pattern fill [`holds_transparent_image_patterns`] counts with `levels` into the art the exporters write for +/// them ([`expand_live_deep`](vectorcraft_render::effects::expand_live_deep), under one budget for +/// everything nested in each), so the shapes they draw are leaves. The objects that art holds count +/// in `made`, and an object whose art would take `made` past `budget` stays live. Returns how many +/// were expanded. +fn expand_live_with_transparent_image_patterns( + d: &mut Document, + root: NodeId, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, + made: &std::cell::Cell, + budget: usize, +) -> Result { + fn outermost( + d: &Document, + n: &Node, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, + out: &mut Vec, + ) { + if !drawn(n) { + return; + } + match &n.kind { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + if holds_transparent_image_patterns(d, n, see_through, colorize, levels) { + out.push(n.id); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + children.iter().for_each(|c| outermost(d, c, see_through, colorize, levels, out)); + } + _ => {} + } + } + /// Fresh ids for everything under `n`: copies of the same art share theirs. + fn fresh_ids(d: &mut Document, n: &mut Node) { + for c in n.children_mut().into_iter().flatten() { + let c = Arc::make_mut(c); + c.id = d.alloc_id(); + fresh_ids(d, c); + } + } + let mut live = vec![]; + if let Some(n) = d.node(root) { + outermost(d, n, see_through, colorize, levels, &mut live); + } + let mut count = 0; + for id in &live { + if made.get() >= budget { + break; + } + let Some(n) = d.node(*id) else { continue }; + // Random Transform effects take their seed from the ids: bake them first ([`bake_seeded`]), + // and leave the object live when that can't be done. + let Some(mut g) = bake_seeded(&vectorcraft_render::effects::expand_live_deep(Some(d), n)) else { continue }; + // An object whose art would take the expanded art past the budget stays live. + let size = g.count_with_masks(); + if size > budget - made.get() { + continue; + } + made.set(made.get() + size); + g.id = *id; + fresh_ids(d, &mut g); + *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = g; + count += 1; + } + Ok(count) +} + +/// Whether `i`, an item of leaf `l`, is a pattern fill whose tile art holds a transparent image +/// ([`transparent_image`], through pattern fills in that art down to `levels` patterns), +/// counting the fill's and the shape's opacity and blending mode. +fn transparent_pattern_fill( + d: &Document, + l: &Node, + i: &AppearanceItem, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, +) -> bool { + let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); + pattern_fill_holds_transparent_image(d, i, see_through, colorize, shape, levels) +} + +/// Whether `i` is a pattern fill whose tile art holds a transparent image ([`transparent_image`]), +/// down to `levels` patterns, counting the fill's opacity and blending mode, or anything when `on`. +fn pattern_fill_holds_transparent_image( + d: &Document, + i: &AppearanceItem, + keys: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + on: bool, + levels: u32, +) -> bool { + if levels == 0 { + return false; + } + let Some((f, def, _)) = pattern_fill(d, i) else { return false }; + let on = on || f.opacity < 1.0 || f.blend != BlendMode::Normal; + def.art.iter().any(|a| transparent_image(d, a, keys, colorize, on, levels - 1)) } /// Whether `n` has opacity or a blending mode of its own. @@ -473,13 +654,21 @@ fn blended(n: &Node) -> bool { n.opacity < 1.0 || n.blend != BlendMode::Normal } -/// Whether `n` is or holds an image with see-through pixels (key in `keys`), a colorize paint, -/// opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. -fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { +/// Whether `n` (art of `d`) is or holds an image with see-through pixels (key in `keys`), a +/// colorize paint that `colorize` counts, opacity or a blending mode of its own or of a group in +/// `n`, or anywhere when `on`, also in the tile art of the pattern fills of the paths in `n`, down +/// to `levels` patterns. Hidden art doesn't count. +fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool, on: bool, levels: u32) -> bool { + if !n.visible { + return false; + } let on = on || blended(n); match &n.kind { - NodeKind::Image(im) => on || keys.contains(&im.key) || n.extras().colorize.is_some(), - _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))), + NodeKind::Image(im) => on || keys.contains(&im.key) || (n.extras().colorize.is_some() && colorize(d, n)), + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + n.appearance.items.iter().any(|i| pattern_fill_holds_transparent_image(d, i, keys, colorize, on, levels)) + } + _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(d, c, keys, colorize, on, levels))), } } @@ -491,12 +680,12 @@ fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { /// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill /// lays a tile. fn expand_patterns(d: &mut Document, l: &Node) -> Option { - expand_patterns_where(d, l, |_, _| true) + expand_patterns_where(d, l, |_, _| true, usize::MAX) } /// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other -/// items. -fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool) -> Option { +/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects. +fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option { if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i)) { @@ -509,19 +698,47 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & let mut parts = vec![]; let mut rest = vec![]; let mut expanded = false; + // What `assemble` adds around the tiles when every picked fill expands: a copy of the path for + // each run of other items, a group around the parts and one for the path's transparency, and + // the path's opacity mask. + let (mut copies, mut run, mut picked) = (0usize, false, vec![]); + for i in &l.appearance.items { + if let Some((f, _, _)) = pattern_fill(d, i).filter(|_| which(d, i)) { + run = false; + picked.push(f.opacity < 1.0 || f.blend != BlendMode::Normal); + } else if i.visible() && !i.paint().is_none() && !run { + (copies, run) = (copies + 1, true); + } + } + let group = copies + picked.len() > 1 || !l.appearance.effects.is_empty(); + // A lone tile group with transparency of its own goes in a group for the path's. + let transparency = blended(l) && !group && picked.first().is_some_and(|b| *b); + let around = copies.saturating_mul(l.count()) + usize::from(group) + usize::from(transparency) + l.count_with_masks().saturating_sub(l.count()); + let mut left = budget.saturating_sub(around); + // A fill that expands while others follow leaves room for the copy and group that those take + // if they don't fit, and once one doesn't fit, the ones after it stay too. + let (mut later, mut full) = (picked.len(), false); for item in &l.appearance.items { let fill = pattern_fill(d, item).filter(|_| which(d, item)).map(|(f, def, xf)| (f, def.clone(), xf)); - let tiles = fill.and_then(|(f, def, xf)| { + later -= usize::from(fill.is_some()); + let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left }; + let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| { let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; - let t = pattern_tiles(d, &def, xf, bounds); + let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else { + full = true; + return None; + }; (!t.is_empty()).then_some((f, fl, t)) }); - let Some(art) = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)) else { + let art = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)); + full |= art.as_ref().is_some_and(|a| a.count_with_masks() > room); + let Some(art) = art.filter(|a| a.count_with_masks() <= room) else { if item.visible() && !item.paint().is_none() { rest.push(item.clone()); } continue; }; + left -= art.count_with_masks(); if !rest.is_empty() { parts.push(styled_copy(d, l, std::mem::take(&mut rest))); } @@ -548,30 +765,38 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & /// not, and when it does more than move art that holds a symbol instance, whose strokes the canvas /// draws scaled with the tile and an instance does not scale. Random Transform effects and brushes /// that vary at random in the art are baked first ([`seeds_baked`], [`random_brushes_baked`]). -fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect) -> Vec { +/// `None` when the tiles would hold more than `budget` objects. +fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect, budget: usize) -> Option> { if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) { - return vec![]; + return Some(vec![]); } let Some(def) = seeds_baked(def) else { - return vec![]; + return Some(vec![]); }; let brushes = vectorcraft_brush::library(d); let def = random_brushes_baked(d, &brushes, def); let def = &*def; + let per_tile = def.art.iter().map(|a| a.count_with_masks()).sum::(); + if def.offsets_covering(xf.inverse().transform_rect_bbox(bounds)).len().saturating_mul(per_tile) > budget { + return None; + } let scaling = Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), patterns: true, ..Scaling::default() }; if scales_evenly(xf) && (moves_only(xf) || !def.art.iter().any(|a| has_geometry_effects(a))) { - return def.instances_in_with(xf, bounds, scaling); + return Some(def.instances_in_with(xf, bounds, scaling)); } if def.art.iter().any(|a| has_unbakeable_effects(a)) { - return vec![]; + return Some(vec![]); } let placed = Scaling { patterns: true, ..Scaling::default() }; let mut tiles = def.instances_in_with(Affine::IDENTITY, xf.inverse().transform_rect_bbox(bounds), placed); + // Outlined strokes and effects can make more objects than the art had. + let mut left = budget; for t in &mut tiles { *t = prepared(d, &brushes, t); + left = left.checked_sub(t.count_with_masks())?; place_art(t, xf, scaling); } - tiles + Some(tiles) } /// Pattern `def` with each path in its art (opacity masks included) that has a random Transform diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index c8cb7218a..fb9419052 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -33,6 +33,7 @@ pub(crate) mod imagemap; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +mod jp2read; mod metafile; mod naming; mod native; @@ -56,6 +57,7 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub use export::export_source; +pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; pub(crate) use load::check_not_lossy_overwrite; use load::err; @@ -74,6 +76,7 @@ pub(crate) use native::preview_png; pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; +pub(crate) use save::save as save_document; pub use save::{ SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, @@ -118,11 +121,12 @@ use access::{may_read, may_write}; pub use commands::specs; pub use export_names::artboard_file_names; pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return}; -use export_names::{distinct_names, write_encoded}; +use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable}; pub use formats::{ - DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, file_stem, - format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, writable_format, + DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, + file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, + writable_format, }; #[cfg(test)] @@ -130,6 +134,8 @@ mod tests; #[cfg(test)] mod tests_affinity; #[cfg(test)] +mod tests_artboard_fill; +#[cfg(test)] mod tests_pdf; #[cfg(test)] mod tests_pdfcolor; @@ -193,6 +199,8 @@ mod tests_psd; #[cfg(test)] mod tests_psdread; +#[cfg(test)] +mod tests_jp2read; #[cfg(test)] mod tests_pcxread; diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index d1db60302..e11b6adcd 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -34,6 +34,7 @@ struct Run<'a> { override_export: bool, destination: &'a str, dest_folder: Option<&'a str>, + dataset_file_naming: &'a str, } /// Play `commands` on each source file or data set. @@ -90,6 +91,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { override_export: bool_or(p, "overrideExport", true), destination, dest_folder: dest_folder.as_deref(), + dataset_file_naming: p.get("datasetFileNaming").map_or("fileDataSetName", |v| v.as_str().unwrap_or("")), }; match source { "folder" => batch_folder(s, C, p, override_open, &run), @@ -135,9 +137,6 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: #[cfg(not(target_arch = "wasm32"))] fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { - use super::distinct_names; - use super::screens::file_part; - let st = s.doc()?; let names: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); if names.is_empty() { @@ -147,11 +146,7 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); } let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); - // Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder - // destination uses the same name). `file_part` writes the characters Windows does not allow in a - // file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) in the data set's - // name as `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used. - let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name)))); + let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?; let steps = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; let mut processed = Vec::new(); @@ -227,7 +222,7 @@ fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, sou "folder" => { let folder = run.dest_folder.ok_or_else(|| bad("action.batch", "missing destination folder"))?; let out_ext = if ext.is_empty() { "vectorcraft" } else { ext }; - let out = std::path::Path::new(folder).join(format!("{stem}.{out_ext}")); + let out = output_path(folder, stem, out_ext).ok_or_else(|| bad("action.batch", "unsafe destination file name"))?; let out = out.to_string_lossy().into_owned(); // When `close` is false (data sets), write a copy so the template keeps its path. write_result(s, &out, out_ext, !close)?; @@ -240,6 +235,174 @@ fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, sou } } +/// Builds the three data set naming forms, replaces unsafe name characters, and adds `-2`, `-3` +/// suffixes when a stem was used earlier or its output already exists in a folder the batch may +/// read and write. +#[cfg(not(target_arch = "wasm32"))] +fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Result> { + use super::screens::{file_part, safe_file_name}; + + if !matches!(naming, "fileNumber" | "fileDataSetName" | "dataSetName") { + return Err(bad("action.batch", "`datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName`")); + } + let doc_stem = safe_file_name(doc_stem); + let doc_stem = if doc_stem.is_empty() || matches!(doc_stem.as_str(), "." | "..") { "Untitled" } else { &doc_stem }; + let bases = names.iter().enumerate().map(|(i, name)| { + let safe = file_part(name); + let safe_data_set = safe_file_name(&safe); + let ordinal = format!("{doc_stem}{:03}", i + 1); + match naming { + "fileNumber" => ordinal, + "dataSetName" if !safe_data_set.is_empty() => safe_data_set, + "dataSetName" => ordinal, + _ => format!("{doc_stem}_{safe}"), + } + }); + let outputs = dataset_output_extensions(run, doc); + let mut taken = std::collections::HashSet::new(); + let mut reserved = std::collections::HashSet::new(); + let mut stems = Vec::with_capacity(bases.len()); + for base in bases { + let mut candidate = base.clone(); + let mut suffix = 2; + while taken.contains(&candidate.to_lowercase()) + || dataset_output_paths(run.dest_folder, &candidate, &outputs, doc) + .iter() + .any(|path| reserved.contains(&path.to_string_lossy().to_lowercase())) + || dataset_output_exists(run.dest_folder, &candidate, &outputs, doc) + { + if suffix > 10_000 { + return Err(bad("action.batch", "no free data set file name in destination folder")); + } + candidate = format!("{base}-{suffix}"); + suffix += 1; + } + taken.insert(candidate.to_lowercase()); + reserved + .extend(dataset_output_paths(run.dest_folder, &candidate, &outputs, doc).into_iter().map(|path| path.to_string_lossy().to_lowercase())); + stems.push(candidate); + } + Ok(stems) +} + +#[cfg(not(target_arch = "wasm32"))] +struct DatasetOutput { + ext: String, + artboards: bool, + joiner: &'static str, +} + +/// Lists step output extensions and reserves artboard file names for saves with any artboard or +/// exports with multiple artboards. Per-artboard names count for vectorcraft, template, AI and AIT +/// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports. +#[cfg(not(target_arch = "wasm32"))] +fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Vec { + let mut outputs = Vec::new(); + if run.destination == "folder" { + outputs.push(DatasetOutput { ext: "vectorcraft".into(), artboards: false, joiner: "-" }); + } + for step in run.steps { + let id = step.get("command").and_then(Value::as_str).unwrap_or(""); + let writes = (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) + || (run.override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection")); + if writes { + let format = step.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()); + let ext = match format { + Some(format) if super::format(format).is_some() => format_extension(format), + Some(_) => continue, + None => "vectorcraft".to_string(), + }; + if safe_extension(&ext) { + let format_id = format.and_then(super::format).map(|f| f.id).unwrap_or("vectorcraft"); + let is_save = matches!(id, "document.save" | "file.save" | "file.saveAs"); + let artboards = if is_save { + !doc.artboards.is_empty() && matches!(format_id, "vectorcraft" | "template" | "ai" | "ait") + } else { + doc.artboards.len() > 1 + && id == "document.export" + && matches!( + format_id, + "svg" + | "svgz" + | "eps" + | "png" + | "jpg" + | "webp" + | "gif" + | "png8" + | "tiff" + | "bmp" + | "tga" + | "psd" + | "dxf" + | "emf" + | "wmf" + | "ai" + ) + }; + outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" } }); + } + } + } + outputs +} + +#[cfg(not(target_arch = "wasm32"))] +fn dataset_output_exists(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> bool { + let Some(folder) = folder else { return false }; + if super::may_read(folder).is_err() || super::may_write(folder).is_err() { + return false; + } + dataset_output_paths(Some(folder), stem, outputs, doc).iter().any(|path| { + let path_text = path.to_string_lossy(); + super::may_read(&path_text).is_ok() && super::may_write(&path_text).is_ok() && path.exists() + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn dataset_output_paths(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec { + let Some(folder) = folder else { return Vec::new() }; + let boards: Vec<_> = (0..doc.artboards.len()).collect(); + let mut parts = super::artboard_file_names(doc, &boards); + for board in boards { + parts.extend(super::artboard_file_names(doc, &[board])); + } + let mut paths = Vec::new(); + for output in outputs { + if !safe_extension(&output.ext) || super::format(&output.ext).is_none() { + continue; + } + paths.extend(output_path(folder, stem, &output.ext)); + if output.artboards { + for part in &parts { + paths.extend(output_path(folder, &format!("{stem}{}{part}", output.joiner), &output.ext)); + } + } + } + paths +} + +/// Returns whether an extension is safe to use in an output file name. +#[cfg(not(target_arch = "wasm32"))] +fn safe_extension(ext: &str) -> bool { + !ext.is_empty() && !ext.contains(['/', '\\', ':']) && !ext.contains("..") +} + +/// Returns a path inside the destination folder for a safe stem and extension. +#[cfg(not(target_arch = "wasm32"))] +fn output_path(folder: &str, stem: &str, ext: &str) -> Option { + use std::path::Path; + if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(std::path::is_separator) || !safe_extension(ext) { + return None; + } + let name = format!("{stem}.{ext}"); + if name.contains(std::path::is_separator) { + return None; + } + let path = Path::new(folder).join(name); + (path.parent() == Some(Path::new(folder))).then_some(path) +} + /// Save when the extension is a Save format; otherwise export (PNG, JPEG…). /// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches). #[cfg(not(target_arch = "wasm32"))] @@ -285,7 +448,10 @@ fn rewrite_paths( Some(f) => format_extension(f), None => default_ext.to_string(), }; - let file = std::path::Path::new(folder).join(format!("{stem}.{ext}")); + if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) { + return c; + } + let Some(file) = output_path(folder, stem, &ext) else { return c }; params.insert("path".into(), json!(file.to_string_lossy())); if let Some(o) = c.as_object_mut() { o.insert("params".into(), Value::Object(params)); @@ -411,6 +577,74 @@ mod tests { assert!(dest.join("b.svg").is_file(), "b.svg written"); } + #[cfg(unix)] + #[test] + fn batch_folder_keeps_backslash_in_source_file_name() { + let src = tmp("backslash-src"); + let dest = tmp("backslash-dest"); + write_svg(&src, "a\\b.svg", "#f00"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "folder", + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "stopOnError": true, + "commands": [{"command": "object.unlockAll", "params": {}}] + }), + ) + .unwrap(); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["stopped"], false, "{r}"); + assert!(dest.join("a\\b.svg").is_file(), "{r}"); + } + + #[cfg(unix)] + #[test] + fn batch_folder_override_export_keeps_backslash_in_source_file_name() { + let src = tmp("backslash-export-src"); + let dest = tmp("backslash-export-dest"); + write_svg(&src, "a\\b.svg", "#f00"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "folder", + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "stopOnError": true, + "commands": [{"command": "document.export", "params": {"format": "svg"}}] + }), + ) + .unwrap(); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["stopped"], false, "{r}"); + assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}"); + } + + #[test] + fn output_path_accepts_only_platform_file_name_separators() { + let folder = tmp("output-path"); + let folder = folder.to_string_lossy(); + let backslash = output_path(&folder, "a\\b", "svg"); + #[cfg(windows)] + assert!(backslash.is_none()); + #[cfg(not(windows))] + assert_eq!(backslash, Some(std::path::Path::new(folder.as_ref()).join("a\\b.svg"))); + for stem in ["a/b", ".", "..", ""] { + assert!(output_path(&folder, stem, "svg").is_none(), "{stem:?}"); + } + #[cfg(windows)] + assert_eq!(std::path::Path::new("a\\b.svg").file_stem(), Some(std::ffi::OsStr::new("b"))); + } + #[test] fn batch_skips_open_steps_when_override_open() { let src = tmp("open"); @@ -548,6 +782,412 @@ mod tests { s } + fn named_batch(s: &mut Session, dest: &std::path::Path, naming: Option<&str>) -> Value { + let mut p = json!({ + "source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}] + }); + if let Some(naming) = naming { + p["datasetFileNaming"] = json!(naming); + } + s.execute("action.batch", &p).unwrap() + } + + #[test] + fn batch_data_set_naming_choices_and_omitted_default() { + for (i, (naming, want)) in [ + (Some("fileNumber"), ["source001.vectorcraft", "source002.vectorcraft"]), + (Some("fileDataSetName"), ["source_Alpha.vectorcraft", "source_Beta.vectorcraft"]), + (Some("dataSetName"), ["Alpha.vectorcraft", "Beta.vectorcraft"]), + (None, ["source_Alpha.vectorcraft", "source_Beta.vectorcraft"]), + ] + .into_iter() + .enumerate() + { + let dest = tmp(&format!("choice-{i}")); + let mut s = with_data_sets(&["Alpha", "Beta"]); + let saved = tmp(&format!("saved-{i}")).join("source.vectorcraft"); + s.execute("document.save", &json!({"path": saved.to_string_lossy()})).unwrap(); + let r = named_batch(&mut s, &dest, naming); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), want, "{naming:?}: {r}"); + } + } + + #[test] + fn batch_file_number_uses_three_digits_through_500() { + let names: Vec = (1..=500).map(|n| format!("Set {n}")).collect(); + let run = Run { + label: "Test", + steps: &[], + stop_on_error: true, + override_save: true, + override_export: true, + destination: "none", + dest_folder: None, + dataset_file_naming: "fileDataSetName", + }; + let stems = dataset_stems(&names, "source", "fileNumber", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap(); + for n in [1, 2, 10, 100, 500] { + assert_eq!(stems[n - 1], format!("source{n:03}")); + } + } + + #[test] + fn batch_unsaved_data_set_names_and_empty_fallback() { + let dest = tmp("unsaved-number"); + let mut s = with_data_sets(&["Alpha"]); + assert_eq!(named_batch(&mut s, &dest, Some("fileNumber"))["failed"], 0); + assert_eq!(file_names(&dest), ["Untitled001.vectorcraft"]); + let dest = tmp("unsaved-name"); + assert_eq!(named_batch(&mut s, &dest, Some("fileDataSetName"))["failed"], 0); + assert_eq!(file_names(&dest), ["Untitled_Alpha.vectorcraft"]); + let run = Run { + label: "Test", + steps: &[], + stop_on_error: true, + override_save: true, + override_export: true, + destination: "none", + dest_folder: None, + dataset_file_naming: "fileDataSetName", + }; + assert_eq!(dataset_stems(&[String::new()], "Untitled", "dataSetName", &run, &s.doc().unwrap().doc).unwrap(), ["Untitled001"]); + assert_eq!(dataset_stems(&[" ".to_string()], "Untitled", "dataSetName", &run, &s.doc().unwrap().doc).unwrap(), ["Untitled001"]); + } + + #[test] + fn batch_data_set_name_collisions_and_existing_files() { + let clear = tmp("choice-collision-clear"); + let mut s = with_data_sets(&["a:b", "a-b"]); + let r = named_batch(&mut s, &clear, Some("dataSetName")); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&clear), ["a-b-2.vectorcraft", "a-b.vectorcraft"]); + let dest = tmp("choice-collision"); + std::fs::write(dest.join("a-b.vectorcraft"), b"keep").unwrap(); + let mut s = with_data_sets(&["a:b", "a-b"]); + let r = named_batch(&mut s, &dest, Some("dataSetName")); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(std::fs::read(dest.join("a-b.vectorcraft")).unwrap(), b"keep"); + assert_eq!(file_names(&dest), ["a-b-2.vectorcraft", "a-b-3.vectorcraft", "a-b.vectorcraft"]); + } + + #[test] + fn batch_data_set_name_avoids_windows_device_files() { + let dest = tmp("choice-device"); + let mut s = with_data_sets(&["CON"]); + let r = named_batch(&mut s, &dest, Some("dataSetName")); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["CON-.vectorcraft"]); + } + + #[test] + fn batch_override_export_keeps_existing_output() { + let dest = tmp("choice-existing-export"); + std::fs::write(dest.join("Alpha.svg"), b"keep").unwrap(); + let mut s = with_data_sets(&["Alpha"]); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.export", "params": {"format": "svg"}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(std::fs::read(dest.join("Alpha.svg")).unwrap(), b"keep"); + assert_eq!(file_names(&dest), ["Alpha-2.svg", "Alpha.svg"]); + } + + #[test] + fn batch_artboard_export_keeps_existing_output() { + let dest = tmp("choice-existing-artboard-export"); + let existing = dest.join("Alpha-Artboard-1.png"); + std::fs::write(&existing, b"keep").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap(); + s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.export", "params": {"format": "png", "useArtboards": true}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(std::fs::read(existing).unwrap(), b"keep"); + assert_eq!(file_names(&dest), ["Alpha-2-Artboard-1.png", "Alpha-2-Artboard-2.png", "Alpha-Artboard-1.png"]); + } + + #[test] + fn batch_remembered_separate_artboards_keeps_existing_output() { + let source = tmp("choice-remembered-separate-source"); + let dest = tmp("choice-remembered-separate-dest"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap(); + s.execute("document.save", &json!({"path": source.join("source.vectorcraft").to_string_lossy(), "separateArtboards": true})).unwrap(); + assert!(source.join("source-Artboard-1.vectorcraft").exists()); + let existing = dest.join("Alpha-Artboard-1.vectorcraft"); + std::fs::write(&existing, b"keep").unwrap(); + s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.save"}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(std::fs::read(existing).unwrap(), b"keep"); + assert_eq!( + file_names(&dest), + ["Alpha-2-Artboard-1.vectorcraft", "Alpha-2-Artboard-2.vectorcraft", "Alpha-2.vectorcraft", "Alpha-Artboard-1.vectorcraft",] + ); + } + + #[test] + fn batch_single_artboard_save_keeps_existing_artboard_file() { + let source = tmp("choice-single-artboard-source"); + let dest = tmp("choice-single-artboard-dest"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 1})).unwrap(); + s.execute( + "document.save", + &json!({ + "path": source.join("source.vectorcraft").to_string_lossy(), + "separateArtboards": true + }), + ) + .unwrap(); + assert!(source.join("source-Artboard-1.vectorcraft").exists()); + + let existing = dest.join("Alpha-Artboard-1.vectorcraft"); + std::fs::write(&existing, b"keep").unwrap(); + s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.save", "params": {"separateArtboards": true}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(std::fs::read(existing).unwrap(), b"keep"); + assert_eq!(file_names(&dest), ["Alpha-2-Artboard-1.vectorcraft", "Alpha-2.vectorcraft", "Alpha-Artboard-1.vectorcraft"]); + } + + #[test] + fn batch_pdf_export_ignores_artboard_named_file() { + let dest = tmp("choice-pdf-artboard-name"); + let existing = dest.join("Alpha-Artboard-1.pdf"); + std::fs::write(&existing, b"keep").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap(); + s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.export", "params": {"format": "pdf"}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(std::fs::read(existing).unwrap(), b"keep"); + assert_eq!(file_names(&dest), ["Alpha-Artboard-1.pdf", "Alpha.pdf"]); + } + + #[test] + fn batch_separate_artboards_ignores_unrelated_extension() { + let dest = tmp("choice-separate-unrelated-extension"); + let unrelated = dest.join("Alpha-photo.png"); + std::fs::write(&unrelated, b"keep").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap(); + s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.save", "params": {"separateArtboards": true}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(std::fs::read(unrelated).unwrap(), b"keep"); + assert_eq!(file_names(&dest), ["Alpha-Artboard-1.vectorcraft", "Alpha-Artboard-2.vectorcraft", "Alpha-photo.png", "Alpha.vectorcraft"]); + } + + #[test] + fn batch_separate_artboards_reserves_prior_output_names() { + let dest = tmp("choice-separate-reserved"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap(); + s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Alpha-Artboard-1"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.save", "params": {"separateArtboards": true}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(dest.join("Alpha-Artboard-1.vectorcraft").exists()); + assert!(dest.join("Alpha-Artboard-1-2.vectorcraft").exists()); + } + + #[test] + fn batch_does_not_probe_a_denied_destination() { + let allowed = tmp("probe-allowed"); + let denied = tmp("probe-denied"); + std::fs::write(denied.join("Alpha.svg"), b"private").unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[allowed.as_path()], &[allowed.as_path()]).unwrap().unwrap(); + let steps = [json!({"command": "document.export", "params": {"format": "svg"}})]; + let folder = denied.to_string_lossy().into_owned(); + let run = Run { + label: "Test", + steps: &steps, + stop_on_error: true, + override_save: true, + override_export: true, + destination: "none", + dest_folder: Some(&folder), + dataset_file_naming: "fileDataSetName", + }; + crate::file_access::confine(Some(&roots), || { + assert_eq!( + dataset_stems(&["Alpha".into()], "source", "dataSetName", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap(), + ["Alpha"] + ); + let mut s = with_data_sets(&["Alpha"]); + let result = s + .execute( + "action.batch", + &json!({ + "source": "datasets", "datasetFileNaming": "dataSetName", "destination": "none", + "destinationFolder": folder, "overrideExport": true, + "commands": steps, + }), + ) + .unwrap(); + assert!(!result.to_string().contains("Alpha-2"), "{result}"); + }); + } + + #[test] + fn batch_does_not_probe_unknown_format_paths() { + let root = tmp("probe-format"); + let folder = root.join("out"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(root.join("outside"), b"private").unwrap(); + for format in ["../outside", "x/../../outside", "..\\..\\outside"] { + let steps = [json!({"command": "document.export", "params": {"format": format}})]; + let folder = folder.to_string_lossy().into_owned(); + let run = Run { + label: "Test", + steps: &steps, + stop_on_error: true, + override_save: true, + override_export: true, + destination: "none", + dest_folder: Some(&folder), + dataset_file_naming: "fileDataSetName", + }; + assert_eq!( + dataset_stems(&["Alpha".into()], "source", "dataSetName", &run, &vectorcraft_doc::Document::new(100.0, 80.0)) + .unwrap_or_else(|e| panic!("{format}: {e}")), + ["Alpha"], + "{format}" + ); + assert_eq!(rewrite_paths(&steps, false, true, Some(&folder), "Alpha", "vectorcraft"), steps, "{format}"); + } + } + + #[test] + fn batch_probe_and_dot_names_stay_in_destination() { + let root = tmp("probe-parent"); + let folder = root.join("out"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(root.join("outside.svg"), b"private").unwrap(); + let folder = folder.to_string_lossy().into_owned(); + let run = Run { + label: "Test", + steps: &[], + stop_on_error: true, + override_save: true, + override_export: true, + destination: "none", + dest_folder: Some(&folder), + dataset_file_naming: "fileDataSetName", + }; + assert_eq!( + dataset_stems(&[".".into(), "..".into()], ".", "dataSetName", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap(), + ["Untitled001", "Untitled002"] + ); + assert_eq!( + dataset_stems(&["Alpha".into()], "..", "fileNumber", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap(), + ["Untitled001"] + ); + let output = root.join("named"); + let mut s = with_data_sets(&[".", ".."]); + let r = named_batch(&mut s, &output, Some("dataSetName")); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&output), ["Untitled001.vectorcraft", "Untitled002.vectorcraft"]); + } + + #[test] + fn batch_probe_rejects_parent_escape() { + let root = tmp("probe-parent-only"); + let folder = root.join("out"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(root.join("outside.svg"), b"private").unwrap(); + let folder = folder.to_string_lossy().into_owned(); + assert!(!dataset_output_exists( + Some(&folder), + "../outside", + &[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-" }], + &vectorcraft_doc::Document::new(100.0, 80.0), + )); + } + + #[test] + fn batch_rejects_unknown_data_set_naming_but_ignores_it_for_folders() { + let mut s = with_data_sets(&["Alpha"]); + let e = s + .execute("action.batch", &json!({"source": "datasets", "datasetFileNaming": "other", "commands": [{"command": "object.unlockAll"}]})) + .unwrap_err(); + let msg = e.to_string(); + for value in ["fileNumber", "fileDataSetName", "dataSetName"] { + assert!(msg.contains(value), "{msg}"); + } + assert!( + s.execute("action.batch", &json!({"source": "datasets", "datasetFileNaming": 7, "commands": [{"command": "object.unlockAll"}]})).is_err() + ); + let src = tmp("folder-ignore-src"); + write_svg(&src, "one.svg", "#f00"); + let r = s.execute("action.batch", &json!({"source": "folder", "folder": src.to_string_lossy(), "datasetFileNaming": "other", "commands": [{"command": "object.unlockAll"}]})).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + } + #[test] fn batch_keeps_data_set_files_inside_the_destination_folder() { let root = tmp("set-names-root"); diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs index 0375ff39e..2e9334227 100644 --- a/crates/engine/src/cmd/fileio/affinity/nodes.rs +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -186,14 +186,19 @@ impl Builder { return Some(Arc::new(g)); }; self.outline_text(&n.effects, &mut text); - let mut node = self.base(n, NodeKind::Text(Box::new(text))); - node.opacity *= alpha; - if !children.is_empty() { - let mut kids = vec![Arc::new(node)]; + if children.is_empty() { + let mut node = self.base(n, NodeKind::Text(Box::new(text))); + node.opacity *= alpha; + node + } else { + // Text clips its children as a shape does: the type is the clipping path, its + // characters' fill behind the clipped art and their stroke over it. + let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(text))); + clip.opacity = alpha; + let mut kids = vec![Arc::new(clip)]; kids.extend(children); - node = Node::group(self.doc.alloc_id(), kids); + self.base(n, NodeKind::Group { children: kids, clip: true }) } - node } Kind::Table(t) => { let mut kids = self.table(t, to_doc); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 0c38079df..f80302eee 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,