diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index f58f270b4..37cebf6c3 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -36,47 +36,4 @@ jobs: with: require-craft-fonts: true craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 - - package: - name: FreeBSD 14 (package) - # The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies) - # and on demand, not on every push to main. - if: github.event_name != 'push' - runs-on: ubuntu-latest - timeout-minutes: 150 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM). - # Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 - path: craft-fonts - - name: Package in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - mem: 12288 - prepare: | - pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM. - # The craft-fonts checkout is synced into the VM with the workspace. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4 - bash packaging/freebsd/package.sh - # Every part the package must have, the craft-fonts licences included. - tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt - for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do - grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; } - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: freebsd-package - path: dist/release/* - if-no-files-found: error + run-package-check: true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # 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/nix.yml@main diff --git a/Cargo.lock b/Cargo.lock index 1f48a387d..eca47581b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4507,7 +4507,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arboard", "block2 0.6.2", @@ -4542,7 +4542,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.11.0" +version = "0.12.0" dependencies = [ "crc32fast", "flate2", @@ -4553,7 +4553,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.11.0" +version = "0.12.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -4565,7 +4565,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4581,7 +4581,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "objc2-core-foundation", @@ -4603,7 +4603,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.11.0" +version = "0.12.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "jpeg-encoder", @@ -4632,7 +4632,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4648,7 +4648,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "crc32fast", "flate2", @@ -4684,7 +4684,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "image", @@ -4705,7 +4705,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "proptest", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.11.0" +version = "0.12.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -4732,7 +4732,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "log", @@ -4751,7 +4751,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4765,7 +4765,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.11.0" +version = "0.12.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -4777,7 +4777,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "craft-pdf", @@ -4807,7 +4807,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", "serde_json", @@ -4821,7 +4821,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "gif", @@ -4848,14 +4848,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.11.0" +version = "0.12.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "image", @@ -4879,7 +4879,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4896,7 +4896,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.11.0" +version = "0.12.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -4916,7 +4916,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.11.0" +version = "0.12.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -4924,7 +4924,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4940,7 +4940,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4952,7 +4952,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-ui", "egui", @@ -4986,7 +4986,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -6096,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index b001535c2..1c10d551b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index 8ce335a05..36fe11fe9 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ The scores are self-assessed from our code against Illustrator's public document - reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); -- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery; +- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery; - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); - generative AI features. diff --git a/ROADMAP.md b/ROADMAP.md index 90a78757c..522d5ffc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| -| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | | **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | @@ -80,7 +80,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so | 3D and Materials | ~25% | 30–55 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | | Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | -| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 | +| Symbols, blends, envelopes, Repeat, perspective | 87% | 5–9 | | Image Trace, graphs, image tools | 75% | 5–8 | | Layers, artboards, document setup | 78% | 5–8 | | View & navigation | 70% | 9–14 | @@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5). | 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). | | 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. | | 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. | diff --git a/apps/vectorcraft/src/profile.rs b/apps/vectorcraft/src/profile.rs index c73596a01..4030d0aea 100644 --- a/apps/vectorcraft/src/profile.rs +++ b/apps/vectorcraft/src/profile.rs @@ -1,29 +1,184 @@ //! Native profile paths. Isolated profiles never migrate preferences from a user's old profile. +//! +//! In order: +//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups). +//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the +//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves +//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding +//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding +//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows +//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is +//! created, the settings in the user's profile are copied into it, so a portable zip that gains +//! the marker keeps its users' settings. If the folder can't be written (read-only medium, +//! Program Files), a warning is logged and the platform folder is used. A portable copy never +//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never +//! looks for the marker. +//! 3. The platform convention. use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Files in the install folder that switch on portable mode (either one; contents are ignored). +pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"]; +/// The settings folder created in the install folder in portable mode. +pub const PORTABLE_DATA_DIR: &str = "VectorCraftData"; pub fn prefs_path(legacy: bool) -> Option { - prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key)) + prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir) } -fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option) -> Option { +/// `portable` is the portable settings folder, if any: only asked for when no override is set and +/// preferences are in use. +fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option, portable: impl FnOnce() -> Option) -> Option { if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") { // Never fall back to DrawCraft's real profile when the isolated file does not exist. return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json")); } + // A run without preferences reads and writes nothing, so it must not create the portable folder. + if env("VECTORCRAFT_NO_PREFS").is_none() + && let Some(dir) = portable() + { + // A portable copy is self-contained: DrawCraft's old profile is not read into it. + return (!legacy).then(|| dir.join("ui.json")); + } + platform_dir(os, legacy, &env).map(|p| p.join("ui.json")) +} + +/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`). +fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option) -> Option { let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") }; - let base = match os { + match os { "macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)), "windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)), _ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)), + } +} + +/// The portable settings folder of this copy, looked for once per process (it probes the disk, and +/// the settings path is asked for many times). +fn portable_dir() -> Option { + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| { + let os = std::env::consts::OS; + let env = |key: &str| std::env::var_os(key); + let install = install_dir(os, &std::env::current_exe().ok()?, &env)?; + portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| { + // Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger + // keeps early records for the log file. + log::warn!("{warning}"); + None + }) + }) + .clone() +} + +/// The folder the user sees the app in, where the portable marker is looked for: the executable's; +/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a +/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when +/// `$APPIMAGE` names it (the executable itself runs from a read-only mount). +fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option) -> Option { + let exe_dir = exe.parent()?; + let dir = match os { + "macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(), + "windows" => exe_dir.to_path_buf(), + _ => { + let appimage = env("APPIMAGE").map(PathBuf::from); + let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty()); + beside.unwrap_or(exe_dir).to_path_buf() + } }; - base.map(|p| p.join("ui.json")) + Some(dir) +} + +/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`. +fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> { + let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?; + let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?; + bundle.extension().filter(|ext| *ext == "app").and(bundle.parent()) +} + +/// `/VectorCraftData` when a portable marker sits in `install_dir`, created if missing; +/// `None` without a marker, and the warning to log when there is one but that folder can't be +/// written. The first time the folder is created, the settings in `previous` (the user's profile +/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its +/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it +/// is, and a later start finds the folder and copies nothing. +fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result, String> { + let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) }; + let data = install_dir.join(PORTABLE_DATA_DIR); + let first_start = !data.exists(); + if let Err(e) = ensure_writable(&data) { + return Err(format!( + "portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead", + marker.display(), + data.display() + )); + } + if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) { + match copy_dir(previous, &data) { + Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()), + Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()), + } + } + Ok(Some(data)) +} + +/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only). +fn ensure_writable(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let probe = dir.join(format!(".write-test-{}", std::process::id())); + std::fs::write(&probe, b"")?; + // A leftover probe is harmless; don't give up portable mode over it. + let _ = std::fs::remove_file(&probe); + Ok(()) +} + +/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be +/// copied; the error says how many were skipped and why the first one was. +fn copy_dir(from: &Path, to: &Path) -> Result<(), String> { + let mut skipped = 0usize; + let mut first: Option = None; + copy_dir_into(from, to, &mut |why| { + skipped += 1; + first.get_or_insert(why); + }); + match first { + None => Ok(()), + Some(why) => Err(format!("{skipped} skipped, the first because {why}")), + } +} + +fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) { + let entries = match std::fs::read_dir(from) { + Ok(entries) => entries, + Err(e) => return skip(format!("{}: {e}", from.display())), + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + skip(format!("{}: {e}", from.display())); + continue; + } + }; + let (src, dst) = (entry.path(), to.join(entry.file_name())); + // A symbolic link is copied as what it points to, or skipped when that is a folder. + let copied = match entry.file_type() { + Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)), + Ok(_) => std::fs::copy(&src, &dst).map(|_| ()), + Err(e) => Err(e), + }; + if let Err(e) = copied { + skip(format!("{}: {e}", src.display())); + } + } } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; fn environment(key: &str) -> Option { match key { @@ -34,33 +189,202 @@ mod tests { } } + /// No portable marker in the install folder. + fn installed() -> Option { + None + } + #[test] fn isolated_profile_has_no_legacy_fallback_on_any_platform() { for os in ["macos", "windows", "linux"] { let env = |key: &str| { if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) } }; - assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into())); - assert_eq!(prefs_path_with(os, true, env), None); + assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into())); + assert_eq!(prefs_path_with(os, true, env, installed), None); } } #[test] fn empty_override_does_not_fall_back_to_user_files() { let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) }; - assert_eq!(prefs_path_with("macos", false, env), None); - assert_eq!(prefs_path_with("macos", true, env), None); + assert_eq!(prefs_path_with("macos", false, env, installed), None); + assert_eq!(prefs_path_with("macos", true, env, installed), None); } #[test] fn unset_override_keeps_platform_paths_and_legacy_migration() { - assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into())); - assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into())); - assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into())); - assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into())); + assert_eq!(prefs_path_with("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into())); + assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into())); assert_eq!( - prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }), + prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed), Some("/user/.config/drawcraft/ui.json".into()) ); } + + #[test] + fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() { + let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData")); + for os in ["macos", "windows", "linux"] { + assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}"); + // A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped. + assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}"); + } + } + + #[test] + fn the_config_dir_override_wins_over_portable_mode_without_probing_it() { + let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }; + let portable = || -> Option { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") }; + assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into())); + assert_eq!(prefs_path_with("windows", true, env, portable), None); + } + + #[test] + fn a_run_without_preferences_never_looks_for_the_portable_folder() { + let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) }; + let portable = || -> Option { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") }; + assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into())); + } + + #[test] + fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() { + let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft"); + assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into())); + // A bare executable (the CLI zip) and other platforms: the executable's own folder. + assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into())); + assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into())); + assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into())); + } + + #[test] + fn an_appimage_looks_for_the_marker_beside_the_appimage_file() { + let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft"); + let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) }; + assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into())); + assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into())); + let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) }; + assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into())); + // The variable means nothing on other platforms. + assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into())); + assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into())); + } + + fn temp(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn either_marker_beside_the_executable_makes_a_writable_data_folder() { + for marker in PORTABLE_MARKERS { + let exe = temp("portable"); + std::fs::write(exe.join(marker), b"").unwrap(); + let dir = portable_dir_in(&exe, None).unwrap().unwrap(); + assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}"); + assert!(dir.is_dir(), "the data folder is created"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed"); + let _ = std::fs::remove_dir_all(&exe); + } + } + + #[test] + fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() { + let folder = temp("bundle"); + let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft"); + std::fs::create_dir_all(exe.parent().unwrap()).unwrap(); + std::fs::write(folder.join("portable.txt"), b"").unwrap(); + let install = install_dir("macos", &exe, &environment).unwrap(); + assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR)))); + assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle"); + // A marker dropped inside the bundle is not looked at. + std::fs::remove_file(folder.join("portable.txt")).unwrap(); + std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap(); + let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR)); + assert_eq!(portable_dir_in(&install, None), Ok(None)); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn without_a_marker_file_there_is_no_portable_folder() { + let exe = temp("installed"); + assert_eq!(portable_dir_in(&exe, None), Ok(None)); + // A folder named like the marker is not a marker. + std::fs::create_dir_all(exe.join("portable.txt")).unwrap(); + assert_eq!(portable_dir_in(&exe, None), Ok(None)); + assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker"); + let _ = std::fs::remove_dir_all(&exe); + } + + #[test] + fn an_unwritable_portable_folder_is_reported_instead_of_used() { + let exe = temp("unwritable"); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + // A file where the data folder should go: it can't be created, on every platform. + std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap(); + let warning = portable_dir_in(&exe, None).unwrap_err(); + assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}"); + let _ = std::fs::remove_dir_all(&exe); + } + + #[test] + fn the_first_start_copies_the_user_profile_into_the_data_folder_once() { + let exe = temp("upgrade"); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + let previous = temp("previous-profile"); + std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap(); + std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap(); + std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap(); + + let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap(); + assert_eq!(data, exe.join(PORTABLE_DATA_DIR)); + assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied"); + assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied"); + assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is"); + assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is"); + + // A second start finds the folder and copies nothing, whatever changed on either side. + std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap(); + std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap(); + std::fs::write(previous.join("later.txt"), b"").unwrap(); + assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone()))); + assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten"); + assert!(!data.join("later.txt").exists(), "nothing is copied after the first start"); + + let _ = std::fs::remove_dir_all(&exe); + let _ = std::fs::remove_dir_all(&previous); + } + + #[test] + fn without_a_user_profile_the_data_folder_starts_empty() { + let exe = temp("fresh"); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + let missing = exe.join("no-such-profile"); + let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap(); + assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&exe); + } + + #[test] + fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() { + let from = temp("copy-from"); + let to = temp("copy-to"); + std::fs::write(from.join("kept.txt"), b"kept").unwrap(); + std::fs::create_dir_all(from.join("blocked")).unwrap(); + std::fs::write(from.join("blocked/inner.txt"), b"").unwrap(); + // A file where the sub-folder should go: that entry can't be copied, the others can. + std::fs::write(to.join("blocked"), b"in the way").unwrap(); + let error = copy_dir(&from, &to).unwrap_err(); + assert!(error.starts_with("1 skipped"), "{error}"); + assert!(error.contains("blocked"), "{error}"); + assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept"); + let _ = std::fs::remove_dir_all(&from); + let _ = std::fs::remove_dir_all(&to); + } } diff --git a/contributors/people.toml b/contributors/people.toml index e70db4a25..b30eb0fd2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -115,3 +115,8 @@ sync_github_name = false real_name = "Kamil Kwiatkowski" display_name = "Kamil Kwiatkowski" sync_github_name = false + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 94567dfb9..895b70226 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -965,6 +965,41 @@ fn drawings_the_export_writes_read_back() { } } +#[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. + let cases = [(true, true), (true, false), (false, true), (false, false)]; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for (i, &(closed, returns)) in cases.iter().enumerate() { + let mut b = kurbo::BezPath::new(); + b.move_to((20.0, 20.0)); + b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0)); + if returns { + b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0)); + } + if closed { + b.close_path(); + } + let id = doc.alloc_id(); + let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0); + doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap(); + } + for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) { + let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() }; + let out = crate::export(&doc, &opts).unwrap(); + let text = std::str::from_utf8(&out.bytes).unwrap(); + assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities"); + let back = leaves(&open(&out.bytes, &points()).document); + assert_eq!(back.len(), cases.len(), "{version:?}"); + for (n, &(closed, returns)) in back.iter().zip(&cases) { + let path = n.path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1, "{version:?}"); + assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}"); + } + } +} + #[test] fn info_lists_layers_layouts_and_version() { let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")]; diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index a562fa3ff..bf84b450a 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option< tops } +/// The most objects [`locked_objects_at`] lists. +pub const MAX_LOCKED_AT: usize = 20; + +/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own +/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed, +/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left +/// out. While a pattern is edited only its tile layer is searched, and in isolation mode +/// (`opt.scope`) only what is inside the isolated group or object. +pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec { + let symbols = doc.symbol_boxes(); + let mut out = Vec::new(); + if let Some(scope) = opt.scope + && let Some(isolated) = doc.node(scope) + { + if isolated.is_container() { + locked_children(isolated, p, opt, &symbols, &mut out); + } else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) { + out.push(isolated.id); + } + return out; + } + for layer in doc.layers.iter().rev() { + if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) { + continue; + } + let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; + locked_children(layer, p, opt, &symbols, &mut out); + } + out +} + +/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested +/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside. +fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec) { + let Some(children) = parent.children() else { return }; + if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) { + return; + } + for i in hit_order(parent, children, p) { + if out.len() >= MAX_LOCKED_AT { + return; + } + let Some(c) = children.get(i) else { continue }; + if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) { + continue; + } + if c.locked { + if locked_hit(c, p, opt, symbols) { + out.push(c.id); + } + continue; + } + match &c.kind { + NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out), + NodeKind::Layer { .. } + | NodeKind::Group { .. } + | NodeKind::Intertwine { .. } + | NodeKind::Blend { .. } + | NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out), + _ => {} + } + } +} + +/// Would a click at `p` hit the locked object `n` if it were not locked? +fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool { + let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some(); + match &n.kind { + NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(), + NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(), + _ => hit_leaf(n, p, opt, symbols).is_some(), + } +} + /// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are /// measured by their art ([`Node::geometric_bounds_with`]). fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option { @@ -901,6 +975,69 @@ world", assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); } + /// Insert `n` locked at the top of `parent`. + fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId { + let id = n.id; + n.locked = true; + d.insert(Some(parent), usize::MAX, n).unwrap(); + id + } + + /// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is + /// listed rather than its contents, and a locked object in an unlocked group is listed itself. + /// Hidden objects, locked layers and the art outside an isolated group are left out. + #[test] + fn locked_objects_at_lists_the_locked_stack() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let rect = + |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0)); + let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b)); + let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id()); + inner.locked = true; + let inner_id = inner.id; + let mut g = Node::group(locked_group, vec![Arc::new(inner)]); + g.locked = true; + d.insert(Some(l), usize::MAX, g).unwrap(); + let open_group = d.alloc_id(); + let mut child = rect(&mut d, 25.0); + child.locked = true; + let child_id = child.id; + let free = rect(&mut d, 25.0); + d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap(); + let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() }); + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]); + assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds"); + assert_eq!(at(&d, 5.0, None), [a]); + assert!(at(&d, 150.0, None).is_empty()); + // Isolated, only the group's own objects are looked at. + assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]); + // A hidden object is left out, and nothing on a locked or hidden layer is listed. + d.node_mut(b).unwrap().visible = false; + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]); + d.node_mut(l).unwrap().locked = true; + assert!(at(&d, 30.0, None).is_empty()); + d.node_mut(l).unwrap().locked = false; + d.node_mut(l).unwrap().visible = false; + assert!(at(&d, 30.0, None).is_empty()); + } + + /// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones. + #[test] + fn locked_objects_at_stops_at_its_limit() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let ids: Vec = (0..MAX_LOCKED_AT + 5) + .map(|_| { + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); + insert_locked(&mut d, l, n) + }) + .collect(); + let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default()); + assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::>()); + } + #[test] fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cc4f1c72e..a11f04725 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -20,9 +20,11 @@ pub mod inks; mod inline; pub mod intertwine; pub mod shared; +mod symbol_export_type; 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_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; pub mod live; @@ -110,9 +112,9 @@ 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::{ - AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel, - Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, - TextWrap, VerticalAlign, WrapShape, + AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify, + Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, + TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; @@ -1578,6 +1580,52 @@ impl Document { o.insert(name.into(), serde_json::json!(reg.0)); } } + + /// Move the stored registration when a symbol is renamed. + pub fn rename_symbol_registration(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let reg = self.symbol_registration(from); + self.set_symbol_registration(from, SymbolRegistration::CENTER); + self.set_symbol_registration(to, reg); + } + + /// Symbol Options › Export Type for `name` (default: Movie Clip). + pub fn symbol_export_type(&self, name: &str) -> SymbolExportType { + self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default() + } + + /// Set or clear (when Movie Clip, the default) the export type stored for `name`. + pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) { + if kind.is_default() { + self.clear_symbol_export_type(name); + return; + } + let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({})); + let Some(o) = m.as_object_mut() else { return }; + o.insert(name.into(), serde_json::json!(kind.id())); + } + + /// Drop any stored export type for `name` (Movie Clip default). + pub fn clear_symbol_export_type(&mut self, name: &str) { + let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return }; + let Some(o) = m.as_object_mut() else { return }; + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_EXPORT_TYPE); + } + } + + /// Move the stored export type when a symbol is renamed. + pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let kind = self.symbol_export_type(from); + self.clear_symbol_export_type(from); + self.set_symbol_export_type(to, kind); + } } fn default_graphic_styles() -> Vec { diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index bce0ad42b..05f6bc1bd 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1217,6 +1217,13 @@ impl Node { let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol)); self.projected(Some(b)).map(|b| b.inflate(o, o)) } + NodeKind::Text(t) => { + // The characters' strokes paint around the glyphs, scaled with the type. + let k = Scaling::factor(t.xf).unwrap_or(1.0); + let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k; + let o = self.appearance.outset().max(chars); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } _ => { let o = self.appearance.outset(); self.geometric(symbols).map(|b| b.inflate(o, o)) @@ -2336,6 +2343,25 @@ mod tests { assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); } + /// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the + /// weight centred, the whole weight outside, nothing inside; scaled with the type. + #[test] + fn type_visual_bounds_grow_with_the_character_stroke() { + use crate::appearance::StrokeAlign; + use crate::text::{CharStyle, TextObject}; + let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); + t.xf = Affine::scale(2.0) * t.xf; + let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + let g = node(&t).geometric_bounds().unwrap(); + for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { + t.runs[0].style.stroke_align = align; + assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); + } + t.runs[0].style.stroke = vectorcraft_color::Paint::None; + assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); + } + /// A group holding a path, a compound, an image and a nested group, as a file writes it. fn nested_group() -> Node { let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); diff --git a/crates/doc/src/symbol_export_type.rs b/crates/doc/src/symbol_export_type.rs new file mode 100644 index 000000000..9e326cd35 --- /dev/null +++ b/crates/doc/src/symbol_export_type.rs @@ -0,0 +1,49 @@ +//! Symbol Options › Export Type: Graphic or Movie Clip +//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). +//! +//! For Illustrator-only work the choice has no canvas effect; it is kept for Animate / Flash / +//! HTML5 handoff. Illustrator's default is Movie Clip. Stored under [`SYMBOL_EXPORT_TYPE`] keyed +//! by symbol name; the default (Movie Clip) is omitted from the map. + +use serde_json::Value; + +/// Document unknown key: `{ symbolName: "graphic"|"movieClip" }`. +pub const SYMBOL_EXPORT_TYPE: &str = "symbolExportType"; + +/// Symbol Options › Export Type. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SymbolExportType { + /// Static / graphic symbol instance (Animate Graphic). + Graphic, + /// Default: Movie Clip for Animate / game handoff; instances still place the same on the canvas. + #[default] + MovieClip, +} + +impl SymbolExportType { + pub const IDS: [&'static str; 2] = ["movieClip", "graphic"]; + + pub fn id(self) -> &'static str { + match self { + Self::Graphic => "graphic", + Self::MovieClip => "movieClip", + } + } + + pub fn from_id(s: &str) -> Option { + match s { + "graphic" => Some(Self::Graphic), + "movieClip" => Some(Self::MovieClip), + _ => None, + } + } + + /// True when this is the document default (nothing stored for the symbol). + pub fn is_default(self) -> bool { + matches!(self, Self::MovieClip) + } + + pub fn from_value(v: &Value) -> Option { + v.as_str().and_then(Self::from_id) + } +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index ad0f6b92e..c0adafba7 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2}; -use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer}; +use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { @@ -84,6 +84,10 @@ pub struct CharStyle { pub stroke_miter_limit: f64, #[serde(default, skip_serializing_if = "Option::is_none")] pub stroke_dash: Option, + /// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the + /// glyph outlines, inside or outside them, as for object strokes; the type stays live. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_align: StrokeAlign, /// Superscript or subscript (Character panel). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub position: CharPosition, @@ -249,6 +253,7 @@ impl Default for CharStyle { stroke_join: LineJoin::Miter, stroke_miter_limit: 10.0, stroke_dash: None, + stroke_align: StrokeAlign::Center, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, proportional_metrics: false, @@ -266,8 +271,9 @@ impl CharStyle { pub fn has_stroke(&self) -> bool { !self.stroke.is_none() && self.stroke_width > 0.0 } - /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and - /// overprint), so type strokes share the object strokes' geometry, rendering and export. + /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes, + /// alignment and overprint), so type strokes share the object strokes' geometry, rendering + /// and export. pub fn stroke_layer(&self) -> StrokeLayer { StrokeLayer { overprint: self.overprint_stroke, @@ -275,11 +281,13 @@ impl CharStyle { join: self.stroke_join, miter_limit: self.stroke_miter_limit, dash: self.stroke_dash.clone(), + align: self.stroke_align, ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) } } - /// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke - /// (the options characters have no use for, such as alignment and arrowheads, are dropped). + /// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the + /// character stroke (the options characters have no use for, such as arrowheads and width + /// profiles, are dropped). pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { self.stroke = st.paint.clone(); self.stroke_width = st.width; @@ -287,6 +295,19 @@ impl CharStyle { self.stroke_join = st.join; self.stroke_miter_limit = st.miter_limit; self.stroke_dash = st.dash.clone(); + self.stroke_align = st.align; + } + /// How far the character stroke paints past the glyph outlines (text space): half the weight + /// centred, the whole weight outside, nothing inside (miter spikes aside). + pub fn stroke_outset(&self) -> f64 { + if !self.has_stroke() { + return 0.0; + } + match self.stroke_align { + StrokeAlign::Center => self.stroke_width / 2.0, + StrokeAlign::Outside => self.stroke_width, + StrokeAlign::Inside => 0.0, + } } /// The character fill as a fill layer, overprinting as the characters do. fn fill_layer(&self) -> FillLayer { @@ -360,6 +381,37 @@ impl Composer { } } +/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic +/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from +/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen +/// limit, no hyphenation zone, and capitalized words hyphenated. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct HyphenationOptions { + /// Words Longer Than: only words with more letters than this are hyphenated. + pub words_longer_than: u32, + /// After First: the fewest letters a word keeps before the hyphen. + pub after_first: u32, + /// Before Last: the fewest letters a word keeps after the hyphen. + pub before_last: u32, + /// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen; + /// 0 sets no limit. + pub hyphen_limit: u32, + /// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next + /// word already ends within this distance of its right edge breaks before that word instead + /// of hyphenating it. 0 allows every hyphenation. + pub zone: f64, + /// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not + /// hyphenated. Words in capitals only are never hyphenated. + pub capitalized: bool, +} + +impl Default for HyphenationOptions { + fn default() -> Self { + Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true } + } +} + /// Paragraph attributes (the Paragraph panel). /// /// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before @@ -374,6 +426,8 @@ pub struct ParaStyle { pub space_before: f64, pub space_after: f64, pub hyphenate: bool, + /// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…). + pub hyphenation: HyphenationOptions, /// Line breaking: Single-line or Every-line Composer. pub composer: Composer, /// Tab stops (Tabs panel), sorted by position. @@ -421,6 +475,8 @@ struct ParaStyleFile { space_after: f64, #[serde(default)] hyphenate: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + hyphenation: HyphenationOptions, #[serde(default, skip_serializing_if = "Composer::is_default")] composer: Composer, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -454,6 +510,7 @@ impl From for ParaStyleFile { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, @@ -473,6 +530,7 @@ impl From for ParaStyleFile { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, @@ -495,6 +553,7 @@ impl From for ParaStyle { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, @@ -513,6 +572,7 @@ impl From for ParaStyle { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 877807b3b..6697f5b65 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -28,7 +28,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -106,9 +106,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { let m = evaluate_container(&m).unwrap_or(m); return Some(bake_pieces(d, m)); } - // Type, images, symbol instances and live objects: reshaped through their outlines. Type that + // Type, images, symbol instances and live objects: reshaped through their outlines, and so is + // type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that // Transform effects only move and copy stays type, moved to each place. - if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) { + if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) { if let Some(m) = moved_type_group(n) { return Some(bake_pieces(d, m)); } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 9a3391e5e..89ba2d491 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -6,7 +6,7 @@ //! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble, //! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order. //! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the -//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, +//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern, //! Unsharp Mask and Glowing Edges) are described by //! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond //! the geometry. @@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RasterFx, outset, raster_effects}; -pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape}; +pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, @@ -149,6 +149,7 @@ fn lengths_of(id: &str) -> Lengths { "texture.mosaicTiles" => always(&["tileSize"]), "texture.patchwork" => always(&["squareSize"]), "texture.stainedGlass" => always(&["cellSize"]), + "sketch.halftonePattern" => always(&["size"]), _ => Lengths::default(), } } @@ -165,6 +166,7 @@ const ARTISTIC: &[&str] = &["Effect", "Artistic"]; const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"]; const DISTORT: &[&str] = &["Effect", "Distort"]; const SHARPEN: &[&str] = &["Effect", "Sharpen"]; +const SKETCH: &[&str] = &["Effect", "Sketch"]; const PIXELATE: &[&str] = &["Effect", "Pixelate"]; const TEXTURE: &[&str] = &["Effect", "Texture"]; const VIDEO: &[&str] = &["Effect", "Video"]; @@ -289,6 +291,13 @@ pub fn effect_catalog() -> Vec { "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), ), + g( + "path.outlineObject", + "Outline Object", + PATH, + "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", + json!({}), + ), g( "path.outlineStroke", "Outline Stroke", @@ -432,6 +441,13 @@ pub fn effect_catalog() -> Vec { "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), ), + r( + "sketch.halftonePattern", + "Halftone Pattern…", + SKETCH, + "{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}), + ), r( "stylize.glowingEdges", "Glowing Edges…", @@ -865,6 +881,9 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo } vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) } + // Outline Object returns the geometry unchanged. Type reaches every geometry effect as its + // glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines. + "path.outlineObject" => path.clone(), "path.outlineStroke" => { let fallback; let base = match ctx.stroke { diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 98cbabb48..73548a0b8 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,7 +1,8 @@ //! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes › //! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes //! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, -//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture +//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize › +//! Glowing Edges, Texture //! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video › //! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels. //! @@ -20,6 +21,7 @@ mod edges; mod loaded; mod pixelate; mod sharpen; +mod sketch; mod texture; mod video; @@ -33,10 +35,11 @@ pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection}; pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture}; pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture}; pub use pixelate::{MEZZOTINT_TYPES, Mezzotint}; +pub use sketch::{HALFTONE_TYPES, HalftonePattern}; pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). -pub const PIXEL_EFFECTS: [&str; 28] = [ +pub const PIXEL_EFFECTS: [&str; 29] = [ "artistic.filmGrain", "blur.radial", "blur.smart", @@ -56,6 +59,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [ "pixelate.mezzotint", "pixelate.pointillize", "sharpen.unsharpMask", + "sketch.halftonePattern", "stylize.glowingEdges", "texture.craquelure", "texture.grain", @@ -123,6 +127,9 @@ pub enum PixelFx { /// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount` /// (1 = 100 %), where they differ from it by at least `threshold` levels. UnsharpMask { amount: f64, radius: f64, threshold: f64 }, + /// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size` + /// (1..12) apart, its edges as hard as `contrast` (0..50) says. + HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern }, /// Stylize › Glowing Edges: bright coloured Sobel outlines on black. GlowingEdges { width: f64, brightness: f64, smoothness: f64 }, /// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels @@ -277,6 +284,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { radius: num(p, "radius", 1.0).clamp(0.1, 250.0), threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0), }, + "sketch.halftonePattern" => PixelFx::HalftonePattern { + size: num(p, "size", 1.0).clamp(1.0, 12.0), + contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0), + kind: HalftonePattern::parse(text(p, "patternType", "dot")), + }, "stylize.glowingEdges" => PixelFx::GlowingEdges { width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0), brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0), @@ -353,7 +365,7 @@ impl PixelFx { // Content shifted outwards reaches past the bounds by as much as the shift. PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion), PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude), - PixelFx::UnsharpMask { .. } => 0.0, + PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0, PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0), PixelFx::FilmGrain { .. } | PixelFx::ColorHalftone { .. } @@ -396,7 +408,7 @@ impl PixelFx { // The cells around the pixel's, and the cells around theirs. PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius), PixelFx::Crystallize { cell } => Some(1.5 * cell), - PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0), + PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0), // The dots' points, and the softened colour around them. PixelFx::Pointillize { cell } => Some(2.5 * cell), // The softened tones around the pixel. @@ -439,6 +451,7 @@ impl PixelFx { } PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude), PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), + PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind), PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)), PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles), PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell), diff --git a/crates/effects/src/pixel/sketch.rs b/crates/effects/src/pixel/sketch.rs new file mode 100644 index 000000000..7f9f7cd04 --- /dev/null +++ b/crates/effects/src/pixel/sketch.rs @@ -0,0 +1,71 @@ +//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation: +//! (Sketch effects redraw in +//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones) +//! and (only Size depends on the raster +//! resolution). A visual approximation, not a reproduction of a proprietary algorithm. + +use super::pixelate::Place; +use super::texture::{luma, premultiply, straight}; +use super::{PixelSpace, pick}; + +/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value). +pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")]; + +/// A Halftone Pattern screen ([`HALFTONE_TYPES`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HalftonePattern { + /// Rings round the object's centre. + Circle, + /// Dots on a square grid. + Dot, + /// Horizontal lines. + Line, +} + +impl HalftonePattern { + const ALL: [HalftonePattern; 3] = [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]; + + /// The screen of Pattern Type `value` (a [`HALFTONE_TYPES`] value, any case); Dot otherwise. + pub fn parse(value: &str) -> Self { + pick(&HALFTONE_TYPES, &Self::ALL, value, HalftonePattern::Dot) + } +} + +/// Halftone Pattern: the object redrawn in black ink on white paper through a `kind` screen whose +/// cells are `size` points apart, anchored at the object's centre. The ink covers as much of a +/// cell as the colour is dark; `contrast` (0..50) hardens the screen's soft edges, to pure black +/// and white at 50. Coverage is kept. +pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, contrast: f64, kind: HalftonePattern) { + if !(size.is_finite() && size > 0.0 && contrast.is_finite()) { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + // The width of the screen's soft edge, in tones: 0 is a hard threshold. + let soft = (0.5 * (1.0 - contrast / 50.0)).clamp(0.0, 0.5); + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let band = |v: f64| (2.0 * ((v / size).rem_euclid(1.0) - 0.5)).abs(); + // Where in its cell the pixel lies, 0 where the ink starts to 1 where it ends. + let screen = match kind { + HalftonePattern::Line => band(q.y), + HalftonePattern::Circle => band(q.x.hypot(q.y)), + HalftonePattern::Dot => 0.5 * (band(q.x).powi(2) + band(q.y).powi(2)), + }; + let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0); + let ink = if dark >= 1.0 { + 1.0 + } else if soft > 0.0 { + // The threshold kept inside [soft / 2, 1 - soft / 2], so white stays white and black black. + let threshold = 0.5 * soft + screen * (1.0 - soft); + ((dark - threshold) / soft + 0.5).clamp(0.0, 1.0) + } else if dark > screen { + 1.0 + } else { + 0.0 + }; + *p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0); + }); +} diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index e77a8fedf..017c96698 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -1893,3 +1893,119 @@ fn brush_strokes_follow_the_object_at_any_resolution() { assert_eq!(shifted, one, "{id}"); } } + +/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`. +fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec { + let mut out = src.to_vec(); + fx("sketch.halftonePattern", p).apply(&mut out, w, h, s); + out +} + +/// The smallest period of `values` (their length when they don't repeat). +fn period(values: &[u8]) -> usize { + (1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len()) +} + +#[test] +fn halftone_pattern_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot }); + assert_eq!( + fx("sketch.halftonePattern", json!({"size": 1e9, "contrast": -3, "patternType": "LINE"})), + PixelFx::HalftonePattern { size: 12.0, contrast: 0.0, kind: HalftonePattern::Line } + ); + assert_eq!( + fx("sketch.halftonePattern", json!({"size": 0, "contrast": 1e9, "patternType": "circle"})), + PixelFx::HalftonePattern { size: 1.0, contrast: 50.0, kind: HalftonePattern::Circle } + ); + for junk in [json!({"patternType": 2}), json!({"patternType": "nonsense"})] { + assert_eq!(fx("sketch.halftonePattern", junk), fx("sketch.halftonePattern", json!({}))); + } + let doc = crate::effect_info("sketch.halftonePattern").unwrap().params; + let kinds: Vec = HALFTONE_TYPES.iter().map(|(_, v)| HalftonePattern::parse(v)).collect(); + assert_eq!(kinds, [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]); + assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}"); + let f = fx("sketch.halftonePattern", json!({"size": 12})); + assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0); + assert_eq!(f.reach(), Some(0.0)); +} + +/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi. +#[test] +fn halftone_line_period_is_the_size_at_any_resolution() { + let p = json!({"size": 4, "contrast": 50, "patternType": "line"}); + let (w, h) = (48, 48); + let out = halftone(p.clone(), &image(w, h, |_, _| grey(128)), w, h, &space(w, h)); + let column: Vec = (0..h).map(|y| at(&out, w, 0, y)[0]).collect(); + assert!(column.contains(&0) && column.contains(&255), "{column:?}"); + assert_eq!(period(&column), 4, "{column:?}"); + let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) }; + let out = halftone(p, &image(2 * w, 2 * h, |_, _| grey(128)), 2 * w, 2 * h, &fine); + let column: Vec = (0..2 * h).map(|y| at(&out, 2 * w, 0, y)[0]).collect(); + assert_eq!(period(&column), 8, "{column:?}"); +} + +/// Circle draws rings round the object's centre, Line rows that don't change along them, and +/// Dot neither. +#[test] +fn halftone_pattern_type_changes_the_pattern() { + let (w, h) = (64, 64); + let src = image(w, h, |_, _| grey(100)); + // The centre on a pixel's centre: pixel (32 + dx, 32 + dy) lies at (dx, dy) from it. + let s = PixelSpace { center: Point::new(32.5, 32.5), ..space(w, h) }; + let draw = |kind: &str| halftone(json!({"size": 8, "contrast": 50, "patternType": kind}), &src, w, h, &s); + let (circle, dot, line) = (draw("circle"), draw("dot"), draw("line")); + assert!(circle != dot && dot != line && line != circle); + // Pixels at the same distance from the centre in different directions. + let same_radius: [&[(i64, i64)]; 6] = [ + &[(5, 0), (3, 4), (-4, 3), (0, -5), (-3, -4)], + &[(10, 0), (6, 8), (-8, -6), (0, 10)], + &[(13, 0), (5, 12), (-12, 5), (0, -13)], + &[(15, 0), (9, 12), (-12, 9)], + &[(17, 0), (8, 15), (-15, -8)], + &[(25, 0), (7, 24), (-15, 20), (-20, -15), (24, -7)], + ]; + let value = |d: &[u8], (dx, dy): (i64, i64)| at(d, w, (32 + dx) as usize, (32 + dy) as usize)[0]; + let radial = |d: &[u8]| same_radius.iter().all(|ring| ring.iter().all(|q| value(d, *q) == value(d, ring[0]))); + assert!(radial(&circle), "Circle is the same all round"); + assert!(!radial(&dot) && !radial(&line)); + for y in 0..h { + assert!((0..w).all(|x| at(&line, w, x, y) == at(&line, w, 0, y)), "row {y} of Line is uniform"); + } +} + +#[test] +fn halftone_contrast_sharpens_the_screen() { + let (w, h) = (64, 32); + let src = image(w, h, |x, _| grey((x * 4) as u8)); + let mid = |d: &[u8]| share(d, |p| p[0] != 0 && p[0] != 255); + let soft = halftone(json!({"size": 6, "contrast": 0}), &src, w, h, &space(w, h)); + let hard = halftone(json!({"size": 6, "contrast": 50}), &src, w, h, &space(w, h)); + assert!(mid(&soft) > 0.3, "{}", mid(&soft)); + assert_eq!(mid(&hard), 0.0); +} + +/// Halftone Pattern redraws in black ink on white paper, whatever the object's colours, and +/// keeps its coverage. +#[test] +fn halftone_draws_in_black_and_white_only() { + let (w, h) = (48, 48); + let src = image(w, h, |x, y| { + let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4]; + let a: u16 = if y < 24 { 255 } else { 128 }; + [c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::>().try_into().unwrap() + }); + for kind in ["circle", "dot", "line"] { + let out = halftone(json!({"size": 6, "contrast": 50, "patternType": kind}), &src, w, h, &space(w, h)); + for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + assert_eq!(p[3], q[3], "{kind}: coverage kept"); + assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{kind}: {p:?} from {q:?}"); + } + assert!(share(&out, |p| p[0] == 0) > 0.1 && share(&out, |p| p[0] == p[3]) > 0.1, "{kind}: ink and paper"); + let soft = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &src, w, h, &space(w, h)); + assert!(soft.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{kind}: greys only"); + for (tone, expect) in [(0, 0), (255, 255)] { + let out = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &image(w, h, |_, _| grey(tone)), w, h, &space(w, h)); + assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == expect), "{kind}: {tone} stays {expect}"); + } + } +} diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 1113000c0..a6e2926ac 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -5,7 +5,9 @@ //! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect //! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a //! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects -//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. +//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without +//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those +//! effects reshapes the whole outline that its fill or stroke paints. use std::sync::Arc; @@ -30,6 +32,14 @@ pub fn needs_outline(n: &Node) -> bool { ) } +/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a +/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke +/// paints ([`outline_text`], which leaves inline graphics out). +pub fn has_item_geometry(n: &Node) -> bool { + let NodeKind::Text(t) = &n.kind else { return false }; + t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) +} + /// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one /// path per run, and all of them as one. pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath) { @@ -155,11 +165,13 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: /// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped -/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and -/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a -/// perspective, or has no art (a symbol instance without `symbol_art`). +/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a +/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster) +/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with +/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a +/// symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) { + if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) { return None; } as_art(n, symbol_art) diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 1416d91ce..9b90995bc 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -572,6 +572,18 @@ fn outline_stroke_uses_context_stroke() { assert!(close(out.bounds().unwrap().width(), 101.0, 0.1)); } +#[test] +fn outline_object_is_a_geometry_effect_that_keeps_the_path() { + let info = effect_info("path.outlineObject").unwrap(); + assert_eq!((info.label, info.menu, &info.defaults, info.raster), ("Outline Object", &["Effect", "Path"][..], &json!({}), false)); + assert!(is_geometry("path.outlineObject")); + assert_eq!(run("path.outlineObject", json!({}), &square()), square()); + // The effects after it reshape the same geometry. + let b = square().bounds().unwrap(); + let offset = fx("path.offsetPath", json!({"offset": 5})); + assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b)); +} + #[test] fn convert_to_shape_sizes() { let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap(); diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 813d6cd34..a137271c5 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -80,7 +80,7 @@ fn clean_name(name: &str) -> Option { /// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it /// (sublayers unfolded). Layers alone have nothing to collect. -fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec) { +pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec) { let Some(n) = doc.node(id) else { return }; if n.is_layer() { if let Some(kids) = n.children() { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index d72b3414e..d91f82408 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_brush::{self as brush, Brush}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration}; use vectorcraft_geom::{Affine, Point, Vec2}; use super::edit::{outermost, selected_roots}; @@ -95,16 +95,25 @@ pub fn specs() -> Vec { brush_freehand ), // ----- symbols ----- - cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8}], current}", has_doc, symbol_list), + cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], 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)} make a symbol from the selection and replace it with an instance → {name, id, registration}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}", has_selection, symbol_new ), + cmd!( + "symbol.options", + "Symbol Options…", + [], + None, + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}", + has_doc, + symbol_options + ), cmd!( "symbol.place", "Place Symbol Instance", @@ -517,10 +526,30 @@ fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) } -fn registration_param(p: &Value, cmd: &str) -> Result { +fn registration_param(p: &Value, cmd: &str) -> Result> { match p.get("registration") { - None => Ok(SymbolRegistration::CENTER), - Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).ok_or_else(|| bad(cmd, "registration must be 0..8")), + None => Ok(None), + Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")), + } +} + +fn export_type_param(p: &Value, cmd: &str) -> Result> { + match str_param(p, "exportType") { + None => Ok(None), + Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))), + } +} + +fn rename_natural_size(d: &mut Document, from: &str, to: &str) { + if from == to { + return; + } + let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned(); + set_natural_size(d, from, None); + if let Some(v) = size + && let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64)) + { + set_natural_size(d, to, Some((w, h))); } } @@ -566,6 +595,7 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result { "instances": instances_of(d, Some(&sym.name)).len(), "size": [w, h], "registration": d.symbol_registration(&sym.name).0, + "exportType": d.symbol_export_type(&sym.name).id(), }) }) .collect(); @@ -589,11 +619,13 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin } fn symbol_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.new"; let ids = roots(s, p)?; let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; let (art, size, c) = normalize(art)?; let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol")); - let reg = registration_param(p, "symbol.new")?; + let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); + let export = export_type_param(p, C)?.unwrap_or_default(); // 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| { @@ -601,12 +633,64 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result { d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); set_natural_size(d, &name, Some(size)); d.set_symbol_registration(&name, reg); + d.set_symbol_export_type(&name, export); d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); let id = replace_with_instance(d, &ids, &name, place)?; sel.set([id]); Ok(id) })?; - Ok(json!({ "name": name, "id": id.0, "registration": reg.0 })) + Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() })) +} + +fn symbol_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.options"; + let name = resolve_symbol(&s.doc()?.doc, p, C)?; + let export = export_type_param(p, C)?; + let reg = registration_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() { + return Err(bad(C, "pass newName, registration and/or exportType")); + } + if let Some(ref n) = new_name + && n != &name + && s.doc()?.doc.symbols.iter().any(|x| x.name == *n) + { + return Err(bad(C, format!("a symbol named `{n}` already exists"))); + } + let final_name = new_name.clone().unwrap_or_else(|| name.clone()); + s.edit("Symbol Options", |d, _| { + if let Some(ref n) = new_name + && n != &name + { + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.name = n.clone(); + } + for id in instances_of(d, Some(&name)) { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) { + *symbol = n.clone(); + } + } + rename_natural_size(d, &name, n); + d.rename_symbol_registration(&name, n); + d.rename_symbol_export_type(&name, n); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); + } + } + if let Some(r) = reg { + d.set_symbol_registration(&final_name, r); + } + if let Some(kind) = export { + d.set_symbol_export_type(&final_name, kind); + } + Ok(()) + })?; + let d = &s.doc()?.doc; + Ok(json!({ + "name": final_name, + "registration": d.symbol_registration(&final_name).0, + "exportType": d.symbol_export_type(&final_name).id(), + })) } fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { @@ -763,6 +847,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result { 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); if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { d.unknown.remove(CURRENT_SYMBOL); } @@ -779,14 +864,16 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy"))); let size = natural_size(d, &name); let reg = d.symbol_registration(&name); + let export = d.symbol_export_type(&name); s.edit("Duplicate Symbol", |d, _| { let art = d.reid(&sym.art); d.symbols.push(Symbol { 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); Ok(()) })?; - Ok(json!({ "name": new })) + Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() })) } fn symbol_replace(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 70240db1e..fd9dbaae7 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -100,6 +100,15 @@ pub fn specs() -> Vec { has_selection, save_selection ), + cmd!( + "select.update", + "Update Selection", + ["Select"], + None, + "{} make the saved selection last recalled (`select.recall`) hold the selected objects instead of its own, in one undo step → {name}", + has_recalled_selection, + update_selection + ), cmd!( "select.recall", "Recall Selection", @@ -135,7 +144,7 @@ pub fn specs() -> Vec { "Release Guides", ["View", "Guides"], Some("Cmd+Alt+5"), - "{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}", + "{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}", has_doc, release_guides ), @@ -342,6 +351,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result { if existing.is_none() && saved.len() >= SavedSelection::MAX { return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX))); } + let mut slots = saved_ids(s)?; s.edit("Save Selection", |d, _| { let entry = SavedSelection { name: name.clone(), objects: ids }; match existing.and_then(|i| d.saved_selections.get_mut(i)) { @@ -350,18 +360,26 @@ fn save_selection(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + let st = s.doc_mut()?; + if existing.is_none() { + slots.push(st.recalled_selection.new_id()); + } + st.recalled_selection.remember(&st.doc.saved_selections, slots); Ok(json!({ "name": name })) } fn recall_selection(s: &mut Session, p: &Value) -> Result { const C: &str = "select.recall"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?; - let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone()); - let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?; + let saved = &s.doc()?.doc.saved_selections; + let i = saved.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?; + let ids = saved.get(i).map(|x| x.objects.clone()).unwrap_or_default(); let st = s.doc()?; let can = selectable_ids(&st.doc, st.isolation); s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?; - Ok(json!({ "count": s.doc()?.selection.len() })) + let st = s.doc_mut()?; + st.recalled_selection.recall(&st.doc.saved_selections, i); + Ok(json!({ "count": st.selection.len() })) } /// Recall and Edit Selection… need a document with at least one saved selection. @@ -370,6 +388,42 @@ fn has_saved_selection(s: &Session) -> std::result::Result<(), String> { if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) } } +/// The session ids of the active document's saved selections ([`crate::RecalledSelection`]). +fn saved_ids(s: &mut Session) -> Result> { + let st = s.doc_mut()?; + Ok(st.recalled_selection.ids(&st.doc.saved_selections)) +} + +/// Where the saved selection last recalled is, while the document has it. +fn recalled_index(st: &crate::DocState) -> Option { + st.recalled_selection.index_in(&st.doc.saved_selections) +} + +/// Update Selection needs selected objects and a recalled saved selection the document still has. +fn has_recalled_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + if recalled_index(st).is_some() { Ok(()) } else { Err("no saved selection recalled".into()) } +} + +fn update_selection(s: &mut Session, _: &Value) -> Result { + const C: &str = "select.update"; + let st = s.doc()?; + let i = recalled_index(st).ok_or_else(|| bad(C, "no saved selection recalled"))?; + let ids = st.selection.objects.clone(); + let name = st.doc.saved_selections.get(i).map(|x| x.name.clone()).unwrap_or_default(); + let slots = saved_ids(s)?; + s.edit("Update Selection", |d, _| { + if let Some(x) = d.saved_selections.get_mut(i) { + x.objects = ids; + } + Ok(()) + })?; + let st = s.doc_mut()?; + st.recalled_selection.remember(&st.doc.saved_selections, slots); + Ok(json!({ "name": name })) +} + fn edit_saved(s: &mut Session, p: &Value) -> Result { const C: &str = "select.editSaved"; let edits = match p.get("edits").and_then(Value::as_array) { @@ -396,6 +450,7 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result { x.name = n; } } + let slots: Vec = saved_ids(s)?.into_iter().zip(&after).filter(|(_, x)| x.is_some()).map(|(id, _)| id).collect(); let after: Vec = after.into_iter().flatten().collect(); let mut names = std::collections::HashSet::with_capacity(after.len()); for x in &after { @@ -408,6 +463,8 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result { d.saved_selections = after.into(); Ok(()) })?; + let st = s.doc_mut()?; + st.recalled_selection.remember(&st.doc.saved_selections, slots); } ok() } @@ -465,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec { v } -fn release_guides(s: &mut Session, _: &Value) -> Result { +/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected +/// guide paths and ruler guides, else every guide path, in one undo step. +fn release_guides(s: &mut Session, p: &Value) -> Result { + const C: &str = "view.guides.release"; + let (index, ids) = (p.get("index"), p.get("ids")); + if (index.is_some() || ids.is_some()) && s.menu.guides_locked { + return Err(EngineError::Other("guides are locked".into())); + } + let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { + let rulers = match index { + Some(v) => vec![editable_guide_index(s, Some(v), C)?], + None => vec![], + }; + let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] }; + let d = &s.doc()?.doc; + let mut paths = vec![]; + for r in roots { + if let Some(n) = d.node(r) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) { + paths.push(c.id); + } + }); + } + } + (rulers, paths) + } else { + let st = s.doc()?; + let all = guide_paths(&st.doc); + let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); + let rulers: Vec = if s.menu.guides_locked { + vec![] + } else { + st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect() + }; + if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) } + }; + // Find each ruler guide's line and its parent: the guide's layer, else where new art goes. let st = s.doc()?; - let all = guide_paths(&st.doc); - let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); - let targets: Vec = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel }; - if targets.is_empty() { + let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)); + let mut lines = Vec::with_capacity(rulers.len()); + for &i in &rulers { + let Some(g) = st.doc.guides.get(i) else { continue }; + let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); + let Some((a, b)) = span else { continue }; + let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) }; + let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) { + Some(l) => Some(l), + None => st.target_parent()?, + }; + lines.push((i, parent, a, b)); + } + let n = paths.len() + lines.len(); + if n == 0 { return Err(EngineError::Other("Release Guides: no guides".into())); } - let n = targets.len(); s.edit("Release Guides", |d, sel| { - for id in &targets { + for id in &paths { if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { *guide = false; } } - sel.set(targets.iter().copied()); + let mut made = Vec::with_capacity(lines.len()); + for &(_, parent, a, b) in &lines { + let id = d.alloc_id(); + let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0); + d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?; + made.push(id); + } + d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i)); + sel.set(paths.iter().copied().chain(made)); Ok(()) })?; Ok(json!({ "count": n })) diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 8d699798d..872eb9cb1 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1,6 +1,7 @@ //! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser, //! blob brush, smooth, path eraser, join). +use std::collections::BTreeSet; use std::sync::Arc; use kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; @@ -12,7 +13,8 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions}; -use super::create::{add_look, add_node, insert_look}; +use super::create::{add_look, insert_look}; +use super::newart::NewArt; use super::pathops::{Beside, remove_each}; use super::*; use crate::EngineError; @@ -60,7 +62,7 @@ pub fn specs() -> Vec { "Remove Anchor Points", ["Object", "Path"], None, - "{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}", + "{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}", has_anchors, remove_anchors ), @@ -141,7 +143,7 @@ pub fn specs() -> Vec { "Blob Brush", [], None, - "{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}", + "{points: [[x,y]…], size?: pt (10), 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 touching same-colour blobs → {id}", has_doc, blob ), @@ -772,7 +774,16 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result { let mut removed = 0; let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); for (id, anchors) in jobs { - removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?); + let shape: Vec<(usize, bool)> = + d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); + let next = following_anchors(&shape, &anchors); + if remove_anchors_of(d, sel, &mut emptied, id, anchors)? { + removed += 1; + } else if !next.is_empty() { + // The next anchor along is selected, so the next Remove Anchor Points goes on from + // there without picking it first (#2241). + sel.anchors.insert(id, next); + } } emptied.finish(d)?; Ok(removed) @@ -780,6 +791,30 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result { Ok(json!({ "removedObjects": removed })) } +/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a +/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its +/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's +/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. +fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { + let mut next = BTreeSet::new(); + let mut kept = 0; + for (si, &(n, closed)) in shape.iter().enumerate() { + let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + if n - gone.len() < 2 { + continue; + } + if let Some(&last) = gone.last() { + let wrap = if closed { 0..last } else { 0..0 }; + let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); + if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { + next.insert((kept, a - gone.range(..a).count())); + } + } + kept += 1; + } + next +} + /// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round /// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed /// through `emptied`. True when the path went. @@ -1312,11 +1347,14 @@ fn blob(s: &mut Session, p: &Value) -> Result { } else { Paint::solid(Color::BLACK) }; + let keep_selected = bool_or(p, "keepSelected", false); + let only_selection = bool_or(p, "mergeOnlySelection", false); let st = s.doc()?; let mut merge_ids = vec![]; - if bool_or(p, "merge", true) { + if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) { let all = Selection::default(); - for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) { + let from = if only_selection { &st.selection } else { &all }; + for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { let Some(n) = st.doc.node(id) else { continue }; if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { continue; @@ -1327,20 +1365,26 @@ fn blob(s: &mut Session, p: &Value) -> Result { } } } - let ap = Appearance::basic(paint, Paint::None, 1.0); + let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0)); if merge_ids.is_empty() { - return add_node(s, "Blob Brush", path_kind(region), ap, None); + return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); } let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); let merged = po::unite_all(&inputs); - let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) }; + let Some(((keep, _, _), rest)) = merge_ids.split_last() else { + return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); + }; let keep = *keep; let others: Vec = rest.iter().map(|m| m.0).collect(); s.edit("Blob Brush", |d, sel| { *path_mut(d, keep)? = merged; remove_each(d, &others)?; - sel.set([keep]); + if keep_selected { + sel.set([keep]); + } else { + sel.remove(keep); + } Ok(()) })?; Ok(json!({ "id": keep.0 })) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 89946fb09..2c0eff9f4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -324,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result> { Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in +/// an isolated compound path its paths are objects of their own, so the hole of a ring moves +/// without the ring. Paint and the 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), st.isolation)) +} + /// 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, None) +} + +/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path. +fn roots_within(doc: &Document, ids: Vec, isolation: Option) -> 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). @@ -336,7 +349,7 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec) -> 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 { .. }) => (n.id, Some(up.to_vec())), + Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())), _ => (id, path.cloned()), } }) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index d24ea14af..87751dc48 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -94,11 +94,12 @@ fn selected_part(n: &Node, keep: &HashSet) -> Option { } /// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left -/// out, at any depth of layers, sublayers and groups that don't clip, and objects that reach it -/// stay whole (the file's bounds clip them), as DXF's crop leaves them. Symbol instances are -/// measured by their art, and the box of one whose art holds brushed strokes grows by as far as -/// their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol -/// instance also stays when what the canvas paints for it reaches `rect` +/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer +/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it. +/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them. +/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes +/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that +/// holds a symbol instance also stays when what the canvas paints for it reaches `rect` /// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A /// clipping layer keeps every object, its clipping path among them. An artboard's file then holds /// its own art, not a whole document of artboards. @@ -122,8 +123,9 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1; let mut holds_instance = false; n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over); let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over); - (n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone()) + (n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone()) } let symbols = doc.symbol_boxes().with_brush_art(); let mut d = doc.clone(); @@ -165,13 +167,19 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result Result<(Document, vectorcraft_geom::Rect)> { + let ids = selected_artwork(s)?; + isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export")) +} + +/// The selected objects (their top-most selected ancestors) that are artwork: those on template +/// layers are guides and are left out. +pub(crate) fn selected_artwork(s: &mut Session) -> Result> { let ids = edit::selected_roots(s)?; let st = s.doc()?; let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - let ids: Vec = ids.into_iter().filter(|id| !on_template(*id)).collect(); - isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export")) + Ok(ids.into_iter().filter(|id| !on_template(*id)).collect()) } /// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 3a6672d4f..93282d33d 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -34,6 +34,7 @@ mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note}; mod metafile; +mod naming; mod native; pub mod pdf; mod pdfimport; @@ -63,6 +64,7 @@ pub use load::{ read_for_open, texture_image, }; pub use load::{losses_summary, overwrite_losses}; +pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; @@ -71,7 +73,10 @@ pub use save::{ SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; -pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows}; +pub use screens::{ + PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize, + builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows, +}; pub(crate) use screens::{ SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings, }; @@ -136,10 +141,37 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one store-only .zip of every file: 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), 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), 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)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)", + "{folder?, zip?: false (one store-only .zip of every file: 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, ,