diff --git a/ASSETS.md b/ASSETS.md index 34ff73361..17ab680ff 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -368,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau | `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` | | `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` | | `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` | +| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` | +| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` | +| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` | +| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` | +| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` | +| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` | +| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` | +| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` | +| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` | +| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` | ## Assets embedded by the PDF backend diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..287a23f0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2286,6 +2286,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heic-rs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" +dependencies = [ + "rayon", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -6246,10 +6255,12 @@ dependencies = [ "crc32fast", "flate2", "hayro-jpeg2000", + "heic-rs", "image", "kurbo 0.13.1", "log", "proptest", + "rayon", "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", diff --git a/ROADMAP.md b/ROADMAP.md index 0f9267e0e..504324eaf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -34,6 +34,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | @@ -134,6 +135,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. @@ -223,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | | 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index f7faf7a92..376f43190 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-format = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-svg = { workspace = true } diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index ff4bcef86..6e4dbd7f0 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -14,7 +14,7 @@ name = "vectorcraft-web" path = "src/main.rs" [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } # The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft. craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 170a7e5c7..4ab382d5c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"] accessibility = ["eframe/accesskit"] [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 8019276ec..112c71370 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -467,6 +467,7 @@ fn main() -> std::process::ExitCode { rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } app.custom_titlebar = custom_titlebar; + app.desktop_frame = !custom_titlebar; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index b66795e89..7f6382b2f 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -233,13 +233,16 @@ impl ParaList { /// The list that the Type tool starts when `typed` and a space are typed at the start of a /// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B, - /// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts - /// none. + /// C, and "*" starts a bulleted list with the filled dot, "+" one with the hollow circle, "#" + /// one with the filled square and ">" one with the dash. Any other text starts none. pub fn typed(typed: &str) -> Option { match typed { "1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)), "A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)), - "*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)), + "*" => Some(Self::bulleted(Bullet::Dot)), + "+" => Some(Self::bulleted(Bullet::Circle)), + "#" => Some(Self::bulleted(Bullet::Square)), + ">" => Some(Self::bulleted(Bullet::Dash)), _ => None, } } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 2a47ee453..a3f4ab4d2 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -212,22 +212,23 @@ fn number_styles_write_their_numbers_and_roll_over() { } /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph -/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the -/// filled dot. Other text, the same characters later in a paragraph or without the space, and -/// a paragraph already in a list start nothing. +/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the +/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters +/// later in a paragraph or without the space, and a paragraph already in a list start nothing. #[test] fn typed_list_starts_are_found_at_a_paragraphs_start() { let (arabic, letters, dot) = (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); - let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed); - assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some)); + let (circle, square, dash) = (ParaList::bulleted(Bullet::Circle), ParaList::bulleted(Bullet::Square), ParaList::bulleted(Bullet::Dash)); + let typed = ["1.", "1)", "A.", "A)", "*", "+", "#", ">"].map(ParaList::typed); + assert_eq!(typed, [arabic, arabic, letters, letters, dot, circle, square, dash].map(Some)); for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] { assert_eq!(ParaList::typed(other), None, "{other:?}"); } let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default()); assert_eq!(t.typed_list_at(9), Some((6, arabic))); assert_eq!(t.typed_list_at(19), Some((16, letters))); - assert_eq!(t.typed_list_at(27), Some((25, dot))); + assert_eq!(t.typed_list_at(27), Some((25, circle))); // Not at the paragraph's start, not right after the space, or past the end. for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] { assert_eq!(t.typed_list_at(at), None, "{at}"); @@ -245,7 +246,7 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() { #[test] fn typed_list_starts_skip_a_continued_paragraph() { let dot = ParaList::bulleted(Bullet::Dot); - let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default()); + let mut t = TextObject::point(Point::ZERO, "* Pears\n* ", CharStyle::default()); assert_eq!(t.typed_list_at(2), Some((0, dot))); t.first_para_continues = true; assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list"); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eddf966db..0ebcb71c5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,10 +9,14 @@ description = "VectorCraft engine: sessions, history, selection, the command reg workspace = true [features] -default = ["bundled-fonts"] +default = ["bundled-fonts", "heif"] # The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at # run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`. bundled-fonts = ["vectorcraft-text/bundled-fonts"] +# HEIC and HEIF images open and place, decoded by heic-rs (an HEVC decoder). A build without this +# feature still recognizes the files and refuses them with a message. The apps list it beside +# `bundled-fonts`. +heif = ["dep:heic-rs", "dep:rayon"] [dependencies] # Which paths stored in documents may be opened. @@ -37,6 +41,8 @@ vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } +# Pure Rust, no unsafe; pinned at the version photocraft-heif uses. +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true } flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). @@ -54,6 +60,11 @@ log = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["std", "process"] } +# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true } +rayon = { version = "1.12", optional = true } + [dev-dependencies] vectorcraft-affinity = { workspace = true, features = ["synth"] } crc32fast = { workspace = true } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index f54752632..1b37ca96f 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Make and Expand", ["Object", "Image Trace"], None, - "{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}", + "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}", has_selection_or_ids, |s, p| trace_make(s, p, true) ), @@ -171,7 +171,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Image Trace"], None, - "{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}", + "{id?, ids?: [id…] (instead of id), byColor?: false (each color's paths in a group of their own, named by the swatch the color links to or as #rrggbb, gradient fills in one group named Gradients; the groups stack by their lowest path and keep their paths' order; a color whose paths overlap another color's from above and below gets a group for each part, so the art draws the same; refused for a trace made with autoGrouping and for a trace with a live Pathfinder effect)} keep only the traced paths (drop the source image) → {ids}", has_selection_or_ids, trace_expand ), diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 00bcc1c83..1924ed405 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -4,10 +4,16 @@ use vectorcraft_color::Swatch; use vectorcraft_doc::ImageObject; use super::{ - Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, + Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, Rect, Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, }; +#[cfg(test)] +thread_local! { + static BOX_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static CANDIDATE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // ---------- Image Trace ---------- fn is_trace_group(n: &Node) -> bool { @@ -16,6 +22,8 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor"; + /// The `view` param ([`TraceView`] id), if given. fn view_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; @@ -368,6 +376,156 @@ fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() } +/// The `byColor` param of an Expand (false when not given). +fn by_color_param(cmd: &str, p: &Value) -> Result { + match p.get("byColor").filter(|v| !v.is_null()) { + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "byColor must be true or false")), + None => Ok(false), + } +} + +/// Expand by color: an expanded trace's `items` (bottom-most first) in a group for each paint, a +/// stroked line counted by its stroke. A solid color's group is named by the swatch it links to (a +/// tint with its percentage) or as `#rrggbb`, and gradient fills share one group, "Gradients". The +/// groups stack in the order of the lowest item in each, and each keeps its items' order. When the +/// paths meet, an item joins its paint's last group only if no item of a later group meets it +/// (by the boxes of everything they paint, effects included, within one point); otherwise it +/// starts another group of that paint, which keeps the stacking order of those items. Items +/// farther apart can still share antialiased pixels at small view or raster-export scales, so +/// their edge pixels may change. A group among `items` (Auto Grouping) is refused. +fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result>> { + use std::collections::{BTreeMap, HashMap}; + use std::ops::Bound::{Excluded, Unbounded}; + use vectorcraft_color::tint_percent; + // Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a + // gradient, or another paint (its label). The name is apart from it: a swatch may be named as + // a color's `#rrggbb` or as "Gradients". + type PaintKey = (u8, String, u32); + struct Run { + name: String, + members: Vec>, + } + // Boxes closer than a point count as overlapping: their antialiased edges can share pixels. + let meet = |a: &Rect, b: &Rect| { + #[cfg(test)] + BOX_TESTS.with(|n| n.set(n.get() + 1)); + let b = b.inflate(1.0, 1.0); + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 + }; + // Choose a cell size from the painted extent and path count. A box over more than eight cells + // across but at most eight down (a stripe) is indexed by its rows, one the other way round by + // its columns, and one over more than eight both ways stays in a separate list, keeping the + // index bounded even when a path covers most of the document. + let items: Vec<_> = items + .into_iter() + .map(|item| { + let bounds = vectorcraft_render::painted_bounds(d, &item); + (item, bounds) + }) + .collect(); + let extent = items.iter().filter_map(|(_, b)| *b).reduce(|a, b| a.union(b)); + let cell_size = extent.map_or(1.0, |r| ((r.x1 - r.x0).abs() * (r.y1 - r.y0).abs() / items.len().max(1) as f64).sqrt().max(1.0)); + // The first and last column and row of the cells `r` covers; `None` when it isn't finite. + let span = |r: Rect| -> Option<(i64, i64, i64, i64)> { + let coord = |v: f64| (v / cell_size).floor() as i64; + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then(|| (coord(r.x0), coord(r.y0), coord(r.x1), coord(r.y1))) + }; + let count = |first: i64, last: i64| i128::from(last) - i128::from(first) + 1; + // A box over more cells than this is checked against every box. + const MAX_CELLS: i128 = 1024; + let mut groups: Vec = vec![]; + let mut latest: HashMap = HashMap::new(); + // An item can join an earlier group, so insertion order is not group-index order. Index + // boxes by group to skip older groups without scanning their entries. + let mut spatial: HashMap<(i64, i64), BTreeMap>> = HashMap::new(); + let mut rows: HashMap>> = HashMap::new(); + let mut columns: HashMap>> = HashMap::new(); + let mut large: BTreeMap> = BTreeMap::new(); + let mut all_boxes: BTreeMap> = BTreeMap::new(); + for (item, bounds) in items { + if matches!(item.kind, NodeKind::Group { .. }) { + return Err(bad(cmd, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + let paint = match item.appearance.fill_paint() { + Paint::None => item.appearance.stroke_paint(), + fill => fill, + }; + let (key, name) = match &paint { + Paint::Solid { swatch: Some(n), tint, .. } => { + let percent = tint_percent(*tint); + let name = if *tint < 1.0 { format!("{n} {percent}%") } else { n.clone() }; + ((1, n.clone(), tint.to_bits()), name) + } + Paint::Solid { color, .. } => ((0, color.to_hex(), 0), color.to_hex()), + Paint::Gradient(_) => ((2, String::new(), 0), "Gradients".to_string()), + other => ((3, other.label(), 0), other.label()), + }; + // Joining an earlier group moves the item under every later group's items. + let hidden_by_later = |at: usize| { + bounds.is_some_and(|b| { + let overlaps_later = |boxes: &BTreeMap>| { + boxes.range((Excluded(at), Unbounded)).rev().any(|(_, group_boxes)| { + group_boxes.iter().rev().any(|box_bounds| { + #[cfg(test)] + CANDIDATE_VISITS.with(|n| n.set(n.get() + 1)); + meet(box_bounds, &b) + }) + }) + }; + match span(b) { + Some((x0, y0, x1, y1)) if count(x0, x1) * count(y0, y1) <= MAX_CELLS => { + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).filter_map(|c| spatial.get(&c)).any(overlaps_later) + || (y0..=y1).filter_map(|y| rows.get(&y)).any(overlaps_later) + || (x0..=x1).filter_map(|x| columns.get(&x)).any(overlaps_later) + || overlaps_later(&large) + } + _ => overlaps_later(&all_boxes), + } + }) + }; + let at = latest.get(&key).copied().filter(|&at| !hidden_by_later(at)); + let mut started = Run { name, members: vec![] }; + let joins = at.and_then(|at| groups.get_mut(at)); + let new = joins.is_none(); + let g = joins.unwrap_or(&mut started); + g.members.push(item); + if new { + groups.push(started); + latest.insert(key, groups.len() - 1); + } + if let Some(b) = bounds { + let index = if new { groups.len() - 1 } else { at.unwrap_or_default() }; + all_boxes.entry(index).or_default().push(b); + match span(b.inflate(1.0, 1.0)) { + Some((x0, y0, x1, y1)) if count(x0, x1) <= 8 && count(y0, y1) <= 8 => { + for place in (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))) { + spatial.entry(place).or_default().entry(index).or_default().push(b); + } + } + Some((_, y0, _, y1)) if count(y0, y1) <= 8 => { + for y in y0..=y1 { + rows.entry(y).or_default().entry(index).or_default().push(b); + } + } + Some((x0, _, x1, _)) if count(x0, x1) <= 8 => { + for x in x0..=x1 { + columns.entry(x).or_default().entry(index).or_default().push(b); + } + } + _ => large.entry(index).or_default().push(b), + } + } + } + Ok(groups + .into_iter() + .map(|run| { + let mut g = Node::group(d.alloc_id(), run.members); + g.name = Some(run.name); + Arc::new(g) + }) + .collect()) +} + /// What a trace depends on: the object traced (an image, or an Image Trace object traced again), /// its image, and the settings it is traced with. The tracer makes the same paths of the same /// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own. @@ -398,6 +556,10 @@ struct Prepared { fn prepare(s: &Session, c: &str, p: &Value) -> Result { let mut params = trace_params(s, c, p)?; let view = view_param(c, p)?; + let by_color = c == "imageTrace.makeAndExpand" && by_color_param(c, p)?; + if by_color && params.auto_grouping { + return Err(bad(c, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; // The preset's name if the parameters are exactly that preset's or its enhanced version's (a // saved preset's under its saved name), else "Custom". @@ -428,6 +590,9 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { }) .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? }; + if by_color && s.doc()?.doc.node(target).is_some_and(vectorcraft_render::effects::has_pathfinder) { + return Err(bad(c, LIVE_PATHFINDER_BY_COLOR)); + } let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?; let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; @@ -533,6 +698,7 @@ impl TraceRun { pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?; + let group_by_color = expand && by_color_param(c, p)?; // A saved preset's settings join the journal entry, so a replay traces as this run did after // the preset is changed, renamed or deleted. let named = str_param(p, "preset").unwrap_or("Default"); @@ -608,6 +774,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Result { } pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.expand"; + let by_color = by_color_param(C, p)?; let targets = trace_targets(s, p)?; + if by_color { + // A trace made with Auto Grouping is refused even when none of its paths were grouped. + let d = &s.doc()?.doc; + let grouped = |n: &Node| n.extras().trace.as_deref().and_then(|t| t.pointer("/params/autoGrouping")).and_then(Value::as_bool) == Some(true); + if targets.iter().any(|g| d.node(*g).is_some_and(grouped)) { + return Err(bad(C, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + if targets.iter().any(|g| d.node(*g).is_some_and(vectorcraft_render::effects::has_pathfinder)) { + return Err(bad(C, LIVE_PATHFINDER_BY_COLOR)); + } + } s.edit("Expand Image Trace", |d, sel| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; @@ -715,6 +897,15 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } + if by_color { + let items = d.node(*g).and_then(Node::children).cloned().unwrap_or_default(); + // The paths may have been moved or reshaped since the trace was made, so any trace's + // paths are taken to overlap. + let groups = color_groups(C, d, items)?; + if let Some(ch) = d.node_mut(*g).and_then(Node::children_mut) { + *ch = groups; + } + } } sel.set(targets.iter().copied()); Ok(()) @@ -722,6 +913,160 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&targets) })) } +#[cfg(test)] +mod color_group_tests { + use super::*; + use vectorcraft_geom::{PathData, SubPath}; + + fn area(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64, color: Color) -> Arc { + let path = PathData::single(SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(Paint::solid(color)))) + } + + #[test] + fn abutting_color_groups_preserve_order_at_shared_edges() { + let mut d = Document::new(100.0, 100.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = vec![area(&mut d, 0.0, 0.0, 20.0, 20.0, a), area(&mut d, 20.0, 0.0, 40.0, 20.0, b), area(&mut d, 40.0, 0.0, 60.0, 20.0, a)]; + let original: Vec<_> = items.iter().map(|n| n.id).collect(); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec<_> = groups.iter().flat_map(|g| g.children().unwrap().iter().map(|n| n.id)).collect(); + assert_eq!(actual, original, "shared-edge paths changed stacking order"); + } + + #[test] + fn dense_color_groups_use_bounded_box_tests() { + let mut d = Document::new(200.0, 200.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = (0..4000) + .map(|i| { + if i % 2 == 0 { + let x = 100.0 + (i % 40) as f64 * 0.01; + area(&mut d, x, 100.0, x + 0.1, 100.1, a) + } else { + let x = if i % 4 == 1 { 0.0 } else { 198.0 }; + area(&mut d, x, x, x + 2.0, x + 2.0, b) + } + }) + .collect(); + BOX_TESTS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let tests = BOX_TESTS.with(std::cell::Cell::get); + assert_eq!(groups.iter().map(|g| g.children().map_or(0, Vec::len)).sum::(), 4000); + assert!(tests < 4000 * 32, "{tests} box tests for 4000 paths"); + } + + #[test] + fn overlapping_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 3000; + let mut d = Document::new(100.0, 100.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + let items = (0..COUNT).map(|i| area(&mut d, 10.0, 10.0, 20.0, 20.0, colors[i % 2])).collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), COUNT); + assert!(visits < COUNT * 16, "{visits} candidate visits for {COUNT} paths"); + } + + #[test] + fn separate_stripes_of_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 2000; + let mut d = Document::new(1000.0, 4000.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + for tall in [false, true] { + let items = (0..COUNT) + .map(|i| { + let at = i as f64 * 2.0; + if tall { area(&mut d, at, 0.0, at + 0.5, 1000.0, colors[i % 2]) } else { area(&mut d, 0.0, at, 1000.0, at + 0.5, colors[i % 2]) } + }) + .collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), 2, "tall {tall}"); + assert!(visits < COUNT * 32, "tall {tall}: {visits} candidate visits for {COUNT} stripes"); + } + } + + #[test] + fn spatial_color_groups_match_simple_scan() { + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]; + for layout in 0..6 { + let mut d = Document::new(200.0, 200.0); + let items: Vec<_> = (0..90) + .map(|i| { + let x = match layout { + 0 => i as f64 * 2.0, + 1 => (i % 10) as f64 * 5.0, + 2 => (i % 7) as f64 * 3.0, + 3 => (i % 13) as f64 * 9.0, + 4 => (i % 4) as f64 * 10.0, + _ => i as f64 * 1.5, + }; + let y = match layout { + 0 => 0.0, + 1 => (i / 10) as f64 * 5.0, + 2 => (i / 7) as f64 * 3.0, + 3 => (i / 13) as f64 * 9.0, + 4 => i as f64 * 1.5, + _ => (i % 4) as f64 * 10.0, + }; + // Layouts 4 and 5: wide and tall stripes, some meeting their neighbors. + let (w, h) = match layout { + 4 => (150.0, 1.0), + 5 => (1.0, 150.0), + _ => (3.0, 3.0), + }; + area(&mut d, x, y, x + w, y + h, colors[i % 3]) + }) + .collect(); + let mut expected: Vec<(String, Vec<(NodeId, Rect)>)> = vec![]; + for item in &items { + let key = item.appearance.fill_paint().color().unwrap().to_hex(); + let bounds = vectorcraft_render::painted_bounds(&d, item).unwrap(); + let previous = expected.iter().rposition(|(k, _)| *k == key); + let can_join = previous.filter(|&at| { + !expected.iter().skip(at + 1).any(|(_, boxes)| { + boxes.iter().any(|(_, m)| { + let b = bounds.inflate(1.0, 1.0); + m.x0 < b.x1 && b.x0 < m.x1 && m.y0 < b.y1 && b.y0 < m.y1 + }) + }) + }); + match can_join.and_then(|at| expected.get_mut(at)) { + Some((_, members)) => members.push((item.id, bounds)), + None => expected.push((key, vec![(item.id, bounds)])), + } + } + let actual = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec> = actual.iter().map(|g| g.children().unwrap().iter().map(|n| n.id).collect()).collect(); + let expected: Vec> = expected.into_iter().map(|(_, members)| members.into_iter().map(|(id, _)| id).collect()).collect(); + assert_eq!(actual, expected, "layout {layout}"); + } + } + + #[test] + fn an_abutting_gradient_stays_above_the_solid_area_it_borders() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops.clone())))); + let mut d = Document::new(100.0, 100.0); + let mut area = |x0: f64, x1: f64, paint: Paint| { + let path = + PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 60.0), Point::new(x0, 60.0)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(paint))) + }; + // A gradient, a solid area beside it and a second gradient beside that, bottom to top. + let items = vec![area(0.0, 50.0, ramp()), area(50.0, 80.0, Paint::solid(Color::rgb(0.2, 0.6, 0.2))), area(80.0, 100.0, ramp())]; + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let names: Vec<_> = groups.iter().map(|g| g.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Gradients", "#339933", "Gradients"]); + } +} + #[cfg(test)] mod trace_limit_tests { use super::{MAX_TRACE_ANCHORS, trace_warnings}; diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 9e0841de6..d54238925 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Move in Perspective", [], None, - "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it", + "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction, and moves every object on that plane, at any depth, by the same plane offset; Transform Again repeats it", has_doc, persp_move ), diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 786d0d956..a561e5099 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -285,7 +285,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let constrain = switch_or(p, "constrain", false, C)?; // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. check_switches(p, &["snap"], C)?; - // Snap to Grid: a correction for the objects on the first one's plane and depth. + // Shift: the constrained end point, read on the first object's plane and depth. let snap_to = if constrain && !perp { let doc = &s.doc()?.doc; let g = grid_of(doc); @@ -300,11 +300,20 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { } else { to }; - let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) }; - let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| { + // With Snap to Grid, every object on the first one's plane, at any depth, moves by the first + // one's plane-space offset and its correction, so the objects keep their spacing on the plane. + let fix = if perp { + None + } else { + let doc = &s.doc()?.doc; + crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to)) + .and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction))) + }; + let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| { + let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1); let (direction, dv) = if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) }; - dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| { + dv + fix.filter(|f| f.0 == pl).map_or(vectorcraft_geom::Vec2::ZERO, |f| { if !constrain { return f.2; } @@ -325,7 +334,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) } else { - let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); + let dv = snapped(pl, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) } }; @@ -336,7 +345,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { if perp { g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9) } else { - g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) + g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) } }; let first_still = if copy { None } else { unmoved(s, &ids, still)? }; @@ -350,9 +359,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let again = match perp { _ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})), true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), - false => { - g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) - } + false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, dv))), "copy": copy})), }; if let Some(a) = again { record_again(s, a)?; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 260b981d2..972fd8d1a 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", has_doc, info ), @@ -130,13 +130,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects and the rows of its Repeat objects. +/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the +/// rows of its objects filled or stroked with a pattern. fn sections( d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], repeats: &[(String, String)], + pattern_objects: &[(String, String)], category: Option<&str>, ) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { @@ -180,7 +182,7 @@ fn sections( "graphicStyles" => names("graphicStyleNames"), "brushes" => names("brushNames"), "spotColors" => names("spotColors"), - "patterns" => names("patternNames"), + "patterns" => pattern_objects.to_vec(), "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), @@ -252,12 +254,67 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result { Ok(v["text"].as_str().unwrap_or_default().to_string()) } -fn paint_kind(p: &Paint) -> Option<&'static str> { - match p { - Paint::Gradient(_) => Some("gradients"), - Paint::Pattern { .. } => Some("patterns"), - _ => None, +/// The type object `n` holds and the runs its layout draws, when it uses a pattern: the runs with a +/// glyph outline (list markers and ruby included) or an underline or strikethrough bar. Spaces and +/// line breaks draw no outline. A type object's characters carry their own fill and stroke, and the +/// type object's own fills and strokes paint on its characters, so only those runs paint. The type +/// is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { + let NodeKind::Text(t) = &n.kind else { return None }; + let is_pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let own = n.appearance.items.iter().any(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => is_pattern(&f.paint), + vectorcraft_doc::AppearanceItem::Stroke(s) => is_pattern(&s.paint), + }); + if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) { + return None; } + let db = vectorcraft_text::FontDb::global(); + let resolved = d.inline_resolved(t); + let lay = vectorcraft_text::layout(db, &resolved); + let mut painted: BTreeSet = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); + painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); + Some((t, painted)) +} + +/// The patterns `n`'s own fills and strokes paint, each once. A hidden fill or stroke and a stroke +/// with no width paint nothing, and neither does any fill or stroke of a guide (drawn in the guide +/// color), of a compound path's member (`members`: the compound path's own fills and strokes paint +/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint +/// only where an effect gives them a shape. +fn own_patterns<'a>( + n: &'a Node, + members: &HashSet<*const Node>, + painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, +) -> Vec<&'a str> { + if matches!(n.kind, NodeKind::Path { guide: true, .. }) + || members.contains(&std::ptr::from_ref(n)) + || painted_runs.is_some_and(|(_, r)| r.is_empty()) + { + return vec![]; + } + // The patterns an image's shaped fills and strokes paint (their effects can transform the + // pattern, so they are matched by name). + let shaped: Option> = matches!(n.kind, NodeKind::Image(_)).then(|| { + vectorcraft_render::effects::image_paint(n) + .into_iter() + .filter_map(|(_, _, item)| match item.paint() { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }) + .collect() + }); + let mut out: Vec<&str> = vec![]; + for item in &n.appearance.items { + let Paint::Pattern { pattern, .. } = item.paint() else { continue }; + let paints = item.visible() + && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) + && shaped.as_ref().is_none_or(|s| s.contains(pattern)); + if paints && !out.contains(&pattern.as_str()) { + out.push(pattern); + } + } + out } fn info(s: &mut Session, p: &Value) -> Result { @@ -293,9 +350,19 @@ fn info(s: &mut Session, p: &Value) -> Result { // `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order. let mut repeats: Vec = vec![]; let mut repeat_rows: Vec<(String, String)> = vec![]; + // `pattern_objects` and `pattern_rows` hold the `patternObjects` entries and the rows of the objects filled or stroked with a pattern, in walk order. + let mut pattern_objects: Vec = vec![]; + let mut pattern_rows: Vec<(String, String)> = vec![]; // The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once. // The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two. let mut seen: HashSet<*const Node> = HashSet::new(); + // The members of the document's compound paths: the compound path's own fills and strokes paint them. + let mut members: HashSet<*const Node> = HashSet::new(); + d.walk(|n| { + if let NodeKind::Compound { children, .. } = &n.kind { + members.extend(children.iter().map(|c| std::ptr::from_ref(&**c))); + } + }); for r in roots { r.walk(&mut |n| { if !seen.insert(std::ptr::from_ref(n)) { @@ -345,16 +412,18 @@ fn info(s: &mut Session, p: &Value) -> Result { } // The paint kinds this object's fills and strokes use, each once. let mut kinds = BTreeSet::new(); + // The patterns this object's fills and strokes use, each once. + let mut used_patterns: Vec<&str> = vec![]; + let painted_runs = painted_runs(d, n); + for pattern in own_patterns(n, &members, painted_runs.as_ref()) { + patterns.insert(pattern.to_string()); + used_patterns.push(pattern); + } for item in &n.appearance.items { - let paint = match item { - vectorcraft_doc::AppearanceItem::Fill(f) => &f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &s.paint, - }; - if let Some(k) = paint_kind(paint) { - kinds.insert(k); - } - if let Paint::Pattern { pattern, .. } = paint { - patterns.insert(pattern.clone()); + let paint = item.paint(); + // Objects counts an object with a pattern when Pattern Objects lists it, below. + if let Paint::Gradient(_) = paint { + kinds.insert("gradients"); } if let Paint::Solid { swatch: Some(name), .. } = paint && d.swatch(name).is_some_and(|s| s.spot) @@ -362,6 +431,28 @@ fn info(s: &mut Session, p: &Value) -> Result { spot.insert(name.clone()); } } + // The characters' own paints: an inline graphic draws no outline and does not take its + // run's paint (a list marker set in its run does), and a stroke with no width paints + // nothing. + if let Some((t, painted)) = &painted_runs { + for run in painted.iter().filter_map(|i| t.runs.get(*i)) { + let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); + for paint in std::iter::once(&run.style.fill).chain(stroke) { + if let Paint::Pattern { pattern, .. } = paint { + patterns.insert(pattern.clone()); + if !used_patterns.contains(&pattern.as_str()) { + used_patterns.push(pattern); + } + } + } + } + } + if !used_patterns.is_empty() { + let name = n.display_name(); + pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns })); + pattern_rows.push((name, used_patterns.join(", "))); + kinds.insert("patterns"); + } for k in kinds { *counts.entry(k).or_default() += 1; } @@ -412,10 +503,11 @@ fn info(s: &mut Session, p: &Value) -> Result { "paragraphStyles": d.para_styles.len(), "patterns": d.patterns.len(), "patternNames": patterns, + "patternObjects": pattern_objects, }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -634,6 +726,120 @@ mod tests { assert_eq!(i["sections"][0]["rows"][0], json!(["", "Grid Repeat, 500 rows × 500 columns, 125000 instances"])); } + /// Document Info › Pattern Objects lists each object filled or stroked with a pattern once, + /// with the patterns it uses, one in a group and a type object whose characters use a pattern + /// included; a pattern no art uses adds no row, and Objects counts the listed objects. With + /// Selection Only it lists the patterned objects in the selection, and the report writes the + /// same rows. + #[test] + fn info_lists_the_pattern_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let tile = square(&mut s, 0.0); + s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap(); + let spare = square(&mut s, 20.0); + s.execute("object.pattern.make", &json!({"ids": [spare], "name": "Unused", "edit": false})).unwrap(); + let (a, b, c) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0)); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // A type object whose characters are filled with the pattern. + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + // A hidden pattern fill and a pattern stroke with no width paint nothing, so neither object is listed. + let (d, e) = (square(&mut s, 100.0), square(&mut s, 200.0)); + s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // A type object with no characters paints nothing either. + let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap(); + // Its own fill (a copy of its characters' pattern fill) paints on its characters, so it paints nothing. + s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap(); + // Nor does type of only spaces and a line break, or a guide made from a patterned path. + let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Dots"})).unwrap(); + let guide = square(&mut s, 300.0); + s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [guide]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + // Nor does a compound path's member's own pattern fill: the compound path's own fill paints it. + let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap(); + let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone(); + let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance); + // Nor does an image's pattern fill without an effect that gives it a shape. + let pic = square(&mut s, 360.0); + s.execute("select.set", &json!({"ids": [pic]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let image = s.doc().unwrap().selection.objects[0]; + s.edit("own paint", |d, _| { + d.node_mut(member).unwrap().appearance = paint.clone(); + d.node_mut(image).unwrap().appearance = paint; + Ok(()) + }) + .unwrap(); + // Underlined spaces draw a bar in the pattern, so that type object is listed. + let bar = s.execute("text.create", &json!({"x": 0, "y": 340, "text": " "})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [bar], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [bar]})).unwrap(); + s.execute("text.setFormat", &json!({"underline": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": bar, "name": "Bar"})).unwrap(); + // A list marker set in an inline graphic's run draws in that run's pattern, so that type object is listed. + s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 10, "height": 10})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let listed = s.execute("text.create", &json!({"x": 0, "y": 360, "text": ""})).unwrap()["id"].clone(); + s.execute("text.insertInline", &json!({"id": listed, "at": 0, "symbol": "Mark"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [listed], "list": "bullet"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [listed], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": listed, "name": "Listed"})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + i["patternObjects"], + json!([ + {"id": a, "name": "Both", "patterns": ["Dots"]}, + {"id": b, "name": "", "patterns": ["Dots"]}, + {"id": t, "name": "Type", "patterns": ["Dots"]}, + {"id": bar, "name": "Bar", "patterns": ["Dots"]}, + {"id": listed, "name": "Listed", "patterns": ["Dots"]}, + ]) + ); + assert_eq!(i["patternNames"], json!(["Dots"])); + // Objects counts the objects that Pattern Objects lists. + assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]); + s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]); + let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("patterns"), Some("Pattern Objects"))); + assert_eq!( + i["sections"][0]["rows"], + json!([["Both", "Dots"], ["", "Dots"], ["Type", "Dots"], ["Bar", "Dots"], ["Listed", "Dots"]]) + ); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Dots"]])); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["patternNames"].clone()), (json!([["Type", "Dots"]]), json!(["Dots"]))); + let text = s.execute("document.info", &json!({"category": "patterns", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nPATTERN OBJECTS\nBoth: Dots\n: Dots\nType: Dots\nBar: Dots\nListed: Dots\n"), "{text}"); + assert!(!text.contains("Unused"), "{text}"); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 4e3cec17a..eb782e7fd 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -36,6 +36,7 @@ mod importer; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +mod heifread; mod jp2read; mod metafile; mod naming; @@ -57,6 +58,7 @@ pub mod zip; use serde_json::{Value, json}; +pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; @@ -206,6 +208,8 @@ mod tests_psd; #[cfg(test)] mod tests_psdread; +#[cfg(test)] +mod tests_heifread; #[cfg(test)] mod tests_jp2read; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 5e2178cdc..c89ecbd4f 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -95,11 +95,93 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { }; match source { "folder" => batch_folder(s, C, p, override_open, &run), - "datasets" => batch_datasets(s, C, &run), + "datasets" => { + let one_file = batch_overwrites_one_file(s, p); + let mut r = batch_datasets(s, C, &run)?; + if one_file { + r["warning"] = json!(ONE_FILE_WARNING); + } + Ok(r) + } _ => Err(bad(C, "`source` must be `folder` or `datasets`")), } } +/// The `warning` of a Data Sets batch for which [`batch_overwrites_one_file`] holds. +#[cfg(not(target_arch = "wasm32"))] +const ONE_FILE_WARNING: &str = "each data set saves over the same file, which keeps only the last data set"; + +/// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and +/// writes each data set to the same file. Save and Close saves the open document over its own file +/// after every data set, unless a Save step that Override Save points into `destinationFolder` +/// first makes each data set's file the document's own. A step that the batch does not point into `destinationFolder` writes the +/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export, +/// Export Selection, PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a +/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not +/// converted from an older version or restored by Data Recovery) and the step gives no `format` or +/// the document's own; an Export for Screens step whose `folder`, given or from its +/// `exportPreset`, names a folder, whatever the overrides. +/// Other steps return their data instead of writing a file. +pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { + if p.get("source").and_then(Value::as_str) != Some("datasets") { + return false; + } + let folder = p.get("destinationFolder").and_then(Value::as_str).is_some(); + let redirected = |key: &str| folder && p.get(key).and_then(Value::as_bool).unwrap_or(true); + let (save, export) = (redirected("overrideSave"), redirected("overrideExport")); + let steps = p.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") && !saves_move_to_each_file(steps, save) { + return true; + } + // The format of the open document's own file, which a Save step without a `path` writes. + let own_format = s.active().filter(|st| st.path.is_some() && !st.converted && !st.recovered).map(|st| st.format); + let names = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty()); + steps.iter().any(|c| { + let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())); + let path = names(params.get("path")); + let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { + None => own_format.is_some(), + Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)), + }; + match c.get("command").and_then(Value::as_str).unwrap_or("") { + "document.save" | "file.save" => !save && (path || own_file()), + "file.saveAs" => !save && path, + "file.saveCopy" | "file.saveAsTemplate" => path, + "document.export" | "document.exportSelection" => !export && path, + // The batch does not point these exports into `destinationFolder`. + "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, + // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. + "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), + _ => false, + } + }) +} + +/// Whether `steps` hold a Save, Save As or `document.save` step that Override Save points at each +/// data set's own file in the destination folder (`redirected`). Such a step makes that file the +/// document's own, so Save and Close then saves each data set to its own file. +fn saves_move_to_each_file(steps: &[Value], redirected: bool) -> bool { + redirected && steps.iter().any(|c| retargeting_save(c, true)) +} + +/// Whether step `c` is a Save, Save As or `document.save` that writes a file (its own `path`, or one +/// in the destination folder when `redirected`) and makes that file the document's own: a save in +/// the native template format writes a copy and leaves the document's file as it was. +fn retargeting_save(c: &Value, redirected: bool) -> bool { + if !matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs")) { + return false; + } + let params = c.get("params"); + let path = params.and_then(|p| p.get("path")).and_then(Value::as_str).filter(|p| !p.is_empty()); + let format_ok = match params.and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()) { + Some(f) => super::format(f).is_some_and(|f| f.id != "template"), + // Override Save gives the step a native file name; else its own path names the format. + None if redirected => true, + None => path.and_then(super::format_for_name).is_none_or(|f| f.id != "template"), + }; + format_ok && (redirected || path.is_some()) +} + #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::{absolute_path, may_read}; @@ -145,6 +227,18 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { if names.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); } + let redirected = run.override_save && run.dest_folder.is_some(); + // A recorded save with a file to write gives the document a file of its own before Save and Close. + if run.destination == "saveAndClose" && !run.steps.iter().any(|c| retargeting_save(c, redirected)) { + if st.path.is_none() { + return Err(bad(cmd, "the document has never been saved")); + } + // `save_plan` writes no file for a document converted from an older version or restored by + // Data Recovery. + if st.converted || st.recovered { + return Err(bad(cmd, "save the document with Save As before Save and Close")); + } + } let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?; let steps = filter_steps(run.steps, true, false); @@ -1292,6 +1386,213 @@ mod tests { assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}"); } + /// A saved document with a text object bound to `Name` and two data sets, `A` and `B`. + fn saved_with_two_data_sets(path: &std::path::Path) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap(); + s.execute("dataset.capture", &json!({"name": "A"})).unwrap(); + s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap(); + s.execute("dataset.capture", &json!({"name": "B"})).unwrap(); + s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s + } + + fn save_and_close_over_data_sets(s: &mut Session) -> Result { + s.execute( + "action.batch", + &json!({"source": "datasets", "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}), + ) + } + + #[test] + fn batch_save_and_close_over_data_sets_saves_each_over_the_template_and_warns() { + let path = tmp("save-close-sets").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + std::fs::write(&path, b"stale").unwrap(); + let r = save_and_close_over_data_sets(&mut s).unwrap(); + assert_eq!((r["count"].as_u64(), r["failed"].as_u64()), (Some(2), Some(0)), "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + assert_ne!(std::fs::read(&path).unwrap(), b"stale", "the template's file is saved over"); + assert_eq!(file_names(path.parent().unwrap()), ["template.vectorcraft"], "{r}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset.as_deref(), Some("B"), "the last data set is applied"); + assert_eq!(st.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the template stays open"); + assert!(!st.is_dirty(), "the last data set is saved"); + } + + #[test] + fn batch_warns_when_a_recorded_save_writes_one_file_for_every_data_set() { + let path = tmp("recorded-save-sets").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let save = json!([{"command": "document.save", "params": {}}]); + let r = s.execute("action.batch", &json!({"source": "datasets", "commands": save})).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + let dest = tmp("recorded-save-dest"); + let p = json!({"source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(), "commands": save}); + let r = s.execute("action.batch", &p).unwrap(); + assert!(r.get("warning").is_none(), "Override Save points each save into the folder: {r}"); + let unlock = json!({"source": "datasets", "commands": [{"command": "object.unlockAll", "params": {}}]}); + let r = s.execute("action.batch", &unlock).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + } + + #[test] + fn batch_save_and_close_after_a_redirected_save_writes_each_data_set_to_its_own_file() { + let save_close = |dest: &std::path::Path| { + json!({ + "source": "datasets", + "destination": "saveAndClose", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.save", "params": {}}] + }) + }; + let path = tmp("redirected-save-close").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let bytes = std::fs::read(&path).unwrap(); + let dest = tmp("redirected-save-close-dest"); + let p = save_close(&dest); + let mut kept = p.clone(); + kept["overrideSave"] = json!(false); + assert!(batch_overwrites_one_file(&s, &kept), "without Override Save the save stays on the template"); + let r = s.execute("action.batch", &p).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert_eq!(file_names(&dest), ["template_A.vectorcraft", "template_B.vectorcraft"], "{r}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template's file stays as it was"); + // A document never saved gets its files from the redirected save too. + let mut unsaved = with_data_sets(&["a", "b"]); + let dest = tmp("redirected-save-close-unsaved"); + let r = unsaved.execute("action.batch", &save_close(&dest)).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a.vectorcraft", "Untitled_b.vectorcraft"], "{r}"); + // A template save writes a copy: Save and Close still saves over the document's own file. + let mut template = save_close(&dest); + template["commands"] = json!([{"command": "document.save", "params": {"format": "template"}}]); + assert!(batch_overwrites_one_file(&s, &template), "{template}"); + let mut never = with_data_sets(&["a", "b"]); + let msg = never.execute("action.batch", &template).unwrap_err().to_string(); + assert!(msg.ends_with("the document has never been saved"), "{msg}"); + // A recorded Save As with its own path gives a never-saved document its file, the same one + // for every data set. + let one = tmp("save-as-then-close").join("one.vectorcraft"); + let p = json!({ + "source": "datasets", + "destination": "saveAndClose", + "commands": [{"command": "file.saveAs", "params": {"path": one.to_string_lossy()}}] + }); + let r = never.execute("action.batch", &p).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + assert!(one.exists(), "{r}"); + } + + #[test] + fn one_file_rule_covers_save_and_close_and_steps_left_in_place() { + let saved = saved_with_two_data_sets(&tmp("one-file-rule").join("template.vectorcraft")); + let unsaved = with_data_sets(&["a"]); + let with = |extra: Value| { + let mut p = json!({"source": "datasets", "commands": [{"command": "document.export", "params": {"path": "/x/a.png"}}]}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + batch_overwrites_one_file(&saved, &p) + }; + assert!(with(json!({})), "an export with its own path"); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out"}))); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false}))); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "overrideSave": false}))); + assert!(!with(json!({"source": "folder"})), "a folder source opens a file per pass"); + assert!(with(json!({"destination": "saveAndClose", "commands": [{"command": "object.unlockAll"}]}))); + let saved_as = json!({"commands": [{"command": "file.saveAs", "params": {"path": "/x/a.svg"}}]}); + assert!(with(saved_as.clone())); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": saved_as["commands"]}))); + assert!(!with(json!({"commands": [{"command": "file.saveAs", "params": {}}]})), "no path: the data comes back"); + assert!(!with(json!({"commands": [{"command": "document.export", "params": {"format": "png"}}]})), "no path: the data comes back"); + let copy = json!([{"command": "file.saveCopy", "params": {"path": "/x/a.vectorcraft"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": copy})), "Override Save leaves copies alone"); + let save = |format: Option<&str>| { + let params = format.map_or(json!({}), |f| json!({"format": f})); + json!({"source": "datasets", "commands": [{"command": "document.save", "params": params}]}) + }; + assert!(batch_overwrites_one_file(&saved, &save(None)), "the document's own file"); + assert!(batch_overwrites_one_file(&saved, &save(Some("vectorcraft"))), "the document's own format"); + assert!(!batch_overwrites_one_file(&unsaved, &save(None)), "a document never saved has no file to write"); + assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back"); + assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back"); + let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder"); + assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]}))); + let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]); + assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back"); + for id in ["document.exportPdf", "document.exportEps", "document.exportDxf", "document.exportForWeb", "document.exportForOffice"] { + let step = json!([{"command": id, "params": {"path": "/x/a.out"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": step})), "{id} keeps its path"); + assert!(!with(json!({"commands": [{"command": id, "params": {}}]})), "{id} without a path: the data comes back"); + } + } + + #[test] + fn batch_refuses_save_and_close_over_data_sets_of_a_converted_or_recovered_document() { + for converted in [true, false] { + let path = tmp(if converted { "converted-sets" } else { "recovered-sets" }).join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let bytes = std::fs::read(&path).unwrap(); + let (active, revision) = { + let st = s.doc_mut().unwrap(); + if converted { + st.converted = true; + } else { + st.recovered = true; + } + (st.doc.variables.active_dataset.clone(), st.revision) + }; + let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); + assert!(msg.ends_with("save the document with Save As before Save and Close"), "{msg}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the file is not written"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); + assert_eq!(st.revision, revision, "no step runs"); + } + } + + #[test] + fn batch_refuses_save_and_close_over_data_sets_of_an_unsaved_document() { + let mut s = with_data_sets(&["a", "b"]); + let (active, revision) = { + let st = s.doc().unwrap(); + (st.doc.variables.active_dataset.clone(), st.revision) + }; + let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); + assert!(msg.ends_with("the document has never been saved"), "{msg}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); + assert_eq!(st.revision, revision, "no step runs"); + assert!(st.path.is_none()); + } + + #[test] + fn batch_save_and_close_saves_and_closes_each_source_file() { + let src = tmp("save-close-src"); + let a = write_svg(&src, "a.svg", "#f00"); + let before = std::fs::read(&a).unwrap(); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({"folder": src.to_string_lossy(), "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_ne!(std::fs::read(&a).unwrap(), before, "the source file is saved over"); + assert_eq!(file_names(&src), ["a.svg"], "{r}"); + assert!(s.doc().is_err(), "the source file is closed"); + } + #[test] fn batch_refuses_nesting_and_empty_commands() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ec7e303c6..415b69642 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 7d646afd6..3c52d5de8 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -503,6 +503,8 @@ pub const FORMATS: &[Format] = &[ reader("pxr", "Pixar", &["pxr"], "image/x-pixar", true), // JP2 and raw codestreams are decoded to PNG; JPX composition is not supported. reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true), + // HEVC-coded HEIF still images, decoded to PNG; image sequences are not read. + reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -537,6 +539,8 @@ pub const OPEN_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", @@ -554,11 +558,13 @@ pub const OPEN_EXTS: &[&str] = &[ /// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to /// raster editors, so Finder and file managers don't offer a vector app for them, and there is no -/// `.epsf`, `.ps`, Targa, PCX, Pixar or JPEG 2000 type, nor one for the `.pns`, `.dib` and `.rle` names of +/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. -pub const UNASSOCIATED_EXTS: &[&str] = - &["psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"]; +pub const UNASSOCIATED_EXTS: &[&str] = &[ + "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", + "heic", "heif", +]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares /// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order. @@ -621,6 +627,8 @@ pub const PLACE_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", diff --git a/crates/engine/src/cmd/fileio/heifread.rs b/crates/engine/src/cmd/fileio/heifread.rs new file mode 100644 index 000000000..4867022de --- /dev/null +++ b/crates/engine/src/cmd/fileio/heifread.rs @@ -0,0 +1,627 @@ +//! HEIC and HEIF images for File › Open and File › Place, decoded with heic-rs when the engine's +//! `heif` feature is on (the default). The image is bounded before decoding and converted to RGBA +//! for the common PNG storage path. A build without the feature still recognizes the files and +//! refuses them with a message. + +#[cfg(all(test, feature = "heif"))] +pub(super) use decoder::{active_sps, picture_bytes, sps_signal, sps_size}; +#[cfg(feature = "heif")] +pub(super) use decoder::{decode, quarter_turned, with_exif}; + +/// The brands heic-rs 0.1.4 reads: HEVC-coded images and image sequences (ISO/IEC 23008-12 annex +/// B) and the general image and sequence brands. +const BRANDS: [&[u8; 4]; 7] = [b"heic", b"heix", b"heim", b"heis", b"hevc", b"mif1", b"msf1"]; + +/// Recognize a HEIF file by its `ftyp` box: one of [`BRANDS`] as the major brand or a compatible +/// one, and no AVIF brand (`avif`, `avis`), as heic-rs refuses a file that lists one. The box is +/// read as JP2 boxes are ([`super::jp2read::boxes`]): ISO BMFF sizes, the 64-bit one included. +pub(super) fn is_heif(bytes: &[u8]) -> bool { + let Some((b"ftyp", ftyp)) = super::jp2read::boxes(bytes).next() else { return false }; + let Some(major) = ftyp.get(..4).and_then(|b| <&[u8; 4]>::try_from(b).ok()) else { return false }; + if ftyp.len() < 8 { + return false; + } + let compatible = ftyp.get(8..).unwrap_or_default().as_chunks::<4>().0; + let brands = || std::iter::once(major).chain(compatible); + !brands().any(|brand| matches!(brand, b"avif" | b"avis")) && brands().any(|brand| BRANDS.contains(&brand)) +} + +/// Without the `heif` feature every HEIF file is refused. +#[cfg(not(feature = "heif"))] +pub(super) fn decode(_bytes: &[u8], _max_alloc: u64) -> Result { + Err("HEIC and HEIF images can't be opened in this build".into()) +} + +/// Without the `heif` feature no image is decoded, so none is turned. +#[cfg(not(feature = "heif"))] +pub(super) fn quarter_turned(_bytes: &[u8]) -> bool { + false +} + +/// Without the `heif` feature no resolution is read. +#[cfg(not(feature = "heif"))] +pub(super) fn with_exif(_bytes: &[u8], _read: impl FnOnce(&[u8]) -> Option) -> Option { + None +} + +#[cfg(feature = "heif")] +mod decoder { + use heic_rs::context::Context; + use heic_rs::{DecodeOptions, Error, MatrixCoefficients, Nclx, PixelLayout, Range}; + + /// The message for a file that isn't a readable HEIF image. + const UNREADABLE: &str = "the HEIC or HEIF file can't be read"; + /// The message for an image sequence, an overlay, an image that isn't HEVC-coded, a grid whose + /// tiles are cropped, turned or mirrored, or an HEVC coding tool the decoder doesn't implement. + const UNSUPPORTED: &str = "the HEIC or HEIF file holds a kind of image that can't be read"; + + /// Decode the primary image within `max_alloc` bytes, counting the decoder's planes, the RGBA + /// output and the copy that rotating or cropping makes, with the container's rotation, mirror + /// and crop applied. An embedded RGB ICC profile, or `nclx` Display P3 primaries with the sRGB + /// curve, is converted to sRGB; other `nclx` values are read as sRGB. A panic inside the + /// decoder is reported as an unreadable file. + pub(in super::super) fn decode(bytes: &[u8], max_alloc: u64) -> Result { + crate::guard::catch_panic(|| decode_unguarded(bytes, max_alloc)).unwrap_or_else(|_| Err(UNREADABLE.into())) + } + + /// Whether the container's rotations of the primary image add up to an odd number of quarter + /// turns, so its width and height swap. The decoder applies every `irot` the item lists. + pub(in super::super) fn quarter_turned(bytes: &[u8]) -> bool { + let turned = || { + let ctx = Context::open(bytes).ok()?; + let props = ctx.props(ctx.meta.primary).ok()?; + let quarters = props.transforms.iter().filter(|t| matches!(t, heic_rs::props::Transform::Rotate(r) if r.swaps_axes())).count(); + Some(quarters % 2 == 1) + }; + crate::guard::catch_panic(turned).ok().flatten().unwrap_or(false) + } + + /// `read` of the primary image's EXIF block, a TIFF structure inside `bytes`, for its + /// resolution tags. + pub(in super::super) fn with_exif(bytes: &[u8], read: impl FnOnce(&[u8]) -> Option) -> Option { + let find = || { + let ctx = Context::open(bytes).ok()?; + ctx.exif(ctx.meta.primary).ok().flatten() + }; + crate::guard::catch_panic(find).ok().flatten().and_then(read) + } + + fn decode_unguarded(bytes: &[u8], max_alloc: u64) -> Result { + let refused = |e: Error| match e { + Error::Unsupported(_) | Error::MissingBox("hvcC" | "meta") => UNSUPPORTED.to_string(), + _ => UNREADABLE.to_string(), + }; + let info = heic_rs::probe(bytes).map_err(refused)?; + let ctx = Context::open(bytes).map_err(refused)?; + if unsupported_items(&ctx).ok_or(UNREADABLE)? { + return Err(UNSUPPORTED.into()); + } + // The stored size, before a crop: the size the decoder holds. + let too_large = || format!("an image of {} × {} pixels is too large to draw", info.coded_width, info.coded_height); + let declared = u64::from(info.coded_width).checked_mul(u64::from(info.coded_height)).ok_or_else(too_large)?; + // A grid is composed at the size its own data gives, which need not match `ispe`. + let grid = grid_pixels(&ctx).ok_or(UNREADABLE)?; + // A single picture is converted at the size its stream gives, which need not match `ispe` + // either. + let coded = declared.max(grid).max(stream_pixels(&ctx).ok_or(UNREADABLE)?); + let shown = u64::from(info.width).checked_mul(u64::from(info.height)).ok_or_else(too_large)?; + // The RGBA output, and a second RGBA image when the decoder rotates, mirrors or crops it, + // which is no larger than the first. + let output = coded.checked_mul(8).ok_or_else(too_large)?; + let pictures = picture_bytes_in(&ctx).ok_or(UNREADABLE)?; + // An alpha grid is composed into a frame of 16-bit planes (at most three samples a pixel), + // and so is the image's grid when it is decoded with a color description of its tiles or + // its stream. A single picture is converted in place. + let composed = if grid > 0 { coded.checked_mul(if info.has_alpha { 12 } else { 6 }).ok_or_else(too_large)? } else { 0 }; + if coded == 0 || shown == 0 || output.checked_add(pictures).and_then(|n| n.checked_add(composed)).is_none_or(|n| n > max_alloc) { + return Err(too_large()); + } + let max_pixels = max_alloc / 4; + let decoded = match tile_nclx(&ctx).or_else(|| stream_nclx(&ctx)) { + Some(nclx) => decode_with_nclx(&ctx, nclx, max_pixels), + None => heic_rs::decode(bytes, &DecodeOptions::default().with_layout(PixelLayout::Rgba8).with_max_pixels(Some(max_pixels))), + } + .map_err(|e| match e { + Error::PixelLimit { .. } | Error::BoxTooLarge { .. } => too_large(), + e => refused(e), + })?; + let mut img = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.data).ok_or(UNREADABLE)?; + if let Some(profile) = source_profile(&ctx) { + for pixel in img.pixels_mut() { + let [r, g, b, a] = pixel.0; + let device = [r, g, b].map(|v| f32::from(v) / 255.0); + if let Some(rgb) = profile.to_srgb_into(&device, vectorcraft_color::cms::Intent::RelativeColorimetric) { + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + pixel.0 = [r, g, b, a]; + } + } + } + Ok(img) + } + + /// The primary image and its alpha image, when it has one. + fn primary_and_alpha(ctx: &Context<'_>) -> Option> { + let primary = ctx.meta.primary; + Some(std::iter::once(primary).chain(ctx.alpha_item(primary).ok()?)) + } + + /// Whether the primary image or its alpha image is neither an HEVC-coded item (`hvc1`, `hev1`) + /// nor a grid of them, or is a grid with a tile that carries a crop, rotation or mirror. heic-rs + /// 0.1.4 decodes any item with an `hvcC` whatever its type, and composes a grid from its tiles + /// as coded, without their transforms: an encoder that pads tiles smaller than its coding block + /// (64 × 64 for x265) and crops them back with `clap` gets the wrong pixels. + fn unsupported_items(ctx: &Context<'_>) -> Option { + let hevc = |id: u32| ctx.meta.item(id).is_some_and(|info| info.is_hevc()); + for item in primary_and_alpha(ctx)? { + let Some((_, tiles)) = ctx.grid(item).ok()? else { + if !hevc(item) { + return Some(true); + } + continue; + }; + for tile in tiles { + if !hevc(tile) || !ctx.props(tile).ok()?.transforms.is_empty() { + return Some(true); + } + } + } + Some(false) + } + + /// The pixels of the larger of the primary image's grid and its alpha image's grid, at the + /// output size each grid gives; 0 when neither is a grid. + fn grid_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if let Some((grid, _)) = ctx.grid(item).ok()? { + largest = largest.max(u64::from(grid.output_width) * u64::from(grid.output_height)); + } + } + Some(largest) + } + + /// The pixels of the larger of the primary image and its alpha image when either is a single + /// picture, at the size of its largest sequence parameter set (before its conformance window); + /// 0 when both are grids. `None` when a size can't be read. + fn stream_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if ctx.grid(item).ok()?.is_some() { + continue; + } + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = ctx.props(item).ok()?.hvcc else { continue }; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, _) = sps_size(nal)?; + largest = largest.max(width.checked_mul(height)?); + } + } + Some(largest) + } + + /// The first tile's `nclx` when the primary image is a grid without an `nclx` of its own and + /// its tiles carry one. heic-rs 0.1.4 converts a grid's samples to RGB with the grid item's + /// `nclx` alone, and with its BT.709 limited-range default when the grid has none + /// (tbraun96/heic-rs#6). + fn tile_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let (_, tiles) = ctx.grid(primary).ok()??; + ctx.props(*tiles.first()?).ok()?.nclx + } + + /// The color description in the HEVC stream's video usability information (ITU-T H.265 + /// E.2.1) of the primary image, or of its grid's first tile, when neither carries an `nclx` + /// (a file whose only color box is an ICC profile, or one without a color box). heic-rs 0.1.4 + /// reads only the container's `nclx` and otherwise converts with BT.709 limited range. + fn stream_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let coded = match ctx.grid(primary).ok()? { + Some((_, tiles)) => *tiles.first()?, + None => primary, + }; + let props = ctx.props(coded).ok()?; + if props.nclx.is_some() { + return None; + } + let hvcc = props.hvcc?; + let data = ctx.item_data(coded).ok()?; + let slices = hvcc.split_nals(&data).ok()?; + active_sps(&hvcc.parameter_sets(), &slices).and_then(sps_signal) + } + + /// The SPS the first slice of `slices` uses: the one its PPS names (ITU-T H.265 7.4.3.3.1). + pub(in super::super) fn active_sps<'a>(parameter_sets: &[&'a [u8]], slices: &[&[u8]]) -> Option<&'a [u8]> { + let slice = slices.iter().find(|nal| nal_type(nal).is_some_and(|t| t < 32))?; + let bits = &mut Bits { data: &payload(slice), at: 0 }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag in an IRAP picture (types 16 + // to 23), slice_pic_parameter_set_id. + bits.skip(1)?; + if nal_type(slice).is_some_and(|t| (16..=23).contains(&t)) { + bits.skip(1)?; + } + let pps_id = bits.exp_golomb()?; + // pps_pic_parameter_set_id, pps_seq_parameter_set_id. + let pps_ids = |nal: &[u8]| { + let bits = &mut Bits { data: &payload(nal), at: 0 }; + Some((bits.exp_golomb()?, bits.exp_golomb()?)) + }; + let sps_id = parameter_sets.iter().filter(|nal| nal_type(nal) == Some(PPS)).find_map(|nal| pps_ids(nal).filter(|(id, _)| *id == pps_id))?.1; + let sps_ids = |nal: &[u8]| { + let rbsp = sps_payload(nal)?; + sps_head(&mut Bits { data: &rbsp, at: 0 }).map(|(_, id, ..)| id) + }; + parameter_sets.iter().copied().find(|nal| sps_ids(nal) == Some(sps_id)) + } + + /// The primary image decoded as heic-rs 0.1.4 decodes it (a grid's tiles one after another), + /// with its samples converted to RGBA with `nclx`, the alpha image applied and the image's + /// rotation, mirror and crop applied. + fn decode_with_nclx(ctx: &Context<'_>, nclx: Nclx, max_pixels: u64) -> heic_rs::Result { + use heic_rs::hevc::Frame; + + fn coded(ctx: &Context<'_>, id: u32) -> heic_rs::Result { + let props = ctx.props(id)?; + let hvcc = props.hvcc.as_ref().ok_or(Error::MissingBox("hvcC"))?; + let data = ctx.item_data(id)?; + let frame = heic_rs::hevc::decode_still(&hvcc.parameter_sets(), &hvcc.split_nals(&data)?)?; + frame.validate()?; + Ok(frame) + } + fn item(ctx: &Context<'_>, id: u32, max_pixels: u64) -> heic_rs::Result { + match ctx.grid(id)? { + Some((grid, tiles)) => { + // The tiles decode in parallel on native, as heic-rs's own decode does. + #[cfg(not(target_arch = "wasm32"))] + let frames = { + use rayon::prelude::*; + tiles.par_iter().map(|&t| coded(ctx, t)).collect::>>()? + }; + #[cfg(target_arch = "wasm32")] + let frames = tiles.iter().map(|&t| coded(ctx, t)).collect::>>()?; + heic_rs::grid::compose(&grid, &frames, max_pixels) + } + None => coded(ctx, id), + } + } + + let primary = ctx.meta.primary; + let props = ctx.props(primary)?; + let frame = item(ctx, primary, max_pixels)?; + let alpha = ctx.alpha_item(primary)?.map(|a| item(ctx, a, max_pixels)).transpose()?; + // `None`: rayon's global pool on native, where heic-rs is built with `parallel`. + let image = heic_rs::color::convert(&frame, alpha.as_ref(), nclx, PixelLayout::Rgba8, max_pixels, None)?; + heic_rs::transform::apply_all(image, &props.transforms) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures of `bytes` it decodes (see + /// [`picture_bytes_in`]); `None` when they can't be read. + #[cfg(test)] + pub(in super::super) fn picture_bytes(bytes: &[u8]) -> Option { + picture_bytes_in(&Context::open(bytes).ok()?) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures it decodes: the primary image's, + /// or each tile of its grid, and its alpha image's the same way. Each picture is sized by the + /// largest of its sequence parameter sets (the size the decoder allocates, rounded up to whole + /// 64-sample coding blocks), not by the container, and takes three sets of 16-bit planes: the + /// picture, a filtered copy and the frame returned. `None` when an item's sizes can't be read. + fn picture_bytes_in(ctx: &Context<'_>) -> Option { + let mut total = 0u64; + for item in primary_and_alpha(ctx)? { + let coded = match ctx.grid(item).ok()? { + Some((_, tiles)) => tiles, + None => vec![item], + }; + for id in coded { + let props = ctx.props(id).ok()?; + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = props.hvcc else { continue }; + // The slices pick their SPS through the PPS, so the largest one is counted. Every + // SPS must be read. + let mut largest = None; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, chroma) = sps_size(nal)?; + let blocks = |n: u64| n.div_ceil(64) * 64; + // Bytes a sample position takes in one set of planes: luma and the two chroma + // planes. + let planes = match chroma { + 0 => 2, + 1 => 3, + 2 => 4, + _ => 6, + }; + let picture = blocks(width).checked_mul(blocks(height))?.checked_mul(planes * 3)?; + largest = largest.max(Some(picture)); + } + total = total.checked_add(largest?)?; + } + } + Some(total) + } + + /// The NAL unit type of an HEVC sequence parameter set. + const SPS: u8 = 33; + + /// The type of an HEVC NAL unit, from its header. + fn nal_type(nal: &[u8]) -> Option { + nal.first().map(|b| (b >> 1) & 0x3f) + } + + /// The NAL unit type of an HEVC picture parameter set. + const PPS: u8 = 34; + + /// A NAL unit's payload, without its two-byte header and the emulation prevention bytes (the 03 + /// of 00 00 03), up to its first 4,096 bytes. + fn payload(nal: &[u8]) -> Vec { + let mut rbsp = Vec::with_capacity(256); + let mut zeros = 0; + for &b in nal.get(2..).unwrap_or_default().iter().take(4096) { + if b == 3 && zeros >= 2 { + zeros = 0; + continue; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + rbsp.push(b); + } + rbsp + } + + /// An SPS NAL unit's [`payload`]; `None` for another NAL unit. + fn sps_payload(nal: &[u8]) -> Option> { + (nal_type(nal) == Some(SPS)).then(|| payload(nal)) + } + + /// The fields of an SPS (ITU-T H.265 7.3.2.2) up to the picture height: + /// `sps_max_sub_layers_minus1`, `sps_seq_parameter_set_id`, the width, the height and + /// `chroma_format_idc`. + fn sps_head(bits: &mut Bits<'_>) -> Option<(u64, u64, u64, u64, u64)> { + // sps_video_parameter_set_id, sps_max_sub_layers_minus1, sps_temporal_id_nesting_flag. + bits.skip(4)?; + let sub_layers = bits.read(3)?; + bits.skip(1)?; + // profile_tier_level (7.3.3): the general profile, tier and level, then the sub-layers'. + bits.skip(96)?; + let mut present = Vec::new(); + for _ in 0..sub_layers { + present.push((bits.read(1)?, bits.read(1)?)); + } + if sub_layers > 0 { + bits.skip(2 * (8 - sub_layers as usize))?; + } + for (profile, level) in present { + bits.skip(88 * profile as usize + 8 * level as usize)?; + } + let id = bits.exp_golomb()?; + let chroma = bits.exp_golomb()?; + if chroma == 3 { + bits.skip(1)?; + } + let width = bits.exp_golomb()?; + let height = bits.exp_golomb()?; + Some((sub_layers, id, width, height, chroma)) + } + + /// The picture width, height and `chroma_format_idc` of an HEVC sequence parameter set NAL + /// unit, before its conformance window; `None` for another NAL unit. + pub(in super::super) fn sps_size(nal: &[u8]) -> Option<(u64, u64, u64)> { + let rbsp = sps_payload(nal)?; + let (_, _, width, height, chroma) = sps_head(&mut Bits { data: &rbsp, at: 0 })?; + Some((width, height, chroma)) + } + + /// The video signal type of an HEVC sequence parameter set NAL unit's VUI (ITU-T H.265 E.2.1) + /// as an `nclx`: the full-range flag and, when the VUI describes them, the color primaries, + /// transfer characteristics and matrix coefficients (otherwise 2, unspecified). `None` when the + /// SPS has no VUI, the VUI has no video signal type, or the SPS can't be read that far. + pub(in super::super) fn sps_signal(nal: &[u8]) -> Option { + let rbsp = sps_payload(nal)?; + let bits = &mut Bits { data: &rbsp, at: 0 }; + let (sub_layers, ..) = sps_head(bits)?; + skip_to_vui(bits, sub_layers)?; + // vui_parameters_present_flag, then aspect_ratio_info_present_flag (an Extended_SAR + // aspect_ratio_idc of 255 adds its 32-bit ratio) and overscan_info_present_flag. + if bits.read(1)? == 0 { + return None; + } + if bits.read(1)? == 1 && bits.read(8)? == 255 { + bits.skip(32)?; + } + if bits.read(1)? == 1 { + bits.skip(1)?; + } + // video_signal_type_present_flag: video_format, video_full_range_flag and the color + // description. + if bits.read(1)? == 0 { + return None; + } + bits.skip(3)?; + let range = if bits.read(1)? == 1 { Range::Full } else { Range::Limited }; + let (primaries, transfer, matrix) = if bits.read(1)? == 1 { (bits.read(8)?, bits.read(8)?, bits.read(8)?) } else { (2, 2, 2) }; + let code = |v: u64| u16::try_from(v).ok(); + let matrix_code = code(matrix)?; + Some(Nclx { primaries: code(primaries)?, transfer: code(transfer)?, matrix: MatrixCoefficients::from_code(matrix_code), matrix_code, range }) + } + + /// Skips the fields of an SPS after [`sps_head`], up to `vui_parameters_present_flag`. + fn skip_to_vui(bits: &mut Bits<'_>, sub_layers: u64) -> Option<()> { + // conformance_window_flag and its four offsets. + if bits.read(1)? == 1 { + for _ in 0..4 { + bits.exp_golomb()?; + } + } + // bit_depth_luma_minus8, bit_depth_chroma_minus8. + bits.exp_golomb()?; + bits.exp_golomb()?; + let poc_bits = bits.exp_golomb()?.checked_add(4).filter(|&n| n <= 16)?; + // sps_sub_layer_ordering_info_present_flag, then three values for each sub-layer given. + let ordered = if bits.read(1)? == 1 { sub_layers + 1 } else { 1 }; + for _ in 0..ordered * 3 { + bits.exp_golomb()?; + } + // The coding and transform block sizes and the transform hierarchy depths. + for _ in 0..6 { + bits.exp_golomb()?; + } + // scaling_list_enabled_flag, sps_scaling_list_data_present_flag. + if bits.read(1)? == 1 && bits.read(1)? == 1 { + skip_scaling_lists(bits)?; + } + // amp_enabled_flag, sample_adaptive_offset_enabled_flag. + bits.skip(2)?; + // pcm_enabled_flag: the PCM sample depths, block sizes and loop filter flag. + if bits.read(1)? == 1 { + bits.skip(8)?; + bits.exp_golomb()?; + bits.exp_golomb()?; + bits.skip(1)?; + } + skip_reference_picture_sets(bits)?; + // long_term_ref_pics_present_flag: each picture's POC bits and used flag. + if bits.read(1)? == 1 { + let count = bits.exp_golomb()?; + if count > 32 { + return None; + } + for _ in 0..count { + bits.skip(poc_bits as usize + 1)?; + } + } + // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag. + bits.skip(2) + } + + /// Skips `scaling_list_data()` (ITU-T H.265 7.3.4). + fn skip_scaling_lists(bits: &mut Bits<'_>) -> Option<()> { + for size in 0..4 { + // The 32 × 32 lists are given for matrices 0 and 3 only. + for _ in 0..if size == 3 { 2 } else { 6 } { + if bits.read(1)? == 0 { + // scaling_list_pred_matrix_id_delta. + bits.exp_golomb()?; + } else { + // scaling_list_dc_coef_minus8, then each scaling_list_delta_coef (se(v) codes, + // as long as ue(v) codes). + if size > 1 { + bits.exp_golomb()?; + } + for _ in 0..if size == 0 { 16 } else { 64 } { + bits.exp_golomb()?; + } + } + } + } + Some(()) + } + + /// Skips `num_short_term_ref_pic_sets` and each `st_ref_pic_set()` (ITU-T H.265 7.3.7) of an + /// SPS. A set predicted from the one before it has one or two flags for each of that set's + /// pictures and one more. + fn skip_reference_picture_sets(bits: &mut Bits<'_>) -> Option<()> { + let sets = bits.exp_golomb()?; + if sets > 64 { + return None; + } + // NumDeltaPocs of each set read so far. + let mut pictures: Vec = Vec::new(); + for index in 0..sets { + let predicted = index != 0 && bits.read(1)? == 1; + let count = if predicted { + // delta_rps_sign, abs_delta_rps_minus1. + bits.skip(1)?; + bits.exp_golomb()?; + let mut count = 0; + for _ in 0..=*pictures.last()? { + // used_by_curr_pic_flag, and use_delta_flag when it is 0. + if bits.read(1)? == 1 || bits.read(1)? == 1 { + count += 1; + } + } + count + } else { + let (negative, positive) = (bits.exp_golomb()?, bits.exp_golomb()?); + if negative > 16 || positive > 16 { + return None; + } + // Each picture's delta_poc_s0_minus1 or delta_poc_s1_minus1 and its used flag. + for _ in 0..negative + positive { + bits.exp_golomb()?; + bits.skip(1)?; + } + negative + positive + }; + pictures.push(count); + } + Some(()) + } + + /// A big-endian bit reader over an RBSP. + struct Bits<'a> { + data: &'a [u8], + at: usize, + } + + impl Bits<'_> { + fn bit(&mut self) -> Option { + let byte = self.data.get(self.at / 8)?; + let bit = (byte >> (7 - self.at % 8)) & 1; + self.at += 1; + Some(u64::from(bit)) + } + + fn read(&mut self, n: u32) -> Option { + (0..n).try_fold(0u64, |v, _| Some(v << 1 | self.bit()?)) + } + + fn skip(&mut self, n: usize) -> Option<()> { + self.at = self.at.checked_add(n)?; + (self.at <= self.data.len() * 8).then_some(()) + } + + /// An unsigned Exp-Golomb code, ue(v), of at most 32 leading zeros. + fn exp_golomb(&mut self) -> Option { + let mut zeros = 0; + while self.bit()? == 0 { + zeros += 1; + if zeros > 32 { + return None; + } + } + Some((1u64 << zeros) - 1 + self.read(zeros)?) + } + } + + /// `nclx` color primaries for Display P3 (SMPTE EG 432-1) and transfer characteristics for + /// the sRGB curve (IEC 61966-2-1), ITU-T H.273. + const P3_PRIMARIES: u16 = 12; + const SRGB_TRANSFER: u16 = 13; + + /// The profile the decoded RGB values are in when it isn't sRGB: the primary item's RGB ICC + /// profile, else Display P3 when its `nclx` names P3 primaries with the sRGB curve. A grid + /// without its own `colr` takes its first tile's, as cameras write it on the tiles. A profile + /// that can't be read, or isn't RGB, is left out and the values are read as sRGB. Without any + /// `colr`, the HEVC stream's color description ([`stream_nclx`]) is read the same way. + fn source_profile(ctx: &Context<'_>) -> Option { + use vectorcraft_color::cms::{self, IccProfile, ProfileKind}; + + let id = ctx.meta.primary; + let own = ctx.props(id).ok()?; + let described = |p: &heic_rs::props::ItemProps<'_>| p.icc.is_some() || p.nclx.is_some(); + let tile = || ctx.grid(id).ok().flatten().and_then(|(_, tiles)| tiles.first().and_then(|&t| ctx.props(t).ok())).filter(described); + let props = if described(&own) { Some(own) } else { tile() }; + if let Some(icc) = props.as_ref().and_then(|p| p.icc) { + return IccProfile::from_bytes(None, icc).ok().filter(|p| p.kind == ProfileKind::Rgb); + } + let nclx = match props { + Some(props) => props.nclx?, + None => stream_nclx(ctx)?, + }; + (nclx.primaries == P3_PRIMARIES && nclx.transfer == SRGB_TRANSFER).then(|| cms::icc::builtin_rgb(cms::DISPLAY_P3)).flatten() + } +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index f32a456b1..9f0319f6c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -103,6 +103,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if super::jp2read::is_jpeg2000(bytes) { return format("jpeg2000"); } + if super::heifread::is_heif(bytes) { + return format("heif"); + } if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } @@ -475,11 +478,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // Threaded type read from a file flows through its frames (the threaded type of a restored // document keeps its text as saved), and every frame of a thread takes its first frame's - // Optical Margin Alignment setting. + // Optical Margin Alignment setting and writing direction. When that changes the direction of a + // frame of a restored document, the story flows through that frame's thread again. Threaded + // frames keep their size: Auto Size is off for them. if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { + crate::cmd::threads::keep_frame_sizes(&mut doc); crate::cmd::threads::share_optical_margin(&mut doc); + crate::cmd::threads::share_orientation(&mut doc); // A frame whose first paragraph continues from the frame before draws it without the // paragraph's first-line indent and list marker. crate::cmd::threads::mark_continued(&mut doc); @@ -731,6 +738,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// [`super::pcxread::decode`], which refuses an image past [`MAX_RASTER_ALLOC`] before decoding. /// A Pixar picture file is recognized by its magic number ([`super::pxrread::is_pxr`]) and read by /// [`super::pxrread::decode`], which does the same. +/// A HEIC or HEIF file is recognized by its `ftyp` brands ([`super::heifread::is_heif`]) and read +/// by [`super::heifread::decode`], which does the same. /// A Targa file, which starts with no signature, is recognized by its header /// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores /// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels @@ -749,6 +758,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { if super::jp2read::is_jpeg2000(bytes) { return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } + if super::heifread::is_heif(bytes) { + // The EXIF resolution describes the stored pixels; a quarter turn swaps its axes. + let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) }); + return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + } if super::pxrread::is_pxr(bytes) { return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 5d6d25f26..9539a81c7 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -1,6 +1,7 @@ //! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and //! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX -//! header's HDpi and VDpi, or JP2 capture and display resolution boxes. Place +//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF +//! resolution tags. Place //! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it //! as a `pHYs` chunk. @@ -26,6 +27,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { super::pcxread::resolution(bytes) } else if super::jp2read::is_jpeg2000(bytes) { jp2(bytes) + } else if super::heifread::is_heif(bytes) { + super::heifread::with_exif(bytes, tiff) } else { None }; diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 201a4b479..11e878bc1 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -670,7 +670,7 @@ fn find_preset(s: &Session, name: &str) -> Option { } /// `p` over the settings of its `exportPreset`, when it names one (its own keys win). -fn with_preset(s: &Session, p: &Value) -> Result { +pub(super) fn with_preset(s: &Session, p: &Value) -> Result { let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) }; let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?; let mut merged = preset.settings; diff --git a/crates/engine/src/cmd/fileio/tests_heifread.rs b/crates/engine/src/cmd/fileio/tests_heifread.rs new file mode 100644 index 000000000..9820164da --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_heifread.rs @@ -0,0 +1,620 @@ +//! HEIC and HEIF Open, Place, pixel, color and resolution regressions using original synthetic +//! fixtures (quadrants of four colors, lossy HEVC, so colors are compared within a tolerance). + +#[cfg(feature = "heif")] +use serde_json::json; + +use super::heifread::is_heif; +use super::*; + +const QUADRANTS: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants.heic"); +const ALPHA: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-alpha.heic"); +const TEN_BIT: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-10bit.heic"); +const GRID: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid.heic"); +const GRID16: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid16.heic"); +const PPI300: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-300ppi.heic"); +const ROTATED: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated.heic"); +const ROTATED_300X150: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated-300x150.heic"); +const TONES: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones.heic"); +const TONES_P3_NCLX: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-nclx.heic"); +const TONES_P3_ICC: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc.heic"); +const TONES_P3_ICC_ONLY: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc-only.heic"); + +const EVERY_FIXTURE: [&[u8]; 12] = + [QUADRANTS, ALPHA, TEN_BIT, GRID, GRID16, PPI300, ROTATED, ROTATED_300X150, TONES, TONES_P3_NCLX, TONES_P3_ICC, TONES_P3_ICC_ONLY]; + +/// Red, green, blue and white: the top-left, top-right, bottom-left and bottom-right quadrants. +#[cfg(feature = "heif")] +const COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255; 4]]; + +/// The centers of the four quadrants of a square image `side` pixels wide. +#[cfg(feature = "heif")] +fn centers(side: u32) -> [(u32, u32); 4] { + let (a, b) = (side / 4, side * 3 / 4); + [(a, a), (b, a), (a, b), (b, b)] +} + +#[cfg(feature = "heif")] +fn decoded(bytes: &[u8]) -> image::RgbaImage { + let raster = load::raster_image(bytes).unwrap(); + image::load_from_memory(&raster.blob.bytes).unwrap().to_rgba8() +} + +#[cfg(feature = "heif")] +fn close(got: [u8; 4], want: [u8; 4], tolerance: u8) -> bool { + got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= tolerance) +} + +#[cfg(feature = "heif")] +fn assert_quadrants(img: &image::RgbaImage, side: u32, what: &str) { + assert_eq!(img.dimensions(), (side, side), "{what}"); + for ((x, y), want) in centers(side).into_iter().zip(COLORS) { + let got = img.get_pixel(x, y).0; + assert!(close(got, want, 8), "{what} at ({x}, {y}): {got:?} != {want:?}"); + } +} + +#[cfg(feature = "heif")] +#[test] +fn quadrants_open_and_place_as_their_pixels() { + assert_quadrants(&decoded(QUADRANTS), 32, "quadrants.heic"); + for name in ["photo.heic", "photo.heif", "unknown.bin"] { + let mut s = Session::new(); + let encoded = vectorcraft_format::base64_encode(QUADRANTS); + let opened = s.execute("document.open", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(opened["format"], "heif", "{name}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(32.0, 32.0)); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + assert_quadrants(&image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), 32, name); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let placed = s.execute("file.place", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(placed["format"], "heif", "{name}"); + } + assert!(load::texture_image(QUADRANTS).is_ok()); + // The general brand alone, without `heic`. + let mut general = QUADRANTS.to_vec(); + assert_eq!(&QUADRANTS[8..28], b"heic\0\0\0\0mif1heicmiaf"); + general[8..12].copy_from_slice(b"mif1"); + general[20..24].copy_from_slice(b"miaf"); + assert_quadrants(&decoded(&general), 32, "mif1 only"); +} + +#[test] +fn signatures_and_every_extension() { + for bytes in EVERY_FIXTURE { + assert!(is_heif(bytes)); + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + } + for ext in ["heic", "heif"] { + assert_eq!(load::detect(&format!("a.{ext}"), b"invalid").map(|f| f.id), Some("heif")); + assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext) && UNASSOCIATED_EXTS.contains(&ext), "{ext}"); + } + // A brand only among the compatible ones counts, and so do the general brands alone. + assert!(is_heif(b"\0\0\0\x14ftypabcd\0\0\0\0heic")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heix")); + assert!(is_heif(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1miaf")); + // A 64-bit box size (ISO/IEC 14496-12 4.2). + assert!(is_heif(b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0")); + let mut extended = b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0".to_vec(); + extended.extend_from_slice(&QUADRANTS[QUADRANTS.windows(4).position(|w| w == b"meta").unwrap() - 4..]); + assert_eq!(load::detect("unknown.bin", &extended).map(|f| f.id), Some("heif")); + // AVIF, with its brand major or compatible, and brands heic-rs doesn't read. + assert!(!is_heif(b"\0\0\0\x18ftypavif\0\0\0\0mif1miaf")); + assert!(!is_heif(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf")); + assert!(!is_heif(b"\0\0\0\x1cftypheic\0\0\0\0mif1heicavis")); + assert!(!is_heif(b"\0\0\0\x18ftyphevx\0\0\0\0hevxiso8")); + assert!(!is_heif(b"\0\0\0\x18ftypisom\0\0\0\0isomavc1")); + // A box shorter than its brands, longer than the file, or of another type. + assert!(!is_heif(b"\0\0\0\x0cftypheic")); + assert!(!is_heif(b"\0\0\0\x40ftypheic\0\0\0\0mif1")); + assert!(!is_heif(b"\0\0\0\x14styphei\0\0\0\0\0heic")); + assert!(!is_heif(b"")); +} + +#[cfg(feature = "heif")] +#[test] +fn alpha_10_bit_and_grid_images_keep_their_pixels() { + let alpha = decoded(ALPHA); + assert_eq!(alpha.dimensions(), (32, 32)); + for ((x, y), want) in centers(32).into_iter().zip(COLORS) { + let got = alpha.get_pixel(x, y).0; + // The left half is opaque; the right half is transparent. + if x < 16 { + assert!(close(got, want, 8), "({x}, {y}): {got:?} != {want:?}"); + } else { + assert!(got[3] <= 8, "({x}, {y}): {got:?}"); + } + } + assert_quadrants(&decoded(TEN_BIT), 32, "10-bit"); + // Four 64 × 64 tiles, one for each quadrant. + assert_quadrants(&decoded(GRID), 128, "2 × 2 grid"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_of_cropped_tiles_is_refused() { + // 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each coded 64 × 64 and cropped back + // with its own clap, which heic-rs 0.1.4 leaves out when it composes the grid. + let error = load::raster_image(GRID16).err().unwrap().to_string(); + assert!(error.contains("kind of image that can't be read"), "{error}"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_without_its_own_color_box_uses_its_tiles() { + // The grid item's associations (ipma) list ispe (1), pixi (5) and colr (3); the tiles carry + // the same colr. Pointing the grid's third association at its ispe leaves the grid without one. + let mut tiles_only = GRID.to_vec(); + assert_eq!(&GRID[565..571], &[0, 1, 3, 1, 0x85, 3], "item 1 with three associations, the last colr"); + tiles_only[570] = 1; + assert_quadrants(&decoded(&tiles_only), 128, "colr on the tiles only"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_rotation_is_applied() { + // A 32 × 16 image, red then blue, stored with a quarter turn clockwise. + let img = decoded(ROTATED); + assert_eq!(img.dimensions(), (16, 32)); + assert!(close(img.get_pixel(8, 4).0, [255, 0, 0, 255], 8), "{:?}", img.get_pixel(8, 4)); + assert!(close(img.get_pixel(8, 27).0, [0, 0, 255, 255], 8), "{:?}", img.get_pixel(8, 27)); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED)})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(16.0, 32.0)); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_mirror_and_crop_are_applied() { + // The rotated image's irot (property 6) turned to no rotation, and to an imir of the same + // size: mirrored left to right (ISO/IEC 23008-12 6.5.12, axis 0). + assert_eq!(&ROTATED[384..393], b"\0\0\0\x09irot\x03"); + let mut stored = ROTATED.to_vec(); + stored[392] = 0; + let mut mirrored = ROTATED.to_vec(); + mirrored[388..393].copy_from_slice(b"imir\0"); + let (stored, mirrored) = (decoded(&stored), decoded(&mirrored)); + assert_eq!((stored.dimensions(), mirrored.dimensions()), ((32, 16), (32, 16))); + let red = [255, 0, 0, 255]; + assert!(close(stored.get_pixel(8, 8).0, red, 8) && !close(stored.get_pixel(24, 8).0, red, 8), "{:?}", stored.get_pixel(8, 8)); + for (x, y) in [(4, 4), (8, 8), (20, 12), (27, 3)] { + assert!(close(mirrored.get_pixel(x, y).0, stored.get_pixel(31 - x, y).0, 8), "({x}, {y})"); + } + // The quadrants' clap (property 4) takes the top-left 32 × 32 of the 64 × 64 coded picture. + // Rewritten to its top-left 16 × 16: a center offset of -24 (ISO/IEC 14496-12 12.1.4) leaves + // the red quadrant alone. Its edge next to green is left out, as lossy chroma blends there. + assert_eq!(&QUADRANTS[329..337], b"\0\0\0\x28clap"); + let mut cropped = QUADRANTS.to_vec(); + let clap: Vec = [16u32, 1, 16, 1, (-48i32).cast_unsigned(), 2, (-48i32).cast_unsigned(), 2].iter().flat_map(|v| v.to_be_bytes()).collect(); + cropped[337..369].copy_from_slice(&clap); + let cropped = decoded(&cropped); + assert_eq!(cropped.dimensions(), (16, 16)); + for (x, y) in [(0, 0), (8, 8), (12, 0), (0, 12), (12, 12)] { + assert!(close(cropped.get_pixel(x, y).0, red, 8), "({x}, {y}): {:?}", cropped.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn exif_resolution_sizes_the_image_and_72_ppi_without_it() { + let got = ppi::resolution(PPI300).unwrap(); + assert!((got.0 - 300.0).abs() < 0.01 && (got.1 - 300.0).abs() < 0.01, "{got:?}"); + assert_eq!(ppi::resolution(QUADRANTS), None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(PPI300)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + let stored = ppi::resolution(&blob.bytes).unwrap(); + assert!((stored.0 - 300.0).abs() < 0.01 && (stored.1 - 300.0).abs() < 0.01); + // 32 × 16 pixels at 300 ppi across and 150 down, turned a quarter: 16 × 32 pixels at 150 ppi + // across and 300 down, the same 7.68 × 7.68 pt either way. + assert_eq!(ppi::resolution(ROTATED_300X150), Some((300.0, 150.0)), "as stored"); + let img = load::raster_image(ROTATED_300X150).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (16, 32, Some((150.0, 300.0)))); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED_300X150)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_images_are_converted_to_srgb() { + use vectorcraft_color::cms::{DISPLAY_P3, Intent, icc::builtin_rgb}; + let p3 = builtin_rgb(DISPLAY_P3).unwrap(); + let plain = decoded(TONES); + // The ICC-only file's samples are full-range BT.601, which only its HEVC stream says. + for (what, bytes) in [("nclx", TONES_P3_NCLX), ("ICC", TONES_P3_ICC), ("ICC only", TONES_P3_ICC_ONLY)] { + let converted = decoded(bytes); + for (x, y) in centers(32) { + let [r, g, b, a] = plain.get_pixel(x, y).0; + let rgb = p3.to_srgb_into(&[r, g, b].map(|v| f32::from(v) / 255.0), Intent::RelativeColorimetric).unwrap(); + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + let got = converted.get_pixel(x, y).0; + assert!(close(got, [r, g, b, a], 2), "{what} at ({x}, {y}): {got:?} != {:?}", [r, g, b, a]); + } + } + // The orange quadrant changes, so the conversion ran. + assert!(!close(decoded(TONES_P3_NCLX).get_pixel(8, 8).0, plain.get_pixel(8, 8).0, 4)); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_named_by_the_stream_alone_is_converted_to_srgb() { + // The nclx file's item associates hvcC, colr (property 2), ispe, pixi and clap. Pointing the + // colr association at the ispe leaves the item without a color box, and its HEVC stream's + // video signal type, which names Display P3 with the sRGB curve, describes the samples. + let ipma = TONES_P3_NCLX.windows(4).position(|w| w == b"ipma").unwrap(); + let at = ipma + 4 + 4 + 4 + 2 + 1 + 1; + assert_eq!(&TONES_P3_NCLX[at - 4..at + 4], &[0, 1, 5, 0x81, 2, 3, 5, 0x84], "item 1 with five associations, the second colr"); + let mut stream_only = TONES_P3_NCLX.to_vec(); + stream_only[at] = 3; + let want = decoded(TONES_P3_NCLX); + let got = decoded(&stream_only); + for (x, y) in centers(32) { + assert!(close(got.get_pixel(x, y).0, want.get_pixel(x, y).0, 2), "at ({x}, {y}): {:?} != {:?}", got.get_pixel(x, y), want.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn two_quarter_turns_keep_the_resolution_axes() { + // The rotated image's ipma lists its irot once; a second association with the same irot turns + // it a half turn, so the EXIF resolution keeps its axes. + let ipma = ROTATED_300X150.windows(4).position(|w| w == b"ipma").unwrap(); + let count = ipma + 4 + 4 + 4 + 2; + let irot = ROTATED_300X150.windows(4).position(|w| w == b"irot").unwrap(); + let ipco = ROTATED_300X150.windows(4).position(|w| w == b"ipco").unwrap(); + // The irot's property index: its place among the boxes of ipco, counted from 1. + let mut index = 1u8; + let mut at = ipco + 4; + while at < irot - 4 { + index += 1; + at += u32::from_be_bytes(ROTATED_300X150[at..at + 4].try_into().unwrap()) as usize; + } + assert_eq!(at, irot - 4, "irot is a box of ipco"); + let n = usize::from(ROTATED_300X150[count]); + assert!(ROTATED_300X150[count + 1..count + 1 + n].contains(&(0x80 | index)), "the item's irot association, essential"); + let mut twice = ROTATED_300X150.to_vec(); + twice[count] += 1; + twice.insert(count + 1 + n, 0x80 | index); + // The ipma, iprp and meta boxes each grow by one byte, and the iloc offsets move with them. + let grow = |bytes: &mut Vec, name: &[u8; 4]| { + let at = bytes.windows(4).position(|w| w == name).unwrap() - 4; + let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) + 1; + bytes[at..at + 4].copy_from_slice(&size.to_be_bytes()); + }; + for name in [b"ipma", b"iprp", b"meta"] { + grow(&mut twice, name); + } + let iloc = twice.windows(4).position(|w| w == b"iloc").unwrap(); + assert_eq!(&twice[iloc + 4..iloc + 8], &[0, 0, 0, 0], "iloc version 0"); + let sizes = twice[iloc + 8]; + assert_eq!((sizes >> 4, sizes & 0xf), (4, 4), "4-byte offsets and lengths"); + assert_eq!(twice[iloc + 9] >> 4, 4, "4-byte base offsets"); + let items = u16::from_be_bytes([twice[iloc + 10], twice[iloc + 11]]); + // Each item: its id, data reference index and base offset, which moves, then its extents. + let mut at = iloc + 12; + for _ in 0..items { + at += 2 + 2; + let offset = u32::from_be_bytes(twice[at..at + 4].try_into().unwrap()) + 1; + twice[at..at + 4].copy_from_slice(&offset.to_be_bytes()); + at += 4; + let extents = u16::from_be_bytes([twice[at], twice[at + 1]]); + at += 2 + 8 * usize::from(extents); + } + let img = load::raster_image(&twice).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (32, 16, Some((300.0, 150.0))), "a half turn keeps the stored axes"); +} + +#[cfg(feature = "heif")] +#[test] +fn damaged_unsupported_and_oversized_files_are_refused() { + let error = |bytes: &[u8]| load::raster_image(bytes).err().unwrap().to_string(); + for cut in [16, 40, QUADRANTS.len() / 2, QUADRANTS.len() - 5] { + assert!(load::raster_image(&QUADRANTS[..cut]).is_err(), "{cut}"); + } + // An ftyp and nothing else, as an image sequence starts (its `moov` would follow). + assert!(error(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc").contains("kind of image that can't be read")); + assert!(error(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic\0\0\0\x08free").contains("HEIC or HEIF file")); + // The primary item's type (infe) turned from HEVC to AV1, with its hvcC kept, and with its + // hvcC turned into an av1C. + let mut av1 = QUADRANTS.to_vec(); + assert_eq!((&QUADRANTS[137..141], &QUADRANTS[176..180]), (&b"hvc1"[..], &b"hvcC"[..])); + av1[137..141].copy_from_slice(b"av01"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + av1[176..180].copy_from_slice(b"av1C"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + // The image spatial extents (`ispe`) claim 60,000 × 60,000 pixels. + let mut huge = QUADRANTS.to_vec(); + let at = huge.windows(4).position(|w| w == b"ispe").unwrap() + 8; + huge[at..at + 4].copy_from_slice(&60_000u32.to_be_bytes()); + huge[at + 4..at + 8].copy_from_slice(&60_000u32.to_be_bytes()); + assert!(error(&huge).contains("60000 × 60000 pixels is too large to draw"), "{}", error(&huge)); + // A single 64 × 64 coded picture (cropped to 32 × 32) takes its RGBA output and copy, + // 64 × 64 × 8, and its planes, 64 × 64 × 9: no composed frame is counted. + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17).is_ok()); + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17 - 1).unwrap_err().contains("too large to draw")); + // The same picture with an ispe of 32 × 32 (its crop's size): its stream still decodes + // 64 × 64, and that size is counted. + let mut small = QUADRANTS.to_vec(); + let at = small.windows(4).position(|w| w == b"ispe").unwrap() + 8; + assert_eq!(&small[at..at + 8], b"\0\0\0\x40\0\0\0\x40"); + small[at..at + 4].copy_from_slice(&32u32.to_be_bytes()); + small[at + 4..at + 8].copy_from_slice(&32u32.to_be_bytes()); + let refused = super::heifread::decode(&small, 64 * 64 * 17 - 1).map(|img| img.dimensions()); + assert!(refused.as_ref().is_err_and(|e| e.contains("too large to draw")), "{refused:?}"); + // A 128 × 128 grid of four 64 × 64 tiles adds its composed frame, 128 × 128 × 6. + let grid = 128 * 128 * (8 + 6) + 4 * 64 * 64 * 9; + assert!(super::heifread::decode(GRID, grid).is_ok()); + assert!(super::heifread::decode(GRID, grid - 1).unwrap_err().contains("too large to draw")); + // The grid's ispe (property 1) claims 16 × 16 pixels; its grid data still composes 128 × 128, + // and that size is counted. + let mut understated = GRID.to_vec(); + assert_eq!(&GRID[358..370], b"ispe\0\0\0\0\0\0\0\x80", "property 1 is the grid's 128 × 128 ispe"); + understated[366..370].copy_from_slice(&16u32.to_be_bytes()); + understated[370..374].copy_from_slice(&16u32.to_be_bytes()); + assert!(super::heifread::decode(&understated, grid - 1).unwrap_err().contains("too large to draw")); +} + +/// Appends `value` as `n` bits, most significant first. +#[cfg(feature = "heif")] +fn put(bits: &mut Vec, value: u64, n: u32) { + bits.extend((0..n).rev().map(|i| i < 64 && value >> i & 1 == 1)); +} + +/// Appends `value` as an unsigned Exp-Golomb code. +#[cfg(feature = "heif")] +fn put_ue(bits: &mut Vec, value: u64) { + let len = 64 - (value + 1).leading_zeros(); + put(bits, 0, len - 1); + put(bits, value + 1, len); +} + +/// The fields of SPS 0 of `width` × `height` 4:2:0 samples with `sub_layers` sub-layers, each +/// with its profile and level, up to its height. +#[cfg(feature = "heif")] +fn sps_head(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_head_with_id(width, height, sub_layers, 0) +} + +/// [`sps_head`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_head_with_id(width: u64, height: u64, sub_layers: u64, id: u64) -> Vec { + let mut bits = Vec::new(); + put(&mut bits, 0, 4); + put(&mut bits, sub_layers, 3); + put(&mut bits, 1, 1); + put(&mut bits, 0, 96); + for _ in 0..sub_layers { + put(&mut bits, 0b11, 2); + } + if sub_layers > 0 { + put(&mut bits, 0, 2 * (8 - sub_layers as u32)); + } + for _ in 0..sub_layers { + put(&mut bits, 0, 88 + 8); + } + put_ue(&mut bits, id); + put_ue(&mut bits, 1); + put_ue(&mut bits, width); + put_ue(&mut bits, height); + bits +} + +/// An SPS NAL unit of `bits` (see [`nal`]). +#[cfg(feature = "heif")] +fn sps_nal(bits: Vec) -> Vec { + nal(33, bits) +} + +/// A NAL unit of type `kind` holding `bits`, with its stop bit, and emulation prevention bytes +/// inserted as a stream carries them. +#[cfg(feature = "heif")] +fn nal(kind: u8, mut bits: Vec) -> Vec { + put(&mut bits, 1, 1); + while !bits.len().is_multiple_of(8) { + bits.push(false); + } + let rbsp: Vec = bits.chunks(8).map(|b| b.iter().fold(0u8, |v, &bit| v << 1 | u8::from(bit))).collect(); + let mut nal = vec![kind << 1, 1]; + let mut zeros = 0; + for b in rbsp { + if zeros >= 2 && b <= 3 { + nal.push(3); + zeros = 0; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + nal.push(b); + } + nal +} + +#[cfg(feature = "heif")] +fn sps(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_nal(sps_head(width, height, sub_layers)) +} + +/// A 64 × 64 SPS that uses every optional part before the VUI's video signal type: a conformance +/// window, sub-layer ordering for two sub-layers, explicit scaling lists, PCM, a reference picture +/// set predicted from another and a long-term picture, then an Extended_SAR aspect ratio and +/// overscan information. `signal` is the full-range flag and the color description, if any. +#[cfg(feature = "heif")] +fn sps_with_vui(signal: Option<(u64, Option<[u64; 3]>)>) -> Vec { + sps_with_vui_and_id(signal, 0) +} + +/// [`sps_with_vui`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_with_vui_and_id(signal: Option<(u64, Option<[u64; 3]>)>, id: u64) -> Vec { + let mut bits = sps_head_with_id(64, 64, 1, id); + put(&mut bits, 1, 1); + for offset in [0, 2, 0, 2] { + put_ue(&mut bits, offset); + } + put_ue(&mut bits, 0); + put_ue(&mut bits, 0); + // 8 bits of picture order count. + put_ue(&mut bits, 4); + put(&mut bits, 1, 1); + for _ in 0..2 * 3 { + put_ue(&mut bits, 1); + } + for value in [0, 3, 0, 3, 1, 1] { + put_ue(&mut bits, value); + } + // Scaling lists: matrix 1 of each size given explicitly, the others copied. + put(&mut bits, 0b11, 2); + for size in 0..4 { + for matrix in 0..if size == 3 { 2 } else { 6 } { + if matrix == 1 { + put(&mut bits, 1, 1); + if size > 1 { + put_ue(&mut bits, 5); + } + for _ in 0..if size == 0 { 16 } else { 64 } { + put_ue(&mut bits, 3); + } + } else { + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + } + } + } + // AMP off, SAO on, PCM with its depths, block sizes and loop filter flag. + put(&mut bits, 0b01, 2); + put(&mut bits, 1, 1); + put(&mut bits, 0x77, 8); + put_ue(&mut bits, 0); + put_ue(&mut bits, 1); + put(&mut bits, 0, 1); + // Two short-term sets: one negative and one positive picture, then a set predicted from it + // with three entries (unused with use_delta_flag, used, unused). + put_ue(&mut bits, 2); + put_ue(&mut bits, 1); + put_ue(&mut bits, 1); + for _ in 0..2 { + put_ue(&mut bits, 0); + put(&mut bits, 1, 1); + } + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + put(&mut bits, 0b01100, 5); + // One long-term picture: 8 bits of picture order count and its used flag. + put(&mut bits, 1, 1); + put_ue(&mut bits, 1); + put(&mut bits, 0xab, 8); + put(&mut bits, 1, 1); + put(&mut bits, 0b11, 2); + // VUI: Extended_SAR 1:1 and overscan information. + put(&mut bits, 1, 1); + put(&mut bits, 1, 1); + put(&mut bits, 255, 8); + put(&mut bits, 0x0001_0001, 32); + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + match signal { + Some((full, description)) => { + put(&mut bits, 1, 1); + put(&mut bits, 5, 3); + put(&mut bits, full, 1); + put(&mut bits, u64::from(description.is_some()), 1); + for code in description.into_iter().flatten() { + put(&mut bits, code, 8); + } + } + None => put(&mut bits, 0, 1), + } + // No chroma location, field or display window information, timing or restrictions. + put(&mut bits, 0, 8); + sps_nal(bits) +} + +#[cfg(feature = "heif")] +#[test] +fn picture_sizes_come_from_the_sequence_parameter_sets() { + use super::heifread::{picture_bytes, sps_size}; + for sub_layers in [0, 1, 6] { + for (w, h) in [(32, 32), (4032, 3024), (65535, 65535)] { + let nal = sps(w, h, sub_layers); + assert!(nal.windows(3).any(|w| w == [0, 0, 3]), "the profile's zeros need emulation prevention"); + assert_eq!(sps_size(&nal), Some((w, h, 1)), "{w} × {h}, {sub_layers} sub-layers"); + } + } + let mut vps = sps(32, 32, 0); + vps[0] = 32 << 1; + assert_eq!(sps_size(&vps), None); + assert_eq!(sps_size(&sps(32, 32, 0)[..8]), None); + // 4:2:0 is 3 bytes a sample position in each of three sets of planes, in 64-sample blocks. + assert_eq!(picture_bytes(QUADRANTS), Some(64 * 64 * 9)); + assert_eq!(picture_bytes(GRID), Some(4 * 64 * 64 * 9)); + assert!(picture_bytes(ALPHA).unwrap() > 64 * 64 * 9, "the alpha image is counted"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_color_description_comes_from_the_streams_video_signal_type() { + use super::heifread::{sps_signal, sps_size}; + use heic_rs::{MatrixCoefficients, Range}; + let signal = |nal: &[u8]| sps_signal(nal).map(|n| (n.primaries, n.transfer, n.matrix, n.matrix_code, n.range)); + let described = sps_with_vui(Some((1, Some([12, 13, 6])))); + assert_eq!(sps_size(&described), Some((64, 64, 1))); + assert_eq!(signal(&described), Some((12, 13, MatrixCoefficients::Bt601, 6, Range::Full))); + assert_eq!(signal(&sps_with_vui(Some((0, None)))), Some((2, 2, MatrixCoefficients::Unspecified, 2, Range::Limited))); + assert_eq!(signal(&sps_with_vui(None)), None); + assert_eq!(signal(&sps(64, 64, 0)), None, "cut short before its VUI"); + // The ICC-only fixture's stream: heif-enc writes full-range BT.601. + let ctx = heic_rs::context::Context::open(TONES_P3_ICC_ONLY).unwrap(); + let props = ctx.props(ctx.meta.primary).unwrap(); + assert!(props.nclx.is_none() && props.icc.is_some()); + let sets = props.hvcc.unwrap().parameter_sets(); + let nal = sets.into_iter().find(|nal| nal[0] >> 1 & 0x3f == 33).unwrap(); + assert_eq!(sps_signal(nal).map(|n| (n.matrix, n.range)), Some((MatrixCoefficients::Bt601, Range::Full))); +} + +#[cfg(feature = "heif")] +#[test] +fn the_slices_picture_parameter_set_picks_the_sps() { + use super::heifread::{active_sps, sps_signal}; + use heic_rs::Range; + let full = sps_with_vui_and_id(Some((1, Some([1, 13, 6]))), 0); + let limited = sps_with_vui_and_id(Some((0, Some([1, 13, 1]))), 1); + let pps = |id: u64, sps: u64| { + let mut bits = Vec::new(); + put_ue(&mut bits, id); + put_ue(&mut bits, sps); + nal(34, bits) + }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag for an IDR picture (type 19), + // slice_pic_parameter_set_id. + let slice = |kind: u8, pps: u64| { + let mut bits = vec![true]; + if kind == 19 { + bits.push(false); + } + put_ue(&mut bits, pps); + nal(kind, bits) + }; + let (pps0, pps3) = (pps(0, 0), pps(3, 1)); + let sets = [full.as_slice(), limited.as_slice(), pps0.as_slice(), pps3.as_slice()]; + let (idr3, trail0, idr5) = (slice(19, 3), slice(1, 0), slice(19, 5)); + assert_eq!(active_sps(&sets, &[&idr3]), Some(limited.as_slice())); + assert_eq!(active_sps(&sets, &[&trail0]), Some(full.as_slice())); + assert_eq!(active_sps(&sets, &[&idr5]), None, "no PPS 5"); + assert_eq!(active_sps(&sets, &[&pps0]), None, "no slice"); + assert_eq!(active_sps(&sets, &[&idr3]).and_then(sps_signal).map(|n| (n.matrix_code, n.range)), Some((1, Range::Limited))); +} + +#[cfg(not(feature = "heif"))] +#[test] +fn a_build_without_the_decoder_refuses_heif_files_with_a_message() { + for bytes in EVERY_FIXTURE { + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + let error = load::raster_image(bytes).err().unwrap().to_string(); + assert!(error.contains("can't be opened in this build"), "{error}"); + } + assert_eq!(ppi::resolution(PPI300), None); +} diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index 822d88aad..d13e7d8c5 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -428,7 +428,7 @@ fn rulers(s: &mut Session, p: &Value) -> Result { /// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`): /// the plane and depth of the first one and the plane-space correction that lands the edge of the -/// joint bounds of the objects there nearer a gridline on it. +/// joint bounds of the objects on that plane, each measured at its own depth, nearer a gridline on it. pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> { let g = grid_of(doc); if !bool_or(p, "snap", g.snap) { diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a702f1580..0267725ef 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -150,11 +150,19 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("scaleCorners", "General", "Options", "Scale Corners", bool), p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool), p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool), + p!("ctrlWheelScrollsSideways", "General", "Options", "Scroll Sideways with Cmd/Ctrl + Wheel", bool), p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), + p!( + "marqueeSelection", + "Selection & Anchor Display", + "Selection", + "Marquee Selection", + choice(&[("touching", "Touching"), ("enclosed", "Fully Enclosed"), ("directional", "Drag Direction")]) + ), p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool), p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")), p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool), @@ -207,6 +215,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool), p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool), p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool), + p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool), p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text), // Units p!("unitsGeneral", "Units", "", "General", choice(UNITS)), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 29d1de28f..d8514ef56 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Bullets and Numbering", [], None, - "{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}", + "{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), \"* \" (a bulleted list with the filled dot), \"+ \" (the hollow circle), \"# \" (the filled square) or \"> \" (the dash), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}", has_doc, start_list ), diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 61a21e506..61fc7f33a 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -168,7 +168,17 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } } let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None }; - let frames: Vec = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect(); + let vertical = if live.len() >= 2 { thread_setting(before, doc, &live, |t| t.vertical) } else { None }; + // The story is measured in each frame with the settings the frame takes. + let frames: Vec = live + .iter() + .filter_map(|id| text_of(doc, *id).cloned()) + .map(|mut t| { + t.optical_margin = optical.unwrap_or(t.optical_margin); + t.vertical = vertical.unwrap_or(t.vertical); + t + }) + .collect(); let refs: Vec<&TextObject> = frames.iter().collect(); let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s); for (id, part) in live.iter().zip(parts) { @@ -177,11 +187,13 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { next.runs = part.runs; next.set_paragraph_styles(part.paras); let optical = optical.unwrap_or(t.optical_margin); + let vertical = vertical.unwrap_or(t.vertical); let continues = part.continues; if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin + || vertical != t.vertical || part.list_count != t.list_count || continues != t.first_para_continues { @@ -189,6 +201,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { t.para = next.para; t.paras = next.paras; t.optical_margin = optical; + t.vertical = vertical; t.list_count = part.list_count; t.first_para_continues = continues; refresh_bounds(t); @@ -235,11 +248,37 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { /// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes: /// that of the first frame whose setting changed between `before` and `doc`, else the first frame's. fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option { - let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin); - let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id))); + thread_setting(before, doc, live, |t| t.optical_margin) +} + +/// The value of the frame setting `get` that every frame of a thread with the frames `live` takes: +/// that of the first frame whose value changed between `before` and `doc`, or the first frame's value +/// when none changed. +fn thread_setting(before: &Document, doc: &Document, live: &[NodeId], get: impl Fn(&TextObject) -> T) -> Option { + let now = |id: &NodeId| text_of(doc, *id).map(&get); + let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(get(t)) != now(id))); changed.or(live.first()).and_then(now) } +/// Turn Auto Size off for the frames of each thread of two or more frames, as [`reflow`] does, +/// without re-flowing the story: for a document restored from a file as it was saved, so a later +/// change to a frame's settings keeps the frame's size. +pub(crate) fn keep_frame_sizes(doc: &mut Document) { + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + if live.len() < 2 { + continue; + } + for id in live { + if text_of(doc, id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight) + && let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) + { + t.area.fit = vectorcraft_doc::AreaFit::None; + } + } + } +} + /// Give every frame of each thread the first frame's Optical Margin Alignment setting, without /// re-flowing the story: for a document restored from a file as it was saved. pub(crate) fn share_optical_margin(doc: &mut Document) { @@ -257,6 +296,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) { } } +/// Give every frame of each thread the first frame's writing direction, and re-flow each thread in +/// which that changes a frame: for a document restored from a file as it was saved. +pub(crate) fn share_orientation(doc: &mut Document) { + let before = doc.clone(); + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + let Some(vertical) = thread_setting(doc, doc, &live, |t| t.vertical) else { continue }; + // Only the frames whose direction changes are copied, so only their threads re-flow. + let turn: Vec = live.into_iter().filter(|id| text_of(doc, *id).is_some_and(|t| t.vertical != vertical)).collect(); + for id in turn { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.vertical = vertical; + refresh_bounds(t); + } + } + } + reflow(&before, doc); +} + /// Mark the frames of each thread whose first paragraph continues from the frame before /// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored /// from a file as it was saved. diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 71d175b3a..1ead2e823 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -1090,6 +1090,177 @@ fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() { } } +/// The writing direction of each of the frames `ids` (true: vertical). +fn vertical(s: &Session, ids: [u64; N]) -> [bool; N] { + ids.map(|id| { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + t.vertical + }) +} + +/// Whether each frame of the thread `ids` holds the part of the story that its layout, in its +/// writing direction, has room for: the parts that [`vectorcraft_text::thread::distribute`] +/// gives the frames as they are now. +fn flows_as_laid_out(s: &Session, ids: &[u64]) -> bool { + let d = &s.doc().unwrap().doc; + let frames: Vec<&TextObject> = ids.iter().map(|id| text_of(d, NodeId(*id)).unwrap()).collect(); + let story = vectorcraft_text::thread::story(&frames); + let paras = vectorcraft_text::thread::story_paras(&frames, &[]); + let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &frames, &story, ¶s); + let texts = |runs: &[TextRun]| runs.iter().map(|r| r.text.as_str()).collect::(); + parts.iter().zip(&frames).all(|(part, f)| texts(&part.runs) == f.plain_text()) +} + +/// Type Orientation is a setting of the whole thread: Type › Type Orientation in any frame sets +/// it in every frame of the thread, in one undo step, and the story flows through the frames in +/// that direction. Typing keeps it, and frames taken out of the thread keep it. +#[test] +fn type_orientation_is_set_for_every_frame_of_a_thread() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("type.orientation.vertical", &json!({})).unwrap(); + assert_eq!(r, json!({"vertical": true, "ids": [a, b]})); + assert_eq!(vertical(&s, [a, b]), [true, true], "the second frame is vertical too"); + assert!(flows_as_laid_out(&s, &[a, b])); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + // Set back from the second frame, both are horizontal, and one undo makes both vertical. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + assert_eq!(s.execute("type.orientation.horizontal", &json!({})).unwrap()["ids"], json!([b, a])); + assert_eq!(vertical(&s, [a, b]), [false, false]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + // Typing in the first frame re-flows the story and keeps the direction. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + assert!(flows_as_laid_out(&s, &[a, b])); + // Any edit that sets one frame sets the thread. + s.edit("Set", |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = false; + refresh_bounds(t); + Ok(()) + }) + .unwrap(); + assert_eq!(vertical(&s, [a, b]), [false, false]); + assert!(flows_as_laid_out(&s, &[a, b])); + s.execute("edit.undo", &json!({})).unwrap(); + // Released or unthreaded, each frame keeps it. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(vertical(&s, [a, b]), [true, true]); +} + +/// Threading type gives the joined thread the writing direction of its first frame, and the +/// story flows through every frame in that direction. +#[test] +fn a_thread_takes_its_first_frames_type_orientation() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let area = json!({"width": 150, "height": 60}); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": area, "vertical": true})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 60, "height": 200})).unwrap()); + let c = new_id(s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b, c]), [true; 3]); + assert!(!text(&s, b).is_empty() && !text(&s, c).is_empty()); + assert!(flows_as_laid_out(&s, &[a, b, c]), "the middle frame holds what fits it as vertical type"); + // From `c`'s out port into horizontal area type `d`: `d` joins the thread and becomes vertical. + let d = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap()); + s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap(); + assert_eq!(threads(&s), [[a, b, c, d]]); + assert_eq!(vertical(&s, [a, b, c, d]), [true; 4]); + assert!(flows_as_laid_out(&s, &[a, b, c, d])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(vertical(&s, [d]), [false], "undo gives `d` its own direction back"); +} + +/// A file saved with the frames of one thread in different writing directions opens with the +/// first frame's direction in every frame, and the story flows through them in it: a native +/// file, and an EPS file restored from the document it carries. +#[test] +fn a_thread_saved_with_differing_orientations_opens_with_the_first_frames() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] { + for (id, on) in [(a, first), (b, !first)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = on; + refresh_bounds(t); + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!(vertical(&s, [a, b]), [first, first], "{name}, first frame vertical: {first}"); + assert!(flows_as_laid_out(&s, &[a, b]), "{name}, first frame vertical: {first}"); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4), "{name}"); + } +} + +/// A thread restored from an EPS file whose frames share one writing direction keeps its text +/// in its frames as saved. +#[test] +fn a_restored_thread_in_one_direction_keeps_its_text_as_saved() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let story = TEXT.repeat(4); + for (id, part) in [(a, &story[..10]), (b, &story[10..])] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.runs = vec![TextRun::new(part.to_string(), t.runs[0].style.clone())]; + refresh_bounds(t); + } + let r = s.execute("document.exportEps", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], true, "{r}"); + assert_eq!((text(&s, a), text(&s, b)), (story[..10].to_string(), story[10..].to_string())); +} + +/// A threaded frame restored from a file with Auto Size on keeps its size when it turns from +/// vertical to horizontal: as the file opens, when the first frame is horizontal, and with Type +/// Orientation, when the whole thread is vertical. +#[test] +fn a_restored_threaded_frame_with_auto_size_keeps_its_size_when_it_turns_horizontal() { + for first_vertical in [false, true] { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + for (id, on) in [(a, first_vertical), (b, true)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = on; + if id == b { + t.area.fit = vectorcraft_doc::AreaFit::AutoHeight; + } + refresh_bounds(t); + } + let saved = frame_box(&s, b); + let r = s.execute("document.exportEps", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], true, "{r}"); + if first_vertical { + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("type.orientation.horizontal", &json!({})).unwrap(); + } + assert_eq!(vertical(&s, [a, b]), [false, false], "first frame vertical: {first_vertical}"); + assert_eq!(frame_box(&s, b), saved, "first frame vertical: {first_vertical}"); + assert!(flows_as_laid_out(&s, &[a, b]), "first frame vertical: {first_vertical}"); + } +} + /// One count runs through a thread: the second frame's numbered paragraphs go on from the /// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a /// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4ad577651..b349ef894 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -16,15 +16,21 @@ use super::{ pub fn specs() -> Vec { vec![ - cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| { - orientation(s, p, true) - }), + cmd!( + "type.orientation.vertical", + "Vertical", + ["Type", "Orientation"], + None, + "{ids?|id?} set vertical writing for the selected type and every frame of its threads → {vertical, ids}", + has_selection, + |s, p| orientation(s, p, true) + ), cmd!( "type.orientation.horizontal", "Horizontal", ["Type", "Orientation"], None, - "{ids?|id?} set horizontal writing", + "{ids?|id?} set horizontal writing for the selected type and every frame of its threads → {vertical, ids}", has_selection, |s, p| orientation(s, p, false) ), @@ -139,8 +145,10 @@ pub fn specs() -> Vec { ] } +/// Type › Type Orientation: set vertical or horizontal writing on every selected text object and +/// every frame of their threads, in one undo step. fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { - let ids = text_targets(s, p, "Text Orientation")?; + let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Text Orientation")?); s.edit("Text Orientation", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 82946ac29..fc2530c03 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -54,6 +54,8 @@ pub struct Prefs { /// Scale Strokes & Effects. pub scale_strokes: bool, pub zoom_with_mouse_wheel: bool, + /// While Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls sideways instead of zooming. + pub ctrl_wheel_scrolls_sideways: bool, /// A horizontal drag on a numeric field or its label steps its value (#400). pub scrub_numeric_fields: bool, /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. @@ -65,6 +67,8 @@ pub struct Prefs { pub paste_offset: f64, // Selection & Anchor Display pub selection_tolerance: f64, + /// Selection-tool marquee: touching (default), enclosed, or screen drag direction. + pub marquee_selection: String, pub object_selection_by_path_only: bool, pub snap_to_point_tolerance: f64, pub ctrl_click_selects_behind: bool, @@ -95,6 +99,9 @@ pub struct Prefs { /// font is highlighted, in every document (on by default). pub highlight_substituted_fonts: bool, pub placeholder_text: bool, + /// Type › Start Lists From Typed Markers: a list marker and a space typed at the start of a + /// paragraph start a list (on by default). Off, they stay as typed text. + pub typed_lists: bool, // Units pub units_general: String, pub units_stroke: String, @@ -367,11 +374,13 @@ impl Default for Prefs { scale_corners: false, scale_strokes: false, zoom_with_mouse_wheel: false, + ctrl_wheel_scrolls_sideways: true, scrub_numeric_fields: true, trackpad_rotate_view: true, hold_tool_keys: true, paste_offset: 10.0, selection_tolerance: 3.0, + marquee_selection: "touching".into(), object_selection_by_path_only: false, snap_to_point_tolerance: 2.0, ctrl_click_selects_behind: true, @@ -399,6 +408,7 @@ impl Default for Prefs { highlight_alternate_glyphs: false, highlight_substituted_fonts: true, placeholder_text: true, + typed_lists: true, units_general: s("points"), units_stroke: s("points"), units_type: s("points"), diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 9e397975f..a94696d16 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3221,6 +3221,316 @@ fn image_trace_release_and_expand() { assert!(s.execute("imageTrace.release", &json!({})).is_err()); } +/// Expand by color: each color's paths in a group of their own, named by the color, the groups +/// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a +/// sublayer for each color. +#[test] +#[allow(clippy::cognitive_complexity)] // Exercises several color and stacking cases in one trace fixture. +fn image_trace_expand_by_color_groups_each_colors_paths() { + // A light field with a black ring and a red square on it. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 75.0); + if (34.0..=40.0).contains(&r) { + [0, 0, 0, 255] + } else if (130..170).contains(&x) && (55..95).contains(&y) { + [200, 40, 50, 255] + } else { + [240, 240, 220, 255] + } + }); + let names = |n: &Node| n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect::>(); + let hex = |n: &Node| n.appearance.fill_paint().color().unwrap().to_hex(); + let shot = |s: &Session| { + let img = render_doc(s, false); + (0..200).flat_map(|x| (0..150).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::>() + }; + for method in ["abutting", "overlapping"] { + let params = json!({"mode": "color", "palette": "limited", "colors": 3, "method": method}); + let expand = |by_color: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params, "byColor": by_color})).unwrap(); + let g = NodeId(r["id"].as_u64().unwrap()); + (s, g) + }; + let (flat, fg) = expand(false); + let (mut s, g) = expand(true); + let paths = node(&flat, fg).children().unwrap().clone(); + // Without byColor (the default) the paths sit in the trace's group, with no group of their own. + assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}"); + let (default, dg) = { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) + }; + assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given"); + // The colors in the order they first come in, bottom to top. + let mut order: Vec = vec![]; + for p in &paths { + if !order.contains(&hex(p)) { + order.push(hex(p)); + } + } + let mut sorted = order.clone(); + sorted.sort(); + assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}"); + let n = node(&s, g); + assert_eq!(names(&n), order, "{method}"); + // Each group holds its color's paths, and in turn they are the paths of the trace without + // byColor, in their order. + let groups = n.children().unwrap(); + for c in groups { + assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}"); + assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name); + } + let members: Vec> = groups.iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect(); + assert!(members == paths, "{method}"); + assert!(shot(&s) == shot(&flat), "{method}: the art changed"); + // Release to Layers: a sublayer for each color, in the same order. + let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap(); + let subs = ids(&r); + assert_eq!(subs.len(), 3, "{method}"); + for (sub, name) in subs.iter().zip(&order) { + let l = node(&s, *sub); + assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}"); + assert_eq!(names(&l), std::slice::from_ref(name), "{method}"); + } + } + + // imageTrace.expand takes it too, as one undo step. + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": params})).unwrap()["id"].as_u64().unwrap()); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + + // A trace of one color gives one group holding every path. + let mut one = session(); + let bar = + vectorcraft_trace::Raster::from_fn(80, 40, |x, y| if (10..70).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] }); + add_image(&mut one, &bar, Affine::IDENTITY); + let r = one.execute("imageTrace.makeAndExpand", &json!({"params": {"ignoreWhite": true}, "byColor": true})).unwrap(); + let og = node(&one, NodeId(r["id"].as_u64().unwrap())); + assert_eq!(names(&og), ["#000000"]); + let only = &og.children().unwrap()[0]; + assert!(!only.children().unwrap().is_empty() && only.children().unwrap().iter().all(|m| hex(m) == "#000000")); + + // Auto Grouping's groups are not split: refused, and the trace stays as it was. + let mut grouped = params.clone(); + grouped["autoGrouping"] = json!(true); + let e = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": grouped, "byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": grouped})).unwrap()["id"].as_u64().unwrap()); + let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + let e = s.execute("imageTrace.expand", &json!({"byColor": "yes"})).unwrap_err().to_string(); + assert!(e.contains("true or false"), "{e}"); + // Refused too when Auto Grouping grouped nothing, as with side-by-side stripes. + let mut s = session(); + let stripes = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| match x / 20 { + 0 => [200, 40, 40, 255], + 1 => [40, 160, 60, 255], + _ => [40, 70, 200, 255], + }); + add_image(&mut s, &stripes, Affine::IDENTITY); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap()); + assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. }))); + let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + + // A color linked to a global swatch goes by the swatch's name, a tint with its percentage. + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) { + (true, _) => [200, 40, 40, 255], + (false, true) => [40, 160, 60, 255], + (false, false) => [40, 70, 200, 255], + }); + add_image(&mut s, &r, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec<(u64, String)> = + node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect(); + let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect(); + colors.sort(); + colors.dedup(); + assert_eq!(colors, ["#2846c8", "#28a03c", "#c82828"]); + // The red paths link to Ink and the blue ones to Sky at 50%; green stays a plain color. + let pick = |want: &str| paths.iter().filter(|(_, h)| h == want).map(|(id, _)| *id).collect::>(); + s.execute("paint.setFill", &json!({"ids": pick("#c82828"), "swatch": "Ink"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": pick("#2846c8"), "swatch": "Sky", "tint": 50})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]); + // A swatch named as another color's #rrggbb still gets a group of its own. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]); + + // Gradient fills share one group, and a stroked line goes by its stroke. + let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| { + let t = (f64::from(x) + 0.5) / 100.0; + [(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255] + }); + let line = + vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] }); + for (art, params, want) in [ + (ramp, json!({"preset": "6 Colors", "params": {"gradients": true}, "byColor": true}), "Gradients"), + (line, json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}, "byColor": true}), "#000000"), + ] { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let res = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + assert_eq!(names(&node(&s, NodeId(res["id"].as_u64().unwrap()))), [want]); + } + + // Flat Logo stacks its shapes biggest first without cutting holes under white: a black ring's + // outline, a white disc on it and a black dot on that. Black keeps a group below white and one + // above it, and the art draws as before, through Make and Expand and through Expand. + let target = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| { + let r = (f64::from(x) + 0.5 - 60.0).hypot(f64::from(y) + 0.5 - 60.0); + if r < 15.0 || (30.0..50.0).contains(&r) { + [0, 0, 0, 255] + } else if r < 30.0 { + [255, 255, 255, 255] + } else { + [0, 0, 0, 0] + } + }); + let logo = json!({"mode": "logo", "logoColors": ["#000000"]}); + let traced = |by_color: bool, expand_after: bool| { + let mut s = session(); + add_image(&mut s, &target, Affine::IDENTITY); + let g = if expand_after { + let g = s.execute("imageTrace.make", &json!({"params": logo})).unwrap()["id"].as_u64().unwrap(); + s.execute("imageTrace.expand", &json!({"byColor": by_color})).unwrap(); + g + } else { + s.execute("imageTrace.makeAndExpand", &json!({"params": logo, "byColor": by_color})).unwrap()["id"].as_u64().unwrap() + }; + (s, NodeId(g)) + }; + for expand_after in [false, true] { + let (flat, fg) = traced(false, expand_after); + let (s, g) = traced(true, expand_after); + let paths = node(&flat, fg).children().unwrap().clone(); + assert_eq!(paths.iter().map(|p| hex(p)).collect::>(), ["#000000", "#ffffff", "#000000"]); + assert_eq!(names(&node(&s, g)), ["#000000", "#ffffff", "#000000"], "expand after: {expand_after}"); + let members: Vec> = node(&s, g).children().unwrap().iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect(); + assert!(members == paths, "expand after: {expand_after}"); + assert!(shot(&s) == shot(&flat), "expand after: {expand_after}: the art changed"); + } + + // An Abutting trace's top path, given the bottom path's color and moved onto the middle one (by + // a move or by a Transform effect), moved clear of it with a drop shadow cast onto it, or moved + // to share a pixel at its edge, before Expand, still draws on top of it. + let stripes = vectorcraft_trace::Raster::from_fn(90, 30, |x, _| match x / 30 { + 0 => [200, 40, 40, 255], + 1 => [40, 160, 60, 255], + _ => [40, 70, 200, 255], + }); + let edited = |by_color: bool, how: &str| { + let mut s = session(); + add_image(&mut s, &stripes, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect(); + assert_eq!(paths.len(), 3); + let (first, middle, last) = (&paths[0], &paths[1], &paths[2]); + s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap(); + let (to, from) = (middle.visual_bounds().unwrap(), last.visual_bounds().unwrap()); + let (dx, dy) = (to.x0 - from.x0, to.y0 - from.y0); + match how { + "beside" => { + // The middle path and the moved top path end and start inside one pixel. + s.execute("object.move", &json!({"ids": [middle.id.0], "dx": -0.5, "dy": 0})).unwrap(); + s.execute("object.move", &json!({"ids": [last.id.0], "dx": -0.3, "dy": 0})).unwrap() + } + "move" => s.execute("object.move", &json!({"ids": [last.id.0], "dx": dx, "dy": dy})).unwrap(), + "transform" => { + s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "distort.transform", "params": {"moveH": dx, "moveV": dy}})).unwrap() + } + _ => { + s.execute("object.move", &json!({"ids": [last.id.0], "dx": 40, "dy": 0})).unwrap(); + s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "stylize.dropShadow", "params": {"x": -70, "y": 0, "blur": 0}})) + .unwrap() + } + }; + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": by_color})).unwrap(); + (s, g) + }; + for how in ["move", "transform", "shadow", "beside"] { + let (flat, _) = edited(false, how); + let (s, g) = edited(true, how); + assert_eq!(names(&node(&s, g)).len(), 3, "{how}"); + assert!(shot(&s) == shot(&flat), "{how}: the edited art changed"); + } + + // Two tints of one swatch that round to the same percentage are still two colors. + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + add_image(&mut s, &stripes, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect(); + assert_eq!(paths.len(), 3); + s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let groups = node(&s, g).children().unwrap().clone(); + assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]); + assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::>(), [1, 2]); +} + +#[test] +fn image_trace_expand_by_color_preserves_live_pathfinder_art() { + let mut s = session(); + let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, y| { + if (10..40).contains(&y) && ((10..35).contains(&x) || (65..90).contains(&x)) { [0, 0, 0, 255] } else { [255, 255, 255, 255] } + }); + add_image(&mut s, &art, Affine::IDENTITY); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| c.path_data().is_some()).map(|c| c.id).collect(); + assert_eq!(paths.len(), 2); + s.execute("select.set", &json!({"ids": [paths[1].0]})).unwrap(); + s.execute("object.move", &json!({"dx": -45, "dy": 0})).unwrap(); + s.execute("effect.apply", &json!({"ids": [g.0], "effect": "pathfinder.subtract"})).unwrap(); + let before = render_doc(&s, false); + let before_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| before.pixel(x, y)).collect(); + let original = node(&s, g); + let steps = s.doc().unwrap().history.undo.len(); + let result = s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})); + if let Err(e) = &result { + assert!(e.to_string().contains("Pathfinder"), "{e}"); + assert_eq!(node(&s, g), original); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + } + let after = render_doc(&s, false); + let after_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| after.pixel(x, y)).collect(); + let changed = after_pixels.iter().zip(&before_pixels).filter(|(a, b)| a != b).count(); + assert_eq!(changed, 0, "byColor changed {changed} live Pathfinder pixels: {result:?}"); + assert!(result.is_err(), "byColor must refuse a live Pathfinder operand group"); + let error = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": {"ignoreWhite": true}, "byColor": true})).unwrap_err(); + assert!(error.to_string().contains("Pathfinder"), "{error}"); + assert_eq!(node(&s, g), original); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + #[test] fn image_trace_make_and_expand_and_color_presets() { let mut s = session(); diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index 4ca4b46fa..6a6022fdb 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -228,6 +228,40 @@ fn diagonal_shift_snap_lands_an_edge_on_a_gridline() { assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}"); } +#[test] +fn snap_moves_objects_at_different_depths_by_one_plane_offset() { + let (mut s, _) = persp_session(); + let rect = |s: &mut Session, x: f64| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) + }; + let near = rect(&mut s, 240.0); + s.execute("perspective.attach", &json!({"ids": [near.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap(); + let far = rect(&mut s, 150.0); + s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap(); + assert_eq!(attached(&s, near), Some((Plane::Left, 0.0))); + assert_eq!(attached(&s, far), Some((Plane::Left, 50.0))); + s.execute("perspective.grid.snap", &json!({"on": true})).unwrap(); + let g = grid(&s); + let (near_before, far_before) = (plane_pts(&s, near, Plane::Left, 0.0), plane_pts(&s, far, Plane::Left, 50.0)); + let b = bounds_of(&near_before).union(bounds_of(&far_before)); + // A move that ends 0.3 pt past a gridline crossing on the near object's plane. + let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v; + let start = bounds_of(&near_before).center(); + let h = g.homography_at(Plane::Left, 0.0).unwrap(); + let from = h.apply(start).unwrap(); + let to = h.apply(start + Vec2::new(past(b.x0), past(b.y0))).unwrap(); + s.execute("perspective.move", &json!({"ids": [near.0, far.0], "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); + let near_delta = plane_pts(&s, near, Plane::Left, 0.0)[0] - near_before[0]; + let far_delta = plane_pts(&s, far, Plane::Left, 50.0)[0] - far_before[0]; + assert!((near_delta - far_delta).hypot() < 1e-6, "near {near_delta:?}, far {far_delta:?}"); + let on_line = |v: f64| ((v / g.cell).round() * g.cell - v).abs() < 1e-6; + let moved = b + near_delta; + assert!(on_line(moved.x0) || on_line(moved.x1), "{moved:?}"); + assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}"); +} + #[test] fn constrained_move_uses_each_objects_plane() { let (mut s, right) = persp_session(); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 437701136..52755f2cb 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -7,6 +7,32 @@ use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; use crate::*; +/// The saved preference reaches the real tool; older preferences keep Touching and invalid +/// values fail without changing the current setting. +#[test] +fn marquee_selection_preference_drives_the_selection_tool() { + assert_eq!(serde_json::from_value::(json!({})).unwrap().marquee_selection, "touching"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (mode, selected) in [("touching", true), ("enclosed", false), ("directional", false)] { + set_pref(&mut s, "marqueeSelection", json!(mode)); + s.execute("select.none", &json!({})).unwrap(); + for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 150.0), (PointerKind::Up, 150.0, 150.0)] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + assert_eq!(!s.doc().unwrap().selection.objects.is_empty(), selected, "{mode}"); + let persisted = s.prefs.to_json(); + assert_eq!(serde_json::from_value::(persisted).unwrap().marquee_selection, mode); + } + assert!(s.execute("prefs.set", &json!({"key": "marqueeSelection", "value": "unknown"})).is_err()); + assert_eq!(s.prefs.marquee_selection, "directional"); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.marquee_selection, "touching"); +} + #[test] fn every_spec_matches_a_prefs_field_and_back() { let all = Prefs::default().to_json(); @@ -2511,6 +2537,20 @@ fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() { } } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel starts on: in a new session, in a preferences +/// file saved before the option existed and after a reset. +#[test] +fn scroll_sideways_with_cmd_wheel_is_on_by_default() { + assert!(Prefs::default().ctrl_wheel_scrolls_sideways); + let old: Prefs = serde_json::from_value(json!({"zoomWithMouseWheel": false})).unwrap(); + assert!(old.ctrl_wheel_scrolls_sideways); + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(!s.prefs.ctrl_wheel_scrolls_sideways); + s.execute("prefs.reset", &json!({})).unwrap(); + assert!(s.prefs.ctrl_wheel_scrolls_sideways); +} + /// The Transform panel menu's choice starts as `default` (the panels follow General › Transform /// Pattern Tiles): in a new session, in a preferences file saved before the choice existed and /// after a reset; `prefs.set` takes it and the three menu choices, and nothing else. diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index ed3e2eac3..a895655b9 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1047,18 +1047,20 @@ fn list_markers(s: &Session, id: NodeId) -> Vec { } /// Typed list detection: at the start of a paragraph, "1.", "1)", "A." or "A)" and a space start -/// a numbered list, and "*", "#", ">" or "+" and a space start a bulleted list with the filled dot. -/// The typed characters go, the paragraph takes the hanging indent, and the text typed next is the -/// list item's. One Undo puts the typed characters back with no list, and typing goes on after -/// them, also after a Backspace that deleted nothing. +/// a numbered list, and "*", "+", "#" or ">" and a space start a bulleted list with the filled dot, +/// the hollow circle, the filled square or the dash. The typed characters go, the paragraph takes +/// the hanging indent, and the text typed next is the list item's. One Undo puts the typed +/// characters back with no list, and typing goes on after them, also after a Backspace that +/// deleted nothing. #[test] fn typing_a_list_start_and_a_space_starts_a_list() { use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; let v = ViewInfo::default(); - let (arabic, letters, dot) = - (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); + let (arabic, letters) = (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters)); let cases = [("1.", arabic, "1."), ("1)", arabic, "1."), ("A.", letters, "A."), ("A)", letters, "A.")]; - let bullets = ["*", "#", ">", "+"].map(|b| (b, dot, "\u{2022}")); + let bullets = + [("*", Bullet::Dot, "\u{2022}"), ("+", Bullet::Circle, "\u{25E6}"), ("#", Bullet::Square, "\u{25AA}"), (">", Bullet::Dash, "\u{2013}")] + .map(|(b, bullet, marker)| (b, ParaList::bulleted(bullet), marker)); for (typed, list, marker) in cases.into_iter().chain(bullets) { let mut s = session(); s.select_tool("type", v).unwrap(); @@ -1132,11 +1134,65 @@ fn the_caret_follows_undo_and_redo_of_a_typed_list() { } } +/// Type › Start Lists From Typed Markers off: a list marker and a space typed at the start of a +/// paragraph stay as text and start no list; `text.startList` still starts one. +#[test] +fn typed_lists_off_leaves_typed_markers_as_text() { + let v = ViewInfo::default(); + for typed in ["1.", "1)", "A.", "A)", "*", "#", ">", "+"] { + let mut s = session(); + s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + for c in typed.chars().chain(" a".chars()) { + s.tool_text(&c.to_string(), v).unwrap(); + } + let t = obj(&s, id); + assert_eq!((t.plain_text(), t.para_at(0).list), (format!("{typed} a"), None), "{typed}"); + assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"), "{typed}"); + // Turned back on, the same typing in a new paragraph starts a list. + s.execute("prefs.set", &json!({"key": "typedLists", "value": true})).unwrap(); + key(&mut s, ToolKey::Enter, Mods::default()); + for c in typed.chars().chain(" ".chars()) { + s.tool_text(&c.to_string(), v).unwrap(); + } + let t = obj(&s, id); + assert_eq!((t.plain_text(), t.para_at(0).list, t.para_at(1).list.is_some()), (format!("{typed} a\n"), None, true), "on: {typed}"); + assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Bullets and Numbering"), "on: {typed}"); + } + let mut s = session(); + s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap(); + let id = text(&mut s, "1. a"); + let r = s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap(); + assert_eq!(r["started"], true); + assert_eq!(obj(&s, id).plain_text(), "a"); + assert!(obj(&s, id).para_at(0).list.is_some()); +} + +/// `typedLists` is a Type preference, on by default: `prefs.list` lists it as a bool, and +/// `prefs.reset` of the Type category turns it back on. +#[test] +fn typed_lists_is_a_type_preference_on_by_default() { + let mut s = Session::new(); + assert!(s.prefs.typed_lists); + let l = s.execute("prefs.list", &json!({})).unwrap(); + let row = l.as_array().unwrap().iter().find(|e| e["key"] == "typedLists").unwrap().clone(); + assert_eq!( + (&row["category"], &row["kind"], &row["label"], &row["value"]), + (&json!("Type"), &json!("bool"), &json!("Start Lists From Typed Markers"), &json!(true)) + ); + s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap(); + assert!(!s.prefs.typed_lists); + s.execute("prefs.reset", &json!({"category": "Type"})).unwrap(); + assert!(s.prefs.typed_lists); +} + /// The same characters later in a paragraph, without the space, typed in one piece with the space /// (a paste), or at the start of a paragraph that is already in a list start nothing. #[test] fn list_starts_typed_elsewhere_or_pasted_start_no_list() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; let v = ViewInfo::default(); let mut s = session(); s.select_tool("type", v).unwrap(); @@ -1151,12 +1207,26 @@ fn list_starts_typed_elsewhere_or_pasted_start_no_list() { for c in "1.Pears".chars() { s.tool_text(&c.to_string(), v).unwrap(); } - key(&mut s, ToolKey::Enter, Mods::default()); - s.tool_text("* ", v).unwrap(); + for pasted in ["* ", "+ ", "# ", "> "] { + key(&mut s, ToolKey::Enter, Mods::default()); + s.tool_text(pasted, v).unwrap(); + } let t = obj(&s, id); - assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* "); - assert_eq!((0..4).map(|i| t.para_at(i).list).collect::>(), [None, None, None, None]); + assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* \n+ \n# \n> "); + assert!((0..7).all(|i| t.para_at(i).list.is_none())); assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering")); + // A paragraph in a bulleted list keeps its bullet when another list start is typed before it. + let id = text(&mut s, "Item"); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "bullet": "dot"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + click(&mut s, 101.0, 95.0); + s.set_tool_option("select", &json!({"start": 0, "end": 0})); + for c in "+ ".chars() { + s.tool_text(&c.to_string(), v).unwrap(); + } + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("+ Item", Some(ParaList::bulleted(Bullet::Dot)))); + assert_eq!(list_markers(&s, id), ["\u{2022}"]); // A paragraph in a list keeps its list, and the typed characters stay as text. let id = text(&mut s, "Item"); s.execute("text.setFormat", &json!({"ids": [id.0], "list": "number"})).unwrap(); @@ -1188,10 +1258,16 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() { assert_eq!(list_markers(&s, id), ["1."]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(1).list), ("Fruit\n1) Apples", None)); - let id = text(&mut s, "x\n# "); - let r = s.execute("text.startList", &json!({"id": id.0})).unwrap(); - assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string())); - assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Dot))); + for (typed, bullet, marker) in + [("* ", Bullet::Dot, "\u{2022}"), ("+ ", Bullet::Circle, "\u{25E6}"), ("# ", Bullet::Square, "\u{25AA}"), ("> ", Bullet::Dash, "\u{2013}")] + { + let id = text(&mut s, &format!("x\n{typed}")); + let r = s.execute("text.startList", &json!({"id": id.0})).unwrap(); + assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()), "{typed:?}"); + assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(bullet)), "{typed:?}"); + s.execute("text.editRange", &json!({"id": id.0, "start": 2, "end": 2, "insert": "Item"})).unwrap(); + assert_eq!(list_markers(&s, id), [marker], "{typed:?}"); + } let steps = undo_labels(&s).len(); let id = text(&mut s, "1.x"); assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap()["started"], json!(false)); diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 3e522a552..6b4ad8706 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -91,6 +91,25 @@ fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() { assert_eq!(s.tool_options()["segments"], json!(14)); } +/// Escape on a grid drag puts back the dividers its arrows changed, in the saved options too, +/// while an option set during the drag stays. +#[test] +fn escape_on_a_drag_saves_the_options_it_put_back() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + select(&mut s, "rectangularGrid"); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap(); + s.tool_key(ToolKey::Up, Mods::default(), v).unwrap(); + // Saves every option, the rows the arrow changed included. + s.set_tool_option("columns", &json!(8)); + assert_eq!(s.prefs.tool_settings["rectangularGrid"]["rows"], json!(6)); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + let saved = &s.prefs.tool_settings["rectangularGrid"]; + assert_eq!((saved["rows"].clone(), saved["columns"].clone()), (json!(5), json!(8))); +} + /// A dropped press (a long touch, a canceled touch, a second finger) keeps nothing it began: the /// tool ends at rest with the options it had before the press, and the options the drag changed /// are not stored. A release stores them. diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1871722c3..c1c4df67d 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -319,6 +319,11 @@ impl Session { slices_locked: self.menu.slices_locked, auto_add_delete: !self.prefs.disable_auto_add_delete, selection_tolerance: self.prefs.selection_tolerance, + marquee_mode: match self.prefs.marquee_selection.as_str() { + "enclosed" => vectorcraft_tools::MarqueeMode::Enclosed, + "directional" => vectorcraft_tools::MarqueeMode::Directional, + _ => vectorcraft_tools::MarqueeMode::Touching, + }, anchor_size: self.prefs.anchor_size, path_only: self.prefs.object_selection_by_path_only, type_path_only: self.prefs.type_selection_by_path_only, @@ -332,6 +337,7 @@ impl Session { pen_rubber_band: self.prefs.pen_rubber_band, curvature_rubber_band: self.prefs.curvature_rubber_band, placeholder_text: self.prefs.placeholder_text, + typed_lists: self.prefs.typed_lists, smart_guide_color: rgb(&self.prefs.smart_guide_color, vectorcraft_tools::guides::MAGENTA), alignment_guides: self.prefs.alignment_guides, anchor_path_labels: self.prefs.anchor_path_labels, @@ -577,6 +583,11 @@ impl Session { } let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); self.take_tool_panic()?; + // A key that cancels a gesture may put back options the gesture changed (Escape on an + // arc, spiral or grid drag): keep them. + if acts.contains(&Action::Cancel) { + self.keep_tool_settings(); + } self.apply_actions(acts) } diff --git a/crates/engine/tests/fixtures/heif/quadrants-10bit.heic b/crates/engine/tests/fixtures/heif/quadrants-10bit.heic new file mode 100644 index 000000000..ab588a1d8 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-10bit.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic b/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic new file mode 100644 index 000000000..0e9a72342 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-alpha.heic b/crates/engine/tests/fixtures/heif/quadrants-alpha.heic new file mode 100644 index 000000000..9465f67bc Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-alpha.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-grid.heic b/crates/engine/tests/fixtures/heif/quadrants-grid.heic new file mode 100644 index 000000000..5997b3dda Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-grid.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-grid16.heic b/crates/engine/tests/fixtures/heif/quadrants-grid16.heic new file mode 100644 index 000000000..dcbcccacf Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-grid16.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants.heic b/crates/engine/tests/fixtures/heif/quadrants.heic new file mode 100644 index 000000000..ebf7f1d45 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic b/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic new file mode 100644 index 000000000..9671f346a Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones-p3-icc.heic b/crates/engine/tests/fixtures/heif/tones-p3-icc.heic new file mode 100644 index 000000000..da0c41068 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones-p3-icc.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones-p3-nclx.heic b/crates/engine/tests/fixtures/heif/tones-p3-nclx.heic new file mode 100644 index 000000000..6e7bbc62d Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones-p3-nclx.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones.heic b/crates/engine/tests/fixtures/heif/tones.heic new file mode 100644 index 000000000..3ab9e509b Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones.heic differ diff --git a/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic b/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic new file mode 100644 index 000000000..b18e5b9ee Binary files /dev/null and b/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic differ diff --git a/crates/engine/tests/fixtures/heif/wide-rotated.heic b/crates/engine/tests/fixtures/heif/wide-rotated.heic new file mode 100644 index 000000000..d6095829e Binary files /dev/null and b/crates/engine/tests/fixtures/heif/wide-rotated.heic differ diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 73c497032..59d079bcd 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1934,6 +1934,33 @@ proptest! { } } +proptest! { + #![proptest_config(config())] + + /// Mutated HEIF boxes and HEVC data must return errors rather than unwind. + #[test] + fn mutated_heif_files_do_not_panic(which in 0usize..4, cut in 0usize..1300, edits in prop::collection::vec((0usize..1300, any::()), 0..8)) { + let mut bytes = match which { + 0 => include_bytes!("fixtures/heif/quadrants.heic").to_vec(), + 1 => include_bytes!("fixtures/heif/quadrants-alpha.heic").to_vec(), + 2 => include_bytes!("fixtures/heif/quadrants-grid.heic").to_vec(), + // Its stream's VUI gives the color description. + _ => include_bytes!("fixtures/heif/tones-p3-icc-only.heic").to_vec(), + }; + for (at, byte) in edits { + if let Some(slot) = bytes.get_mut(at) { *slot = byte; } + } + bytes.truncate(cut); + let result = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let params = json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(&bytes)}); + let _ = no_panic(s.execute("document.open", ¶ms)); + let _ = no_panic(s.execute("file.place", ¶ms)); + }); + prop_assert!(result.is_ok(), "a.heic: panicked: {:?}", result.err()); + } +} + /// The rich document with a hidden layer, saved as SVG with its editing data. fn saved_svg() -> String { let mut d = rich_doc(); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index dbdbf0066..93adbf245 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -21,8 +21,9 @@ use vello_cpu::color::PremulRgba8; use crate::ink::Ink; -/// Largest default budget of the shared image store, in bytes; less on machines with under 8 GiB -/// of memory (an eighth of it). [`set_image_cache_budget`] changes it. +/// Default budget of the shared image store, in bytes, where the installed memory can't be read. +/// Elsewhere the default is a quarter of the installed memory, and on the web it is 256 MiB. +/// [`set_image_cache_budget`] changes it. pub const IMAGE_CACHE_MAX: usize = 1 << 30; /// The default budget on the web (a tab's memory is tight). @@ -33,14 +34,21 @@ fn default_budget() -> usize { #[cfg(not(target_arch = "wasm32"))] fn default_budget() -> usize { - physical_memory().map_or(IMAGE_CACHE_MAX, budget_for) + budget_for_reading(physical_memory()) } -/// The default budget on a machine with `memory` bytes installed: an eighth of it, at most -/// [`IMAGE_CACHE_MAX`]. +/// The default budget for a reading of the installed memory: [`IMAGE_CACHE_MAX`] when `memory` is +/// `None` or 0, otherwise [`budget_for`] it. +#[cfg(not(target_arch = "wasm32"))] +fn budget_for_reading(memory: Option) -> usize { + memory.filter(|&m| m > 0).map_or(IMAGE_CACHE_MAX, budget_for) +} + +/// The default budget on a machine with `memory` bytes installed: a quarter of it, and at most a +/// quarter of the address space (on 32-bit builds). #[cfg(not(target_arch = "wasm32"))] fn budget_for(memory: u64) -> usize { - usize::try_from(memory / 8).unwrap_or(usize::MAX).min(IMAGE_CACHE_MAX) + usize::try_from(memory / 4).unwrap_or(usize::MAX).min(usize::MAX / 4) } /// Installed memory in bytes, `None` where it isn't known (the web, other systems). Other crates @@ -52,13 +60,116 @@ pub fn installed_memory() -> Option { physical_memory() } -/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`). +/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`), or the memory limit of the process's +/// cgroups where that is lower. #[cfg(target_os = "linux")] fn physical_memory() -> Option { let info = std::fs::read_to_string("/proc/meminfo").ok()?; let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; - kib.checked_mul(1024) + let total = kib.checked_mul(1024)?; + let cgroups = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default(); + let mounts = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default(); + Some(cgroup_limit(&cgroups, &mounts, |path| std::fs::read_to_string(path).ok()).map_or(total, |limit| limit.min(total))) +} + +/// The lowest memory limit of the cgroups listed in `/proc/self/cgroup` (`cgroups`) and of their +/// ancestors within the mounted cgroup hierarchy: `memory.max` under cgroup v2 and +/// `memory.limit_in_bytes` under cgroup v1. Each cgroup is looked up under the mount in +/// `/proc/self/mountinfo` (`mounts`) whose root holds it, or under `/sys/fs/cgroup` (v2) or +/// `/sys/fs/cgroup/memory` (v1) when `mounts` lists no mount of its hierarchy; a cgroup that no +/// listed mount holds sets no limit. A mount whose root lies above the cgroup namespace's root +/// (`/..`) gives the limit of its mount point alone. `read` reads a file; a file that can't be read, or that holds +/// `max`, sets no limit. +#[cfg(any(target_os = "linux", test))] +fn cgroup_limit(cgroups: &str, mounts: &str, read: impl Fn(&str) -> Option) -> Option { + let mut lowest: Option = None; + for line in cgroups.lines() { + let mut fields = line.splitn(3, ':'); + let (Some(_), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else { + continue; + }; + let v2 = controllers.is_empty(); + if !v2 && !controllers.split(',').any(|c| c == "memory") { + continue; + } + let file = if v2 { "memory.max" } else { "memory.limit_in_bytes" }; + let mounted = cgroup_mounts(mounts, v2); + // Every mount that holds the cgroup shows the same hierarchy; each may show more ancestors. + let holding: Vec<(String, String)> = if mounted.is_empty() { + vec![(path.to_owned(), (if v2 { "/sys/fs/cgroup" } else { "/sys/fs/cgroup/memory" }).to_owned())] + } else { + mounted + .into_iter() + .filter_map(|(root, point)| { + let root = root.trim_end_matches('/'); + // A root of `..` steps is an ancestor of the cgroup namespace's root, so of every + // cgroup listed: only the mount point's own limit can be read. + if !root.is_empty() && root.split('/').skip(1).all(|step| step == "..") { + return Some((String::new(), point)); + } + let rest = path.strip_prefix(root).filter(|rest| rest.is_empty() || rest.starts_with('/'))?; + Some((rest.to_owned(), point)) + }) + .collect() + }; + for (relative, mount_point) in &holding { + let root = mount_point.trim_end_matches('/'); + let mut dir = relative.trim_end_matches('/'); + loop { + if let Some(limit) = read(&format!("{root}{dir}/{file}")).and_then(|s| s.trim().parse::().ok()) { + lowest = Some(lowest.map_or(limit, |l| l.min(limit))); + } + match dir.rsplit_once('/') { + Some((parent, _)) => dir = parent, + None => break, + } + } + } + } + lowest +} + +/// The root and the mount point of each cgroup v2 mount (`v2`) or cgroup v1 mount with the +/// `memory` controller in `/proc/self/mountinfo` (`mounts`), with their octal escapes decoded. +#[cfg(any(target_os = "linux", test))] +fn cgroup_mounts(mounts: &str, v2: bool) -> Vec<(String, String)> { + mounts + .lines() + .filter_map(|line| { + let (before, after) = line.split_once(" - ")?; + let mut fields = before.split_whitespace().skip(3); + let (root, point) = (fields.next()?, fields.next()?); + let mut after = after.split_whitespace(); + let (fs, _, options) = (after.next()?, after.next()?, after.next().unwrap_or("")); + let found = if v2 { fs == "cgroup2" } else { fs == "cgroup" && options.split(',').any(|o| o == "memory") }; + found.then(|| (mountinfo_unescape(root), mountinfo_unescape(point))) + }) + .collect() +} + +/// A path field of `/proc/self/mountinfo` with its octal escapes (`\040` for a space) decoded. +#[cfg(any(target_os = "linux", test))] +fn mountinfo_unescape(field: &str) -> String { + let mut bytes = Vec::with_capacity(field.len()); + let mut rest = field.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let code = match tail { + [a @ b'0'..=b'3', c @ b'0'..=b'7', d @ b'0'..=b'7', ..] if b == b'\\' => Some(((a - b'0') << 6) | ((c - b'0') << 3) | (d - b'0')), + _ => None, + }; + match code { + Some(code) => { + bytes.push(code); + rest = tail.get(3..).unwrap_or_default(); + } + None => { + bytes.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&bytes).into_owned() } /// Installed memory in bytes (`GlobalMemoryStatusEx`). @@ -524,18 +635,148 @@ mod tests { drop(held); } + #[test] + #[cfg(target_pointer_width = "64")] + fn default_budget_is_a_quarter_of_installed_memory() { + assert_eq!(budget_for(16 << 30), 4 << 30); + assert_eq!(budget_for(64 << 30), 16 << 30, "no fixed ceiling"); + assert_eq!(budget_for(2 << 30), 512 << 20); + } + + #[test] + #[cfg(target_pointer_width = "64")] + fn budget_follows_the_memory_reading() { + assert_eq!(budget_for_reading(Some(2 << 30)), 512 << 20); + assert_eq!(budget_for_reading(Some(8 << 30)), 2 << 30); + assert_eq!(budget_for_reading(Some(64 << 30)), 16 << 30); + assert_eq!(budget_for_reading(None), IMAGE_CACHE_MAX, "memory that can't be read"); + assert_eq!(budget_for_reading(Some(0)), IMAGE_CACHE_MAX, "memory that reads as 0"); + } + #[test] #[cfg(not(target_arch = "wasm32"))] - fn default_budget_is_an_eighth_of_installed_memory_at_most_1_gib() { - assert_eq!(budget_for(4 << 30), 512 << 20); - assert_eq!(budget_for(8 << 30), IMAGE_CACHE_MAX); - assert_eq!(budget_for(64 << 30), IMAGE_CACHE_MAX); + fn images_past_the_budget_drop_the_least_recently_used() { + // 16 MiB installed (16 GiB scaled down): a 4 MiB budget, room for four 512 × 512 images. + let mut s = ImageStore::new(budget_for_reading(Some(16 << 20))); + for k in ["a", "b", "c", "d"] { + drop(s.insert(key(k), Arc::new(Pixmap::new(512, 512)))); + } + assert_eq!(s.bytes(), 4 << 20, "four images fill the budget exactly"); + assert!(["a", "b", "c", "d"].iter().all(|k| s.peek(&key(k)).is_some())); + drop(s.insert(key("e"), Arc::new(Pixmap::new(512, 512)))); + assert_eq!(s.bytes(), 4 << 20); + assert!(s.peek(&key("a")).is_none(), "the oldest image is dropped"); + assert!(["b", "c", "d", "e"].iter().all(|k| s.peek(&key(k)).is_some())); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn an_image_larger_than_the_budget_is_kept_only_while_drawn() { + // 16 MiB installed: a 4 MiB budget. A 1,100 × 1,000 image takes 4.2 MiB. + let budget = budget_for_reading(Some(16 << 20)); + let mut s = ImageStore::new(budget); + assert!(!s.keep(key("big"), Arc::new(Pixmap::new(1100, 1000))), "not kept for a later frame"); + assert_eq!(s.bytes(), 0); + drop(s.insert(key("small"), Arc::new(Pixmap::new(512, 512)))); + let drawn = s.insert(key("big"), Arc::new(Pixmap::new(1100, 1000))); + assert!(s.peek(&key("big")).is_some(), "kept while drawn"); + assert!(s.peek(&key("small")).is_none(), "the other image is dropped to make room"); + drop(drawn); + s.set_budget(budget); + assert!(s.peek(&key("big")).is_none(), "dropped once nothing draws it"); + assert_eq!(s.bytes(), 0); } #[test] #[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] fn installed_memory_is_read() { - assert!(physical_memory().is_some_and(|m| m >= 256 << 20), "{:?}", physical_memory()); + // A cgroup's limit can be far below the installed memory. + assert!(physical_memory().is_some_and(|m| m > 0), "{:?}", physical_memory()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn two_images_of_a_tenth_of_memory_each_stay_cached() { + // 16 MiB installed (16 GiB scaled down): two images of 1,600 KiB (640 × 640 pixels) take + // about a fifth of it. + let mut s = ImageStore::new(budget_for(16 << 20)); + drop(s.insert(key("a"), Arc::new(Pixmap::new(640, 640)))); + drop(s.insert(key("b"), Arc::new(Pixmap::new(640, 640)))); + assert!(s.peek(&key("a")).is_some() && s.peek(&key("b")).is_some(), "neither image is decoded again"); + assert_eq!(s.bytes(), 2 * 640 * 640 * 4); + } + + #[test] + fn cgroup_limit_is_the_lowest_limit_of_the_cgroups_and_their_ancestors() { + let files = |list: &'static [(&'static str, &'static str)]| { + move |path: &str| list.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()) + }; + let v2 = files(&[("/sys/fs/cgroup/user.slice/memory.max", "2147483648\n"), ("/sys/fs/cgroup/user.slice/app.scope/memory.max", "max\n")]); + assert_eq!(cgroup_limit("0::/user.slice/app.scope\n", "", v2), Some(2 << 30), "the parent's limit applies"); + let v1 = files(&[("/sys/fs/cgroup/memory/memory.limit_in_bytes", "1073741824\n")]); + let v1_mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu,cpuacct\n\ + 31 25 0:27 /docker/1 /sys/fs/cgroup/memory rw,nosuid master:9 - cgroup cgroup rw,memory\n"; + let cgroups = "4:cpu,cpuacct:/docker/1\n5:memory:/docker/1\n"; + assert_eq!(cgroup_limit(cgroups, v1_mounts, v1), Some(1 << 30), "a container's own mount"); + assert_eq!(cgroup_limit("0::/\n", "", files(&[("/sys/fs/cgroup/memory.max", "max\n")])), None, "no limit"); + assert_eq!(cgroup_limit("", "", files(&[])), None); + } + + #[test] + fn cgroup_limit_reads_a_subtree_mount_relative_to_its_root() { + let mounts = "25 1 0:22 / /proc rw - proc proc rw\n\ + 40 25 0:30 /docker/id /sys/fs/cgroup rw,nosuid - cgroup2 cgroup2 rw,nsdelegate\n"; + let files = [("/sys/fs/cgroup/worker/memory.max", "536870912\n"), ("/sys/fs/cgroup/memory.max", "4294967296\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/docker/id/worker\n", mounts, read), Some(512 << 20), "the worker's own limit"); + assert_eq!(cgroup_limit("0::/elsewhere\n", mounts, read), None, "no mount holds the cgroup"); + } + + #[test] + fn cgroup_limit_reads_the_mount_that_holds_the_cgroup() { + let mounts = "40 25 0:30 /other /mnt/other rw - cgroup2 cgroup2 rw\n\ + 41 25 0:30 /docker/id /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/mnt/other/memory.max", "1048576\n"), ("/sys/fs/cgroup/memory.max", "268435456\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/docker/id\n", mounts, read), Some(256 << 20), "the unrelated subtree's limit is not read"); + let both = "40 25 0:30 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n\ + 41 25 0:30 /parent/child /mnt/child rw - cgroup2 cgroup2 rw\n"; + let files = [("/mnt/child/memory.max", "max\n"), ("/sys/fs/cgroup/parent/memory.max", "1073741824\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/parent/child\n", both, read), Some(1 << 30), "the ancestor that only the full mount shows"); + } + + #[test] + fn cgroup_limit_reads_a_mount_above_the_namespace_root() { + let mounts = "40 25 0:30 /.. /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/sys/fs/cgroup/memory.max", "1073741824\n"), ("/sys/fs/cgroup/worker/memory.max", "4096\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/\n", mounts, read), Some(1 << 30), "the namespace root's ancestor"); + assert_eq!(cgroup_limit("0::/worker\n", mounts, read), Some(1 << 30), "the cgroup's directory is hidden"); + let v1 = "31 25 0:27 /../.. /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n"; + let files = [("/sys/fs/cgroup/memory/memory.limit_in_bytes", "536870912\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("5:memory:/\n", v1, read), Some(512 << 20)); + let sibling = "40 25 0:30 /../other /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/sys/fs/cgroup/memory.max", "1048576\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/\n", sibling, read), None, "a sibling of the namespace root is not an ancestor"); + } + + #[test] + fn cgroup_mounts_lists_the_hierarchy_of_the_memory_limit() { + let mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu\n\ + 31 25 0:27 /a\\040b /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n\ + 32 25 0:28 / /sys/fs/cgroup/unified rw - cgroup2 cgroup2 rw\n"; + assert_eq!(cgroup_mounts(mounts, false), [("/a b".to_owned(), "/sys/fs/cgroup/memory".to_owned())]); + assert_eq!(cgroup_mounts(mounts, true), [("/".to_owned(), "/sys/fs/cgroup/unified".to_owned())]); + assert!(cgroup_mounts("", true).is_empty()); + } + + #[test] + fn mountinfo_paths_have_their_escapes_decoded() { + assert_eq!(mountinfo_unescape(r"/a\040b\011c\012d\134e"), "/a b\tc\nd\\e"); + assert_eq!(mountinfo_unescape(r"/plain\9x\04"), r"/plain\9x\04", "not an escape"); } #[test] diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 1ff72bdd2..6e9334041 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -57,8 +57,9 @@ fn continues_cluster(prev: Option<&SGlyph>, g: Option<&SGlyph>) -> bool { /// The caret stops inside glyph `g` when it alone stands for several characters (grapheme clusters) /// of `text`, a ligature ([`PositionedGlyph::carets`]); `rtl` when its logical leading edge is its /// right edge; `upright` when it stands upright in vertical type, its carets then down its cell -/// ([`upright_extent`]). Empty for a glyph of one character. -fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usize, f64)> { +/// ([`upright_extent`]) as a share of `upright`, the length in font units its own width runs down. +/// Empty for a glyph of one character. +fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: Option) -> Vec<(usize, f64)> { use unicode_segmentation::UnicodeSegmentation; let Some(s) = text.get(g.byte..g.byte + g.len).filter(|_| g.len > 1) else { return Vec::new() }; let starts: Vec = s.grapheme_indices(true).skip(1).map(|(i, _)| g.byte + i).collect(); @@ -66,12 +67,13 @@ fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usi return Vec::new(); } let parts = starts.len() + 1; - let from_font = if upright { + let from_font = if let Some(length) = upright { let (cell, top) = upright_extent(g); g.face .vertical_ligature_carets(g.gid, starts.len(), top, cell) - .filter(|c| c.len() == starts.len() && cell > 0.0) - .map(|c| c.into_iter().map(|d| d / cell).collect::>()) + .filter(|c| c.len() == starts.len() && length > 0.0) + .map(|c| c.into_iter().map(|d| d / length).collect::>()) + .filter(|c| c.iter().all(|f| *f > 0.0 && *f < 1.0)) } else { let advance = g.face.advance(g.gid); g.face @@ -332,9 +334,31 @@ impl Ctx<'_> { Affine::IDENTITY }; let mut m = pre * local; + // Vertical type on a path: an upright glyph's cell in font units, as (its length with the + // Proportional Metrics change to it, the height of its top above the baseline). + let path_cell = (self.vertical && self.on_path && g.inline.is_none() && g.tcy.is_none() && takes_cell(g) && stands_upright(g) && g.sy != 0.0) + .then(|| { + let (cell, top) = upright_extent(g); + let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy; + (cell + proportional, top) + }); if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; + if let Some((cell, top)) = path_cell { + // An upright glyph whose top then faces the path's end turns half a turn about the + // center of its cell (without its Character Rotation), so that its top faces the + // path's start and the characters of an upright ligature read along the path in + // order. The effects that keep the glyph's up direction fixed already turn it so on + // a path that runs to the left. + let turned = m * unturned; + let tangent = Vec2::new(angle.cos(), angle.sin()); + let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent); + if along(-top) > along(cell - top) { + let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top); + m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m; + } + } } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y @@ -408,20 +432,17 @@ impl Ctx<'_> { } else { 0.0 }; - // On a path, an upright glyph turns about its pen: its cell runs along the path between - // where the glyph's transform sets the cell's foot and its top, moved by the shaper's - // placement and a baseline shift, and slanted by the path's effect. - let (width, width_start) = match (upright && self.on_path, g.sy) { - (true, sy) if sy != 0.0 => { - // In font units, with the Proportional Metrics change to its length. - let (cell, top) = upright_extent(g); - let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / sy; + // On a path, an upright glyph's cell runs along the path from where the glyph's transform + // sets the cell's top to its foot, moved by the shaper's placement and a baseline shift, and + // slanted by the path's effect. + let (width, width_start) = match path_cell { + Some((cell, top)) => { let tangent = Vec2::new(angle.cos(), angle.sin()); let along = |y: f64| (m * Point::new(0.0, y) - origin).dot(tangent); - let (start, end) = (along(cell + proportional - top), along(-top)); + let (start, end) = (along(-top), along(cell - top)); if start.is_finite() && end > start { (end - start, start) } else { (width, width_start) } } - _ => (width, width_start), + None => (width, width_start), }; if let Some(art) = g.inline.as_ref().and_then(|ib| ib.art) { self.out.inlines.push(InlineGlyph { run: g.run, byte: g.byte, glyph: self.out.glyphs.len(), xf: m, bounds: m.transform_rect_bbox(art) }); @@ -446,7 +467,14 @@ impl Ctx<'_> { bend: None, unturned, pivot, - carets: if g.inline.is_some() { Vec::new() } else { ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, upright) }, + // On a path its carets divide the cell its width runs along, with the Proportional + // Metrics change to it. + carets: if g.inline.is_some() { + Vec::new() + } else { + let length = upright.then(|| path_cell.map_or_else(|| upright_extent(g).0, |(cell, _)| cell)); + ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, length) + }, width, width_start, }); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d7bc1d444..ce315645e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -170,7 +170,7 @@ pub struct PositionedGlyph { /// taken off, plus the manual kerning of right-to-left text, which moves the glyph right so that /// the space it adds lies on the glyph's left. 0 for a glyph standing upright or set across the /// column in vertical type, and for an inline graphic. An upright glyph in vertical type on a - /// path turns about its pen, and its cell starts one cell length before the cell's top: 0.12 em + /// path stands with its top toward the path's start, and its cell starts at its top: 0.12 em /// before the pen without the font's vertical metrics. pub width_start: f64, } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2f591b6e0..00c994467 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -855,6 +855,44 @@ pub fn vertical_ligature_caret_font(caret: i16) -> Option> { Some(builder.build()) } +/// [`vertical_ligature_caret_font`] with its one caret at y = `caret` font units, f + i joined by +/// `ccmp` (which shaping applies top to bottom too, unlike `liga`), and its `GPOS` replaced by a +/// `vpal` single adjustment that changes the ligature glyph's advance down the column by +/// `y_advance` font units. `None` when it can't be built. +pub fn proportional_vertical_ligature_caret_font(caret: i16, y_advance: i16) -> Option> { + use write_fonts::tables::gpos::{Gpos, PositionLookup, PositionLookupList, SinglePos, ValueRecord}; + use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let base = vertical_ligature_caret_font(caret)?; + let font = skrifa::FontRef::new(&base).ok()?; + let gid = |c| Some(GlyphId16::new(source_sans_gid(c)?)); + let ligature = gid(LIGATURE_CARET_GLYPH)?; + let langsys = || Script::new(Some(LangSys::new(vec![0])), vec![]); + let scripts = || ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), langsys()), ScriptRecord::new(Tag::new(b"latn"), langsys())]); + let subst = LigatureSubstFormat1::new( + CoverageFormat1::new(vec![gid('f')?]).into(), + vec![LigatureSet::new(vec![Ligature::new(ligature, vec![gid('i')?])])], + ); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + let gsub = Gsub::new(scripts(), features, lookups); + let pos = SinglePos::format_1(CoverageFormat1::new(vec![ligature]).into(), ValueRecord::new().with_y_advance(y_advance)); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"vpal"), Feature::new(None, vec![0]))]); + let lookups = PositionLookupList::new(vec![PositionLookup::Single(Lookup::new(Default::default(), vec![pos]))]); + let mut builder = FontBuilder::new(); + builder.add_table(&gsub).ok()?; + builder.add_table(&Gpos::new(scripts(), features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`variable_ligature_caret_font`]. pub const VARIABLE_LIGATURE_CARET_FAMILY: &str = "Varicaret Sans"; diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index 929ba50bc..f669b07f4 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -8,7 +8,8 @@ use vectorcraft_geom::PathData; use crate::test_fonts::{ LIGATURE_CARET_FAMILY, LIGATURE_CARET_GLYPH, VARIABLE_LIGATURE_CARET_FAMILY, VERTICAL_LIGATURE_CARET_FAMILY, lam_alef_family, lam_alef_font, - ligature_caret_font, placed_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, vertical_ligature_caret_font, + ligature_caret_font, placed_ligature_caret_font, proportional_vertical_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, + vertical_ligature_caret_font, }; use crate::*; @@ -628,9 +629,10 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } -/// On a path, an upright ligature in vertical type turns about its pen, so its cell runs from -/// 0.12 em before the pen to 0.88 em after it, with or without the font's vertical metrics. The -/// inner stop lies at the middle of that cell, and the first character's selection ends there. +/// On a path, an upright ligature in vertical type stands with its top toward the path's start, and +/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical +/// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends +/// there. #[test] fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { for (font, family) in [(ligature_caret_font(None), LIGATURE_CARET_FAMILY), (vertical_ligature_caret_font(900), VERTICAL_LIGATURE_CARET_FAMILY)] { @@ -688,6 +690,100 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { } } +/// On a path, an upright ligature in vertical type reads along the path in order: the top of its +/// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click +/// and the selection put the stop, on a path running either way with any path effect. A glyph lying +/// on its side keeps its top toward the path's end. +#[test] +fn an_upright_ligature_on_a_path_reads_along_it_in_order() { + use vectorcraft_doc::PathEffect; + let db = db_with(vertical_ligature_caret_font(280)); + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + for (to, effect) in [ + (1000.0, PathEffect::Rainbow), + (-1000.0, PathEffect::Rainbow), + (-1000.0, PathEffect::Skew), + (-1000.0, PathEffect::Ribbon3d), + (-1000.0, PathEffect::StairStep), + (-1000.0, PathEffect::Gravity), + ] { + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((to, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = effect; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + assert!((f - 0.6).abs() < 1e-9, "{f}"); + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let along = |p: Point| (p - g.origin).dot(tangent); + // Font units, y down: the cell's top is 880 units above the baseline and its foot 120 below. + let (top, foot) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, 120.0))); + assert!(top < foot, "{to} {effect:?}: the top comes first along the path: {top} {foot}"); + let (start, width) = (g.width_start, g.width); + assert!((top - start).abs() < 1e-6 && (foot - top - width).abs() < 1e-6, "{to} {effect:?}: {top} {foot} {start} {width}"); + let drawn = along(g.xf * Point::new(0.0, -280.0)); + assert!((drawn - (top + (foot - top) * 0.6)).abs() < 1e-6, "{to} {effect:?}: {drawn} against {top} {foot}"); + let (a, b) = caret_position(&l, 1); + let caret = along(a.midpoint(b)); + assert!((caret - drawn).abs() < 1e-6, "{to} {effect:?}: the caret stands at the drawn stop: {caret} against {drawn}"); + assert_eq!(hit_byte(&l, g.origin + tangent * (drawn + 0.3)), 1, "{to} {effect:?}"); + assert_eq!(hit_byte(&l, g.origin + tangent * (drawn - 0.3)), 1, "{to} {effect:?}"); + assert_eq!(hit_byte(&l, g.origin + tangent * 0.3), 0, "{to} {effect:?}"); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1, "{q:?}"); + let (q0, q1) = (along(q[0][0]), along(q[0][1])); + assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}"); + } + // Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces + // the path's end and its advance runs across the path, to its right. + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = PathEffect::Rainbow; + t.runs[0].style.standard_vertical_roman = false; + let l = layout(&db, &t); + let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let tangent = Vec2::new(s.angle.cos(), s.angle.sin()); + let right = Vec2::new(-tangent.y, tangent.x); + let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0)); + assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}"); +} + +/// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units +/// to 800) keeps its font caret (600 units down the cell) where it is drawn: the inner stop lies at +/// 600 / 800 of its width, and the caret stands at the drawn stop. +#[test] +fn an_upright_ligature_on_a_path_keeps_its_font_caret_on_a_proportional_cell() { + use vectorcraft_doc::PathEffect; + let db = db_with(proportional_vertical_ligature_caret_font(280, -200)); + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + t.runs[0].style.proportional_metrics = true; + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = PathEffect::Rainbow; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + assert!((g.width - 80.0).abs() < 1e-6, "the cell is 800 units at 100 pt: {}", g.width); + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + assert!((f - 0.75).abs() < 1e-9, "{f}"); + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let along = |p: Point| (p - g.origin).dot(tangent); + let (top, drawn) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, -280.0))); + assert!((top - g.width_start).abs() < 1e-6 && (drawn - top - 60.0).abs() < 1e-6, "{top} {drawn} {}", g.width_start); + let (a, b) = caret_position(&l, 1); + let caret = along(a.midpoint(b)); + assert!((caret - drawn).abs() < 1e-6, "the caret stands at the drawn stop: {caret} against {drawn}"); +} + /// Tracking that makes an upright ligature's advance negative leaves it upright: it keeps its /// caret down its cell, and its cell starts where it does without tracking, with or without the /// font's vertical metrics. diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 835303b44..deb305b65 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -1,5 +1,15 @@ use super::{Document, NodeId, Paint, Point, Selection, Unit, Vec2, distort, select, xform}; +/// Which objects a Selection-tool marquee reaches before E reverses its rule for this drag. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarqueeMode { + #[default] + Touching, + Enclosed, + /// Screen-right takes enclosed objects; screen-left takes touched objects. + Directional, +} + /// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. #[derive(Clone, Debug, PartialEq)] pub struct PaintDefaults { @@ -120,6 +130,8 @@ pub struct ToolContext<'a> { pub selection: &'a Selection, /// Screen pixels per document point. pub zoom: f64, + /// Selection-tool marquee rule; other selection tools keep their own rules. + pub marquee_mode: MarqueeMode, pub isolation: Option, pub paint: &'a PaintDefaults, pub outline: bool, @@ -223,6 +235,9 @@ pub struct ToolContext<'a> { /// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with /// placeholder text, selected. pub placeholder_text: bool, + /// Type → Start Lists From Typed Markers: a list marker and a space typed at the start of a + /// paragraph start a list ([`crate::text::TypeTool`]). + pub typed_lists: bool, /// Smart Guides → Color: the smart guides' lines and labels (RGB). pub smart_guide_color: [u8; 3], /// Smart Guides → Alignment Guides: the lines along the edges and centres the art lines up diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 0bbdcf79b..ed1ab47b0 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -12,6 +12,7 @@ //! On a grid, F and V lower and raise //! the skew of the horizontal (rectangular) or radial (polar) dividers, and X and C lower and raise //! that of the vertical or concentric ones. +//! Escape cancels the drag and puts back the options its keys and modifiers changed. use serde_json::{Value, json}; use vectorcraft_geom::Point; @@ -63,6 +64,22 @@ pub struct FamilyTool { /// its own). The Reference Point is `reference`. pub grid_size: (f64, f64), snap: DrawSnap, + /// The options a drag's keys and modifiers change, as they were when the drag began: Escape + /// puts them back. + before_drag: Option, +} + +/// The options that keys and modifiers change while a shape is dragged out. +#[derive(Clone, Copy)] +struct DragOptions { + slope: f64, + closed: bool, + base_along: &'static str, + decay: f64, + segments: u32, + rows: u32, + columns: u32, + skew: [f64; 2], } impl FamilyTool { @@ -99,9 +116,35 @@ impl FamilyTool { fill: false, grid_size: (100.0, 100.0), snap: DrawSnap::default(), + before_drag: None, } } + fn drag_options(&self) -> DragOptions { + DragOptions { + slope: self.slope, + closed: self.closed, + base_along: self.base_along, + decay: self.decay, + segments: self.segments, + rows: self.rows, + columns: self.columns, + skew: self.skew, + } + } + + /// Put back the options a canceled drag changed. + fn restore_drag_options(&mut self, o: DragOptions) { + self.slope = o.slope; + self.closed = o.closed; + self.base_along = o.base_along; + self.decay = o.decay; + self.segments = o.segments; + self.rows = o.rows; + self.columns = o.columns; + self.skew = o.skew; + } + fn label(&self) -> &'static str { match self.id { "spiral" => "Spiral", @@ -264,6 +307,7 @@ impl Tool for FamilyTool { self.began = false; self.spiral_end = None; self.spiral_alt_base = None; + self.before_drag = None; vec![] } PointerKind::Drag => { @@ -279,6 +323,7 @@ impl Tool for FamilyTool { return out; } self.began = true; + self.before_drag = Some(self.drag_options()); out.push(Action::Begin(self.label().into())); } if self.id == "spiral" { @@ -293,6 +338,7 @@ impl Tool for FamilyTool { self.snap.clear(); self.spiral_end = None; self.spiral_alt_base = None; + self.before_drag = None; if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] } } _ => vec![], @@ -364,6 +410,9 @@ impl Tool for FamilyTool { self.began = false; self.spiral_end = None; self.spiral_alt_base = None; + if let Some(o) = self.before_drag.take() { + self.restore_drag_options(o); + } self.snap.clear(); vec![Action::Cancel] } else { @@ -442,6 +491,23 @@ impl Tool for FamilyTool { "height" => self.grid_size.1 = grid_length(v, self.grid_size.1), _ => {} } + // An option set while a shape is dragged out stays when Escape cancels the drag; a value + // the match above ignores leaves the drag's starting value as it was. + if let Some(b) = self.before_drag.as_mut() { + let finite = v.as_f64().is_some_and(f64::is_finite); + match key { + "slope" if finite => b.slope = self.slope, + "closed" if v.is_boolean() => b.closed = self.closed, + "baseAlong" if v.is_string() => b.base_along = self.base_along, + "decay" if v.as_f64().is_some() => b.decay = self.decay, + "segments" => b.segments = self.segments, + "rows" | "concentric" => b.rows = self.rows, + "columns" | "radial" => b.columns = self.columns, + "rowSkew" | "concentricSkew" if finite => b.skew[0] = self.skew[0], + "columnSkew" | "radialSkew" if finite => b.skew[1] = self.skew[1], + _ => {} + } + } } } @@ -672,7 +738,8 @@ mod tests { } /// Switching between Alt and Ctrl/Cmd keeps the held inner end and uses the current segment - /// count when Alt resumes. Escape clears the held end before the next drag. + /// count when Alt resumes. Escape clears the held end and puts back the segments and decay + /// from before the drag. #[test] fn spiral_drag_switches_between_alt_and_ctrl_and_escape_lets_go_of_the_inner_end() { let (d, _) = doc_with_rect(); @@ -699,9 +766,125 @@ mod tests { t.key(&cx, ToolKey::Escape, alt); assert_eq!(t.spiral_end, None); assert_eq!(t.spiral_alt_base, None); + assert_eq!((t.segments, t.decay), (10, 80.0), "the options from before the drag"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "a new drag holds a new inner end"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); + assert_eq!(t.segments, 14, "a new drag holds a new inner end"); + } + + /// Escape during a drag puts back every option the drag's keys changed, for the next drag and + /// the tool's options; a drag that is let go keeps them. + #[test] + fn escape_puts_back_the_options_a_drag_changed() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let keys = |id: &str| -> Vec { + match id { + "arc" => vec![ToolKey::Up, ToolKey::Letter('c'), ToolKey::Letter('f')], + "spiral" => vec![ToolKey::Up], + _ => vec![ToolKey::Up, ToolKey::Right, ToolKey::Letter('v'), ToolKey::Letter('c')], + } + }; + let arc_and_spiral = ["slope", "closed", "baseAlong", "segments", "decay"]; + let grids = ["rows", "columns", "concentric", "radial", "rowSkew", "columnSkew", "concentricSkew", "radialSkew"]; + let pick = |v: &Value| arc_and_spiral.iter().chain(&grids).filter_map(|f| v.get(*f).map(|x| (*f, x.clone()))).collect::>(); + for id in ["arc", "spiral", "rectangularGrid", "polarGrid"] { + let mut t = FamilyTool::new(id); + let preview = |a: Vec| match a.last() { + Some(Action::Preview(_, v)) => v.clone(), + _ => panic!("{id}: {a:?}"), + }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let before = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + let options = t.options(); + let mut changed = before.clone(); + for k in keys(id) { + changed = pick(&preview(t.key(&cx, k, Mods::default()))); + } + assert_ne!(changed, before, "{id}: the keys change the options"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}"); + assert_eq!(t.options(), options, "{id}: the tool's options"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let next = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + assert_eq!(next, before, "{id}: the next drag"); + // A drag that is let go keeps what its keys changed. + for k in keys(id) { + t.key(&cx, k, Mods::default()); + } + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let kept = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + assert_eq!(kept, changed, "{id}: a finished drag keeps them"); + t.key(&cx, ToolKey::Escape, Mods::default()); + assert_eq!(pick(&t.options()), changed, "{id}: Escape puts back only what this drag changed"); + } + } + + /// Alt and Ctrl/Cmd change a spiral's segments and decay as it is dragged out; Escape puts + /// both back. + #[test] + fn escape_puts_back_the_spiral_decay_and_segments_alt_and_ctrl_changed() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Mods::default() }; + let ctrl = Mods { cmd: true, ..Mods::default() }; + let mut t = FamilyTool::new("spiral"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); + assert!(t.segments != 10 && t.decay != 80.0, "{} {}", t.segments, t.decay); + t.key(&cx, ToolKey::Escape, ctrl); + assert_eq!((t.segments, t.decay), (10, 80.0)); + } + + /// An option set while a shape is dragged out (`tool.setOption`) stays when Escape cancels + /// the drag; the options only the drag's keys changed go back. + #[test] + fn escape_keeps_an_option_set_during_the_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = FamilyTool::new("rectangularGrid"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Right, Mods::default()); + t.set_option("columns", &json!(9)); + t.set_option("rowSkew", &json!(30.0)); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["rows"].clone(), o["columns"].clone(), o["rowSkew"].clone()), (json!(5), json!(9), json!(30.0))); + let mut t = FamilyTool::new("arc"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Letter('c'), Mods::default()); + t.set_option("slope", &json!(-20.0)); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["closed"].clone(), o["slope"].clone()), (json!(false), json!(-20.0))); + // A value the option ignores leaves the starting value for Escape. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Letter('c'), Mods::default()); + t.key(&cx, ToolKey::Letter('f'), Mods::default()); + for (k, v) in [("slope", json!("steep")), ("closed", json!(1)), ("baseAlong", json!(null))] { + t.set_option(k, &v); + } + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["slope"].clone(), o["closed"].clone(), o["baseAlong"].clone()), (json!(-20.0), json!(false), json!("x"))); } /// Alt adjusts the segment count in whole winds while keeping it within 2 to 1000. diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f77ba9dbf..f16969907 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -57,7 +57,7 @@ mod context; mod cursor; mod input; -pub use context::{GlyphSnap, PaintDefaults, ScreenFrame, ToolContext}; +pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; @@ -66,6 +66,8 @@ pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; pub enum Overlay { /// Marquee / rubber band rectangle. Marquee(Rect), + /// Selection-tool marquee, with its effective rule and pointer position for feedback. + SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point }, /// A path preview (e.g. pen rubber band) in a colour (RGB). Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool }, /// A line segment (smart guide, handle line). @@ -482,6 +484,7 @@ pub(crate) mod testutil { revision: (0, 0), selection: s, zoom: 1.0, + marquee_mode: MarqueeMode::Touching, isolation: None, paint: p, outline: false, @@ -525,6 +528,7 @@ pub(crate) mod testutil { pen_rubber_band: true, curvature_rubber_band: true, placeholder_text: false, + typed_lists: true, smart_guide_color: guides::MAGENTA, alignment_guides: true, anchor_path_labels: true, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 6d90e79e3..5a2739139 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -16,8 +16,8 @@ //! The bounding box stands at the selection's own angle after a rotation, so its handles scale //! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of //! scaling its type: the text reflows at its size. -//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and -//! E pressed again makes it take the objects it touches. +//! Marquee Selection defaults to touched objects, with fully enclosed and screen drag-direction +//! alternatives. E reverses the rule for the current drag. The overlay reports the effective rule. //! Escape during a drag (a move or an Alt-drag copy, a scale, a rotation, a marquee, a widget or //! bracket drag) cancels it: the art returns to its state at the press. //! Selected area type and type on a path show an in port and an out port. A click on the out port @@ -55,6 +55,29 @@ use crate::shaperwidget; use crate::typewidget; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; +/// Direction is read on screen, even in a turned view. Within three pixels of the press's +/// horizontal position, retain the previous rule so hand jitter does not flip it. +fn marquee_direction(cx: &ToolContext, start: Point, cur: Point, previous: bool) -> bool { + let dx = cx.screen.and_then(|f| Some(f.to_screen(cur)?.0 - f.to_screen(start)?.0)).unwrap_or((cur.x - start.x) * cx.zoom); + if dx > 3.0 { + true + } else if dx < -3.0 { + false + } else { + previous + } +} + +/// The same effective rule drives artwork, ruler guides and the visible feedback. +fn marquee_enclosed(cx: &ToolContext, inverted: bool, directional_inside: bool) -> bool { + let inside = match cx.marquee_mode { + crate::MarqueeMode::Touching => false, + crate::MarqueeMode::Enclosed => true, + crate::MarqueeMode::Directional => directional_inside, + }; + inside != inverted +} + #[derive(Clone, Debug, Default)] enum State { #[default] @@ -97,9 +120,10 @@ enum State { cur: Point, /// Shift: the objects inside leave the selection if selected, else join it. toggle: bool, - /// E pressed an odd number of times during the drag: the marquee takes only the objects - /// that lie entirely inside it. + /// E pressed an odd number of times during the drag: reverse the chosen rule. enclosed: bool, + /// Last horizontal direction outside the 3 screen-pixel dead zone at the press. + directional_inside: bool, }, /// Dragging a Live Corners widget. Corner(CornerDrag), @@ -952,8 +976,9 @@ impl Tool for SelectionTool { } vec![Action::Preview("object.transform".into(), params)] } - (PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => { - self.state = State::Marquee { start, cur: p, toggle, enclosed }; + (PointerKind::Drag, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => { + self.state = + State::Marquee { start, cur: p, toggle, enclosed, directional_inside: marquee_direction(cx, start, p, directional_inside) }; vec![] } (PointerKind::Drag, State::Corner(mut c)) => { @@ -1019,13 +1044,14 @@ impl Tool for SelectionTool { out.push(Action::Commit); out } - (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => { + (PointerKind::Up, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => { self.state = State::Idle; let r = Rect::from_points(start, p); if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) { return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } let touched = marquee(cx.doc, r, cx.isolation, false); + let enclosed = marquee_enclosed(cx, enclosed, marquee_direction(cx, start, p, directional_inside)); let ids = if enclosed { inside(cx, r, touched) } else { touched }; let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) })); std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, enclosed)).collect() @@ -1046,7 +1072,11 @@ impl Tool for SelectionTool { } } match &self.state { - State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), + State::Marquee { start, cur, enclosed, directional_inside, .. } => o.push(Overlay::SelectionMarquee { + rect: Rect::from_points(*start, *cur), + enclosed: marquee_enclosed(cx, *enclosed, *directional_inside), + pointer: *cur, + }), State::Corner(c) => o.extend(c.overlays(cx)), State::Pie(d) => o.extend(d.overlays(cx)), State::RotateAll(d) => o.extend(d.overlays(cx)), @@ -1544,7 +1574,7 @@ impl SelectionTool { self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() { State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None, exclude: None } } else { - State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false } + State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false, directional_inside: false } }; } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 242b9f928..518461343 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -1307,7 +1307,7 @@ fn escape_cancels_a_marquee() { t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the marquee"); assert!(!t.busy()); - assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee is removed"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), "the marquee is removed"); assert!(t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)).is_empty(), "the release selects nothing"); } @@ -1380,6 +1380,58 @@ fn e_during_a_marquee_takes_only_the_objects_inside() { assert!(t.busy() && !t.claims_key(&cx, e)); } +/// Each rule selects what its feedback promises, with E reversing it for just this gesture. +#[test] +fn marquee_rules_and_feedback_agree() { + use crate::MarqueeMode::{Directional, Enclosed, Touching}; + let (d, id) = doc_with_rect(); + let (s, paint) = (Selection::default(), paint()); + for (mode, right, expected) in + [(Touching, true, true), (Touching, false, true), (Enclosed, true, false), (Directional, true, false), (Directional, false, true)] + { + let c = ToolContext { marquee_mode: mode, ..cx(&d, &s, &paint) }; + for inverted in [false, true] { + let mut tool = SelectionTool::default(); + let (a, b) = if right { ((50.0, 50.0), (150.0, 150.0)) } else { ((150.0, 50.0), (50.0, 150.0)) }; + tool.pointer(&c, &ev(PointerKind::Down, a.0, a.1)); + tool.pointer(&c, &ev(PointerKind::Drag, b.0, b.1)); + if inverted { + tool.key(&c, ToolKey::Letter('e'), Mods::default()); + } + let selected = expected != inverted; + assert!( + tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { enclosed, .. } if *enclosed != selected)), + "{mode:?} right={right} E={inverted}" + ); + let out = tool.pointer(&c, &ev(PointerKind::Up, b.0, b.1)); + assert_eq!(out, vec![Action::Exec("select.set".into(), json!({"ids": if selected { vec![id.0] } else { vec![] }}))]); + assert!(!tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { .. }))); + } + } +} + +/// Screen direction survives view rotation; near the press's x position, the last mode is kept. +#[test] +fn directional_marquee_uses_screen_direction_and_a_dead_zone() { + let (d, _) = doc_with_rect(); + let (s, paint) = (Selection::default(), paint()); + for angle in [0.0, 45.0, 90.0, 180.0, 270.0] { + for zoom in [0.25, 1.0, 4.0] { + let mut screen = turned_view(angle).unwrap(); + screen.right /= zoom; + screen.down /= zoom; + let c = ToolContext { marquee_mode: crate::MarqueeMode::Directional, screen: Some(screen), zoom, ..cx(&d, &s, &paint) }; + let start = screen.at(20.0, 20.0); + assert!(marquee_direction(&c, start, screen.at(24.0, 60.0), false)); + assert!(!marquee_direction(&c, start, screen.at(16.0, 60.0), true)); + for x in [18.0, 20.0, 22.0] { + assert!(marquee_direction(&c, start, screen.at(x, 60.0), true)); + assert!(!marquee_direction(&c, start, screen.at(x, 60.0), false)); + } + } + } +} + /// With E pressed during the drag, a marquee takes only the artboard guides that lie entirely /// inside it, and no canvas guide, which runs across the whole canvas. #[test] @@ -2025,7 +2077,10 @@ fn a_dropped_press_keeps_the_text_loaded() { t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); assert!(t.drop_press(&cx).is_empty()); assert!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)).is_empty(), "the release threads nothing"); - assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_))), "the new frame's outline is removed"); + assert!( + !t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), + "the new frame's outline is removed" + ); assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); // A press on the port that loaded the text puts it down; dropped, it leaves the text loaded. t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); @@ -2243,7 +2298,7 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0)); let a = t.pointer(&c, &ev(PointerKind::Drag, 450.0, 450.0)); assert!(a.is_empty(), "{a:?}"); - assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); + assert!(matches!(t.overlays(&c).first(), Some(Overlay::SelectionMarquee { .. })), "a marquee"); } /// A Shift-press on a selected object's centre point moves the selection too, held to 45° steps, diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index c7a5a1ad4..9cca37e67 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -521,7 +521,11 @@ impl TypeTool { /// from the start of the caret's paragraph up to it start a list ([`TextObject::typed_list_at`]), /// the typing session ends as one undo step with those characters, and `text.startList` /// removes them and makes the paragraph a list as the next one, so one Undo puts them back. + /// With Type › Start Lists From Typed Markers off, the characters stay as typed. fn start_typed_list(&mut self, cx: &ToolContext) -> Vec { + if !cx.typed_lists { + return vec![]; + } let (Some(id), Some(ty)) = (self.editing, &self.typing) else { return vec![] }; let Some(mut t) = Self::text(cx, id).cloned() else { return vec![] }; // The document does not have the space yet (its preview is applied after this input). diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index a74582972..5b53073a3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -123,6 +123,7 @@ impl VectorcraftApp { canvas_rect: None, hover_doc: None, custom_titlebar: false, + desktop_frame: false, last_window_title: String::new(), fullscreen_for_mode: false, graphics_adapter: None, @@ -453,7 +454,8 @@ impl VectorcraftApp { /// in a floating window, the tab strip's document draws as a pane. pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) { let t = theme::Tokens::get(ui.ctx()); - ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); + // Tools may already occupy the left side of this panel. + ui.painter().rect_filled(ui.available_rect_before_wrap(), 0.0, t.pasteboard); self.canvas.windows.strip = None; if self.ui.screen_mode < 3 { chrome::doc_tabs(self, ui); @@ -541,8 +543,16 @@ impl VectorcraftApp { widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } if self.ui.toolbar && self.ui.screen_mode < 3 { + tools_float::set_window_dock_zone(&ctx, ui.available_rect_before_wrap()); + } + if !self.ui.tools_beside_canvas { + // A canvas-edge target left from before the setting was turned off docks nothing. + tools_float::clear_canvas_dock_zone(&ctx); + } + // Beside the canvas, docked Tools draws inside the canvas panel (`toolbar::show_beside_canvas`). + if self.ui.toolbar && self.ui.screen_mode < 3 && (!self.ui.tools_beside_canvas || self.ui.toolbar_pos.is_some()) { toolbar::show(self, ui); - } else if self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { + } else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { // Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New // Toolbar too. toolbar::show_custom(self, ui.ctx()); diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 49baf494b..0094dc07d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -206,8 +206,14 @@ fn wheel_view( let turn_id = egui::Id::unique("canvas-wheel-turn"); let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); let mut turn = turn_before; - let (pointer, m, space, (factor, scroll)) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, animate, line, rect.height(), &mut turn))); + let (pointer, m, space, (factor, scroll)) = ui.input(|i| { + ( + i.pointer.clone(), + i.modifiers, + i.key_down(egui::Key::Space), + wheel(i, &app.session.prefs, wheel_zooms, animate, line, rect.height(), &mut turn), + ) + }); if turn != turn_before { ui.data_mut(|d| d.insert_temp(turn_id, turn)); } diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8779380ec..a59c0c837 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -275,6 +275,29 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: match o { // Where the tools select: the document rectangle, turned with the view. Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)), + Overlay::SelectionMarquee { rect, enclosed, pointer } => { + let quad = xf.quad(*rect); + if *enclosed { + let points: Vec = quad.iter().chain(quad.first()).copied().collect(); + p.add(Shape::line(points.clone(), Stroke::new(3.0, Color32::from_white_alpha(230)))); + p.add(Shape::line(points, Stroke::new(1.0, Color32::from_gray(40)))); + } else { + marquee_through(p, &quad); + } + let (current, other) = if *enclosed { (tl!("Fully Enclosed"), tl!("Touching")) } else { (tl!("Touching"), tl!("Fully Enclosed")) }; + let label = format!("{current} · E: {other}"); + let galley = p.layout(label, egui::FontId::proportional(11.0), t.text, (p.clip_rect().width() - 12.0).max(1.0)); + let size = galley.size() + vec2(12.0, 8.0); + let wanted = xf.to_screen(*pointer) + vec2(14.0, 14.0); + let clip = p.clip_rect(); + let min = egui::pos2( + wanted.x.clamp(clip.min.x, (clip.max.x - size.x).max(clip.min.x)), + wanted.y.clamp(clip.min.y, (clip.max.y - size.y).max(clip.min.y)), + ); + let badge = egui::Rect::from_min_size(min, size); + p.rect_filled(badge, CornerRadius::same(3), t.panel); + p.galley(badge.min + vec2(6.0, 4.0), galley, t.text); + } Overlay::Path { path, color, width, dashed } => { let s = Stroke::new(*width, c32(*color)); if *dashed { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d9fa7f09c..2c4ef5baa 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2,6 +2,65 @@ use super::*; use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; +/// The marquee explains E without depending on color. At the canvas edge the hint stays in +/// view, in every brightness, and the two rules have visibly different outlines. +#[test] +fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { + let draw = |ui: &mut egui::Ui| { + let t = Tokens::get(ui.ctx()); + let xf = Xf { rect: ui.max_rect(), zoom: 1.0, center: Point::new(160.0, 100.0), rot: 0.0 }; + let prefs = vectorcraft_engine::Prefs::default(); + for (enclosed, y) in [(false, 80.0), (true, 180.0)] { + overlays::draw_overlays( + ui.painter(), + &xf, + &[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }], + &t, + overlays::HandleLook::of(&prefs), + ); + } + }; + for brightness in crate::theme::Brightness::ALL { + for width in [160.0, 320.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, brightness); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(width, 200.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| draw(ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::shapes_text(&out); + assert!(text.contains("Fully Enclosed · E: Touching") && text.contains("Touching · E: Fully Enclosed"), "{text}"); + for shape in &out.shapes { + if let egui::Shape::Text(label) = &shape.shape { + assert!( + label.pos.x >= shape.clip_rect.min.x && label.pos.x + label.galley.size().x <= shape.clip_rect.max.x, + "{brightness:?}: hint clipped" + ); + assert!( + label.pos.y >= shape.clip_rect.min.y && label.pos.y + label.galley.size().y <= shape.clip_rect.max.y, + "{brightness:?}: hint clipped vertically" + ); + } + } + } + } + // Optional visual QA of our own UI. Captures never enter the repository. + if let Some(path) = std::env::var_os("VECTORCRAFT_MARQUEE_CAPTURE") { + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 200.0)).wgpu().build_ui(move |ui| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark); + ready = true; + return; + } + draw(ui); + }); + harness.run_steps(4); + harness.render().unwrap().save(path).unwrap(); + } +} + #[test] fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { let mut observed = Vec::new(); @@ -4306,7 +4365,8 @@ fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() { } /// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, -/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel +/// the wheel scrolls and Cmd-wheel scrolls sideways (Scroll Sideways with Cmd/Ctrl + Wheel is on +/// by default); on, the wheel zooms about the pointer, Shift-wheel /// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a /// trackpad pinch zoom about the pointer either way. #[test] @@ -4348,7 +4408,9 @@ fn zoom_with_mouse_wheel_preference() { let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; let (zoom, scroll, _) = turn(&mut app, ""); assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})"); - assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + // General › Scroll Sideways with Cmd/Ctrl + Wheel is on by default. + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer"); assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer"); app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); @@ -4361,6 +4423,289 @@ fn zoom_with_mouse_wheel_preference() { assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default), through the control channel's +/// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the +/// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch +/// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain +/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl +/// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that +/// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with +/// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms. +#[test] +fn cmd_wheel_scrolls_sideways_preference() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A canvas frame through the host's input hook (it feeds the control channel's input). + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + // One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how + // far the point under `at` moved). + let turn = |app: &mut VectorcraftApp, mods: &str| { + let before = *app.view().unwrap(); + if mods == "pinch" { + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + } else { + let mut p = json!({"x": at.x, "y": at.y, "dy": 1}); + for m in mods.split('+').filter(|m| !m.is_empty()) { + p[m] = json!(true); + } + crate::tests_synthetic::control(app, &ctx, "ui.wheel", p); + } + // egui spreads a notch over a few frames. + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; + assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": true})).unwrap(); + // A notch scrolls one wheel line. + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0, + "on: Cmd-wheel scrolls sideways by a notch ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "pinch")), "on: a pinch right after it zooms in about the pointer"); + // Two fingers spread on a touch screen, with no wheel turn since the Cmd-wheel one. + let before = app.view().unwrap().zoom; + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let fingers = |phase, d: f32| vec![touch(1, phase, at - vec2(40.0 + d, 0.0)), touch(2, phase, at + vec2(40.0 + d, 0.0))]; + frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, 0.0)); + for k in 1..=3 { + frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, 10.0 * k as f32)); + } + frame(&mut app, &ctx, fingers(egui::TouchPhase::End, 30.0)); + let zoom = app.view().unwrap().zoom / before; + assert!(zoom > 1.01, "on: fingers spread on a touch screen zoom in ({zoom})"); + assert!(zooms_in(turn(&mut app, "alt+cmd")), "on: Alt + Cmd-wheel zooms in about the pointer"); + assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel"); + let (zoom, scroll, _) = turn(&mut app, ""); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: the wheel scrolls up ({zoom}, {scroll:?})"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + // A Cmd-wheel notch with a plain one right after it, before egui has spread the first: they + // scroll two notches in all, sideways and up. + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + scroll.x < -1.0 && scroll.y < -1.0 && (scroll.x + scroll.y + 2.0 * line).abs() < 0.5, + "on: a Cmd-wheel notch and a plain one right after it scroll two notches in all ({scroll:?})" + ); + // A pinch while egui still spreads a Cmd-wheel notch zooms by the pinch alone, and the + // canvas scrolls sideways by one notch; the pinch magnifies the part scrolled before it. + let before = *app.view().unwrap(); + let p = Xf::new(rect, &before).to_doc(at); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let (zoom, moved) = (after.zoom / before.zoom, Xf::new(rect, &after).to_screen(p) - at); + let notch = line as f32; + assert!( + (zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3, + "on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})" + ); + // A trackpad gesture starts with no modifier, and Cmd is pressed and released during it: + // egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling + // sideways until then. + let before = *app.view().unwrap(); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, egui::Modifiers::NONE), + swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND), + swipe(egui::TouchPhase::Move, egui::Modifiers::NONE), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0, + "on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", + after.zoom / before.zoom + ); + // An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui + // ends it before the next event, so a move with Cmd alone scrolls sideways. + // Frames at the given times, with the events in the frame's own input (the host's input + // hook hands synthetic events out over several frames). + let run_at = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + // The Alt part rests the fingers without moving, so only the Cmd move below acts. + let rest = |phase| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::ZERO, + phase, + modifiers: egui::Modifiers::ALT, + source: egui::MouseWheelSource::Unknown, + }; + let t = ctx.input(|i| i.time) + 1.0; + run_at(&mut app, t, vec![egui::Event::PointerMoved(at), rest(egui::TouchPhase::Start), rest(egui::TouchPhase::Move)]); + let before = *app.view().unwrap(); + run_at(&mut app, t + 2.0, vec![swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND)]); + for k in 1..=40 { + run_at(&mut app, t + 2.0 + f64::from(k) / 60.0, vec![]); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 6.0).abs() < 0.01 && scroll.y == 0.0, + "on: after egui's timeout, a Cmd move scrolls sideways ({}, {scroll:?})", + after.zoom / before.zoom + ); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, + "with Zoom with Mouse Wheel: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer"); +} + +/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms +/// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and +/// Cmd-wheel alone scrolls sideways. +#[test] +fn cmd_wheel_sideways_alt_cmd_zooms_without_animated_zoom() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("ctrlWheelScrollsSideways", true), ("zoomWithMouseWheel", false), ("animatedZoom", false)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "alt": true, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let expected = ctx.options(|o| f64::from(o.input_options.line_scroll_speed * o.input_options.scroll_zoom_speed).exp()); + let zoom = after.zoom / before.zoom; + assert!((zoom - expected).abs() < 1e-3, "Alt + Cmd-wheel zooms in by one notch ({zoom}, expected {expected})"); + assert!(Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)) < 1e-6, "Alt + Cmd-wheel zooms about the pointer"); + + let before = after; + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "Cmd-wheel does not zoom"); + assert!((scroll.x + line).abs() < 0.5 && scroll.y == 0.0, "Cmd-wheel scrolls sideways ({scroll:?})"); +} + +/// With Zoom with Mouse Wheel off, a trackpad gesture that starts with Alt keeps zooming about the +/// pointer until it ends, also after Alt is let go, as egui keeps a modifier held during a gesture, +/// with Animated Zoom on and off. A gesture that gains Alt partway zooms from then on. +#[test] +fn alt_wheel_gesture_keeps_zooming_after_alt_is_let_go() { + for animate in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("zoomWithMouseWheel", false), ("animatedZoom", animate)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + // One gesture with the given modifiers on its Start and its two Moves → (zoom ratio, how far + // the point under `at` moved). + let gesture = |app: &mut VectorcraftApp, mods: [egui::Modifiers; 3]| { + let before = *app.view().unwrap(); + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, mods[0]), + swipe(egui::TouchPhase::Move, mods[1]), + swipe(egui::TouchPhase::Move, mods[2]), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + (after.zoom / before.zoom, Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at))) + }; + let alt = egui::Modifiers::ALT; + let (held, _) = gesture(&mut app, [alt, alt, alt]); + assert!(held > 1.01, "animate {animate}: an Alt gesture zooms in ({held})"); + let (zoom, moved) = gesture(&mut app, [alt, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: a move without Alt keeps the gesture zooming about the pointer ({zoom}, expected {held}; moved {moved})" + ); + let (zoom, moved) = gesture(&mut app, [egui::Modifiers::NONE, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: Alt pressed partway zooms the rest of the gesture about the pointer ({zoom}, expected {held}; moved {moved})" + ); + } +} + /// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a /// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom. #[test] diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index c0e2a4bc0..39bc80545 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -52,8 +52,19 @@ const WHEEL_GLIDE: f64 = 0.05; /// What the wheel did across frames ([`wheel`]). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct WheelTurn { - /// The last wheel turn was an Alt-wheel one. + /// The last wheel turn was an Alt-wheel one: `held` had Alt without Cmd/Ctrl at its last Start + /// or Move. alt: bool, + /// The modifiers egui's wheel state holds, followed while Zoom with Mouse Wheel is off: each + /// wheel event's own, or, through a trackpad gesture, all those held since it began; none + /// while the wheel is idle. + held: egui::Modifiers, + /// A trackpad gesture is under way: egui has seen its Start phase and has not yet seen its End + /// or Cancel phase. + gesture: bool, + /// When the last wheel event came (`InputState::time`), and whether a trackpad sent it. + last: f64, + trackpad: bool, /// The zoom (natural log) wheel notches still have to glide in. pub(super) glide: f64, } @@ -342,13 +353,22 @@ struct ThumbDrag { /// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: /// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option -/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel -/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points +/// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls +/// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the +/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points /// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated /// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does /// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at /// once either way. -pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { +pub(super) fn wheel( + i: &egui::InputState, + prefs: &vectorcraft_engine::Prefs, + wheel_zooms: bool, + animate: bool, + line: f32, + page: f32, + turn: &mut WheelTurn, +) -> (f64, egui::Vec2) { // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); // A wheel event's motion in points. @@ -360,17 +380,72 @@ pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line if !wheel_zooms { // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. - if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) { - turn.alt = m.alt && !m.command; + // Each wheel event changes `turn.held` and `turn.gesture` as `WheelState::on_wheel_event` + // changes egui's wheel modifiers and status. Both are cleared while egui's wheel is idle. + // `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held + // Alt keeps zooming until it ends, also after Alt is let go. + for e in &i.events { + if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e { + // A wheel event that comes after egui's scroll timeout ends the turn first, as + // `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else + // 0.15 s), also when no frame ran in between. + let timeout = if turn.gesture && turn.trackpad { 1.0 } else { 0.150 }; + if i.time - turn.last > timeout { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + turn.last = i.time; + turn.trackpad = *source == egui::MouseWheelSource::Trackpad; + match phase { + egui::TouchPhase::Start => { + turn.gesture = true; + turn.held = *modifiers; + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::Move => { + if turn.gesture { + turn.held |= *modifiers; + } else { + turn.held = *modifiers; + } + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + } + } + } + if !i.is_scrolling() { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); + // Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without + // Alt, egui's zoom, less the pinches', is `exp(points × scroll_zoom_speed)`, at egui's + // default speed, which the app keeps. The canvas scrolls sideways by those points instead. + // A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the + // fingers' alone, and the canvas leaves it as it is. + if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() { + let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product(); + let wheel_zoom = zoom / pinch; + if wheel_zoom.is_finite() && wheel_zoom > 0.0 { + scroll.x += (wheel_zoom.ln() / f64::from(egui::InputOptions::default().scroll_zoom_speed)) as f32; + zoom = pinch; + } } - let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); if turn.alt && !animate { // Without Animated Zoom, Alt-wheel zooms by the wheel events themselves, on the frame - // they arrive; the rest of egui's spread scroll is dropped. + // they arrive; the rest of egui's spread scroll is dropped. Only Move events count, as + // `on_wheel_event` moves egui's scroll by Move events alone. let d: f32 = i .events .iter() - .filter_map(|e| if let egui::Event::MouseWheel { unit, delta, .. } = e { Some(points(unit, *delta)) } else { None }) + .filter_map(|e| match e { + egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, .. } => Some(points(unit, *delta)), + _ => None, + }) .map(|d| d.x + d.y) .sum(); return (zoom * (f64::from(d) * WHEEL_ZOOM).exp(), pan); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ae8fbccc0..135958756 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -129,13 +129,15 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Whether the window shows the system's title bar instead of the app drawing its own: Windows -/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system -/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor -/// does the web build (the browser tab has no document title), nor an embedder that never -/// turned the preference on. +/// Whether the window shows the system's title bar instead of the app drawing its own +/// ([`VectorcraftApp::desktop_frame`]): Windows with Preferences › User Interface › System Title +/// Bar, and Linux whenever the desktop draws the frame, with or without the preference. The +/// preference is read when the app starts, so changing it later does not change this. macOS +/// always has system decorations, but keeps the in-app brand mark, so it never counts as system +/// mode here; nor does the web build (the browser tab has no document title), nor an embedder +/// without a window of its own. pub fn system_title_bar(app: &VectorcraftApp) -> bool { - !app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) + app.desktop_frame && !app.custom_titlebar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) } /// The OS window title: the active document's name suffixed with the app name (a `*` prefix diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 00d790964..55650c46e 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -1230,16 +1230,51 @@ fn the_zoom_hint_follows_animated_zoom() { fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp { let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); app.custom_titlebar = custom_titlebar; + app.desktop_frame = !custom_titlebar; // The preference that makes the shell launch without its own title bar. app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap(); app } +/// How many brand marks one frame of the app bar paints. +fn brand_marks(app: &mut crate::VectorcraftApp) -> usize { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx))).count() +} + #[test] fn without_the_preference_the_mark_stays() { // The default (e.g. the web build): no custom bar, no preference. let app = crate::VectorcraftApp::new(Session::new(), Default::default()); assert!(!super::system_title_bar(&app)); + // An embedder without a window of its own keeps the mark with the preference on too. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + assert_eq!(brand_marks(&mut app), 1); +} + +#[test] +fn a_desktop_frame_hides_the_mark_whatever_the_preference_says() { + // Linux on a desktop that draws the frame (KDE, X11): the window has the desktop's title bar + // with System Title Bar off. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.desktop_frame = true; + app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap(); + let hidden = !cfg!(target_os = "macos"); + assert_eq!(super::system_title_bar(&app), hidden); + assert_eq!(brand_marks(&mut app), if hidden { 0 } else { 1 }); + // The preference applies at the next start: changing it now leaves the window's frame, and the mark, as they are. + let mut app = title_test_app(false); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap(); + assert_eq!(super::system_title_bar(&app), hidden); + let mut app = title_test_app(true); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + assert_eq!(brand_marks(&mut app), 1); } #[test] diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index adbf1223f..e5008644b 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -523,12 +523,17 @@ pub fn sync(ui: &UiState) { } } -/// Window › Toolbars: Advanced and Double Column, the toolbars made with New Toolbar (each checked -/// while it shows), New Toolbar… and Manage Toolbars…. +/// Window › Toolbars: Advanced, Double Column and Keep Tools Beside the Canvas, the toolbars made +/// with New Toolbar (each checked while it shows), New Toolbar… and Manage Toolbars…. pub fn menu_items() -> Vec { use crate::menus::Item; let c = |label: &'static str, id: &'static str| Item::Cmd(label, id, Value::Null); - let mut v = vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns"), Item::Sep]; + let mut v = vec![ + c("Advanced", "window.toolbarAdvanced"), + c("Double Column", "window.toolbarColumns"), + c("Keep Tools Beside the Canvas", "window.toolsBesideCanvas"), + Item::Sep, + ]; let names = names_store().read().map(|v| v.clone()).unwrap_or_default(); if !names.is_empty() { v.extend(names.into_iter().map(|n| Item::Cmd(n, "window.toolbar.custom", json!({ "name": n })))); diff --git a/crates/ui-egui/src/dialogs/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 0d6382e9a..4e8a3bfae 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -92,9 +92,23 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "overrideSave", "Override Action \"Save\" Commands"); form::check(ui, d, "overrideExport", "Override Action \"Export\" Commands"); form::check(ui, d, "stopOnError", "Stop on Errors"); + if overwrites_one_file(app, d) { + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Each data set saves over the same file, which keeps only the last data set.")).size(11.5).color(t.warning)); + } false } +/// Whether the batch the dialog would run saves every data set over the same file +/// ([`vectorcraft_engine::cmd::fileio::batch_overwrites_one_file`]). +fn overwrites_one_file(app: &VectorcraftApp, d: &Dialog) -> bool { + let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; + let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; + let steps = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).map_or(&[][..], |a| &a.steps[..]); + let commands = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + vectorcraft_engine::cmd::fileio::batch_overwrites_one_file(&app.session, ¶ms(d, "", commands)) +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -161,6 +175,18 @@ mod tests { use vectorcraft_engine::Session; fn shown(source: &str) -> Vec { + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["source"] = json!(source); + render(&mut d) + } + + /// Draws `d` once and returns the texts it shows. + fn render(d: &mut Dialog) -> Vec { + render_in(&mut VectorcraftApp::new(Session::new(), Default::default()), d) + } + + /// Draws `d` once over `app` and returns the texts it shows. + fn render_in(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec { fn collect(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), @@ -168,13 +194,10 @@ mod tests { _ => {} } } - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); - d.fields["source"] = json!(source); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut frame = ctx.run_ui(Default::default(), |ui| { - body(&mut app, ui, &mut d); + body(app, ui, d); }); frame.textures_delta.clear(); let mut labels = vec![]; @@ -220,4 +243,37 @@ mod tests { d.fields["source"] = json!("folder"); assert!(params(&d, "Test", vec![]).get("datasetFileNaming").is_none()); } + + const ONE_FILE: &str = "Each data set saves over the same file, which keeps only the last data set."; + + #[test] + fn data_sets_offer_save_and_close_with_a_warning() { + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["destination"] = json!("saveAndClose"); + let labels = render(&mut d); + assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); + assert!(!labels.iter().any(|label| label == ONE_FILE), "a folder source opens a file per pass: {labels:?}"); + d.fields["source"] = json!("datasets"); + let labels = render(&mut d); + assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); + assert!(labels.iter().any(|label| label == ONE_FILE), "{labels:?}"); + assert_eq!(params(&d, "Test", vec![])["destination"], "saveAndClose"); + d.fields["destination"] = json!("folder"); + assert!(!render(&mut d).iter().any(|label| label == ONE_FILE)); + } + + #[test] + fn data_sets_warn_about_a_recorded_save_left_in_place() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.action_sets[0].actions[0].steps.push(("document.save".into(), json!({"path": "/x/a.vectorcraft"}))); + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["source"] = json!("datasets"); + d.fields["destinationFolder"] = json!("/out"); + assert!(!render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE), "Override Save points the save into the folder"); + d.fields["overrideSave"] = json!(false); + assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE)); + d.fields["overrideSave"] = json!(true); + d.fields["destination"] = json!("none"); + assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE)); + } } diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index c65b4e602..00ae1abe7 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -508,7 +508,7 @@ pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { shortcut_editor::open(app); } if let Some(k) = app.ui.dialog.as_mut() { - k.fields.insert("overrides".into(), crate::keymap::stored::to_value(km.overrides())); + k.fields.insert("overrides".into(), crate::keymap::stored::to_value(&shortcut_editor::saved_overrides(km.overrides(), &ov))); k.fields.insert("set".into(), json!(set)); k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set."))); } diff --git a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs index 61bbc18e8..77842ec8d 100644 --- a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs @@ -500,6 +500,25 @@ fn apply_rows(app: &mut VectorcraftApp, start: &str, rows: &[(&str, &str, &str, (out, dialog(app).fields["overrides"].clone()) } +#[test] +fn resetting_an_imported_function_key_restores_its_edit_default() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut app = app(); + let (out, value) = apply_rows(&mut app, "defaults", &[("Acme Duplicate", "Menus", "edit.duplicate", alias)]); + assert_eq!(out["applied"], 1); + let mut saved = crate::keymap::stored::from_value(value); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + shortcut_editor::reset_one(&mut saved, "edit.duplicate"); + assert_eq!( + crate::keymap::lookup(&crate::keymap::keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), + Some(crate::keymap::command_of(edit)), + ); + } +} + /// Cmd and Ctrl are one key off the Mac: the review lets both rows through, and the later one takes /// the shortcut. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 7816e48f7..95b3f2491 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1130,6 +1130,7 @@ font-technology Variable Променливи Live curve fitting Напасване на кривата в реално време Fill Color Цвят на запълване Fill New Type Objects With Placeholder Text Запълване на нови текстови обекти с примесен текст + Start Lists From Typed Markers Започване на списъци от въведени маркери Fill Rule Правило за запълване Fill with Placeholder Text Запълване с примесен текст Fill: Запълване: @@ -2979,6 +2980,11 @@ digits Malayalam Малаялам Selected Swatches Избрани образци Selected Swatches Only Само избрани образци Selection Селекция +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Избор с рамка + Touching Докоснати обекти + Fully Enclosed Изцяло вътре + Drag Direction Посока на плъзгане Selection & Anchor Display Показване на селекция и опорни точки Selection Only Само селекция Selection Tool Инструмент „Селекция“ @@ -3114,6 +3120,7 @@ digits Malayalam Малаялам Simulate Colored Paper Симулиране на цветна хартия Simulate Paper Симулиране на хартия Double Column Две колони + Keep Tools Beside the Canvas Инструментите до платното Single Left Quotation Mark Лява единична кавичка Single Quotes: Единични кавички: Single Right Quotation Mark Дясна единична кавичка @@ -3710,6 +3717,7 @@ digits Malayalam Малаялам Zoom to Selection Приближаване до селекцията Zoom Tool Инструмент „Мащаб“ Zoom with Mouse Wheel Мащабиране с колелцето на мишката + Scroll Sideways with Cmd/Ctrl + Wheel Хоризонтално превъртане с Cmd/Ctrl + колелцето Scrub Numeric Fields by Dragging Плъзгане на числени полета чрез влачене @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} файл в {folder} {note}. Показване на папката на пакета?|{n} файла в {folder} {note}. Показване на папката на пакета? @plural {n} linked file not found|{n} linked files not found {n} свързан файл не е намерен|{n} свързани файла не са намерени @@ -6099,6 +6107,8 @@ breakText Glyphs Глифи File + Data Set Name Файл + име на набора от данни Data Set Name Име на набора от данни @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` трябва да е `fileNumber`, `fileDataSetName` или `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Всеки набор от данни се записва върху същия файл, в който остава само последният набор от данни. +@msg save the document with Save As before Save and Close запазете документа със „Запази като“ преди „Запази и затвори“ @msg no free data set file name in destination folder Няма свободно име за файл на набор от данни в целевата папка # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a30637ecb..60903565b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -179,6 +179,11 @@ Ungroup Razgrupiši Lock Zaključaj Selection Odabir +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Pravougaoni odabir + Touching Dodirnuti objekti + Fully Enclosed Potpuno obuhvaćeni + Drag Direction Smjer povlačenja All Artwork Above Sav crtež iznad Other Layers Ostali slojevi Unlock All Otključaj sve @@ -665,6 +670,7 @@ Toolbars Trake s alatima Advanced Napredno Double Column Dvostruka kolona + Keep Tools Beside the Canvas Alati pored platna Actions Akcije Asset Export Izvoz resursa Attributes Atributi @@ -3577,6 +3583,7 @@ theme Appearance Izgled Feet Stope Files Datoteke Fill New Type Objects With Placeholder Text Ispuni nove tekstualne objekte tekstom čuvara mjesta + Start Lists From Typed Markers Započni liste iz otkucanih oznaka Folder Folder Font Preview Size Veličina pregleda fonta Get Preferences Preuzmi postavke @@ -3699,6 +3706,7 @@ theme Appearance Izgled Yards Jardi Zoom to Selection Zumiraj na odabir Zoom with Mouse Wheel Zumiraj točkićem miša + Scroll Sideways with Cmd/Ctrl + Wheel Pomjeraj vodoravno uz Cmd/Ctrl + točkić miša Scrub Numeric Fields by Dragging Mijenjaj numerička polja prevlačenjem Linux Display Server Linux server prikaza Print Preview Pregled štampanja @@ -5021,6 +5029,9 @@ imageTrace Outline Obris @msg the Pixar file's image data is damaged slikovni podaci Pixar datoteke su oštećeni @msg the JPEG 2000 file can't be read datoteku JPEG 2000 nije moguće pročitati @msg the JPEG 2000 file has an unsupported color space or JPX content datoteka JPEG 2000 ima nepodržan prostor boja ili JPX sadržaj +@msg the HEIC or HEIF file can't be read datoteku HEIC ili HEIF nije moguće pročitati +@msg the HEIC or HEIF file holds a kind of image that can't be read datoteka HEIC ili HEIF sadrži vrstu slike koju nije moguće pročitati +@msg HEIC and HEIF images can't be opened in this build HEIC i HEIF slike nije moguće otvoriti u ovoj verziji programa @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD slika je prevelika za čitanje ({_1} × {_2} piksela) @msg zipped 32-bit PSD image data with prediction can't be read zipovani 32-bitni PSD podaci slike s predviđanjem ne mogu se pročitati @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitne PSD datoteke u ovom načinu boja ne mogu se pročitati @@ -6111,6 +6122,8 @@ shape Open Otvoren File + Data Set Name Datoteka + naziv skupa podataka Data Set Name Naziv skupa podataka @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` mora biti `fileNumber`, `fileDataSetName` ili `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Svaki skup podataka se sačuva preko iste datoteke, u kojoj ostaje samo posljednji skup podataka. +@msg save the document with Save As before Save and Close sačuvajte dokument pomoću „Sačuvaj kao“ prije „Sačuvaj i zatvori“ @msg no free data set file name in destination folder Nema slobodnog naziva datoteke skupa podataka u odredišnoj mapi # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 56bf65692..aaf0096b7 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1206,6 +1206,7 @@ Double Bar Barra doble Double Click To Isolate Doble clic per aïllar Double Column Columna doble + Keep Tools Beside the Canvas Eines al costat del llenç Double Left Quotation Marks Cometes dobles esquerres Double Quotes: Cometes dobles: Double Right Quotation Marks Cometes dobles dretes @@ -1555,6 +1556,7 @@ digits Malayalam Malaiàlam Fill Line Línia de farciment Fill new strokes Omple els traços nous Fill New Type Objects With Placeholder Text Omple els objectes de text nous amb text de mostra + Start Lists From Typed Markers Comença llistes a partir dels marcadors escrits Fill Rule Regla de farciment Fill with Placeholder Text Omple amb text de mostra Fill: Farciment: @@ -3728,6 +3730,7 @@ digits Malayalam Malaiàlam Scribble… Garabateig… Script / Brush Cal·ligràfica / Pinzell Scripts Scripts + Scroll Sideways with Cmd/Ctrl + Wheel Desplaça horitzontalment amb Cmd/Ctrl + roda del ratolí Scrub Numeric Fields by Dragging Canvia els camps numèrics arrossegant Search Cerca Search All Cerca a tot @@ -3796,6 +3799,11 @@ digits Malayalam Malaiàlam Selected Swatches Only (0) Només les mostres seleccionades (0) Selected Symbols Only Només els símbols seleccionats Selection Selecció +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selecció rectangular + Touching Objectes tocats + Fully Enclosed Totalment inclosos + Drag Direction Direcció de l’arrossegament Selection & Anchor Display Selecció i visualització d’ancoratges Selection Only Només la selecció Selection Tool Eina de selecció @@ -6127,6 +6135,9 @@ anchor Points: Punts: CMYK Images Imatges CMYK Grayscale Images Imatges en escala de grisos @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` ha de ser spotColors, colorEffects, rgbImages, cmykImages o grayImages +@msg byColor must be true or false byColor ha de ser true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no pot separar els grups de l'agrupació automàtica: traça sense l'agrupació automàtica +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no pot tornar a agrupar un traçat amb un efecte Pathfinder actiu: expandeix-lo sense byColor # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Color dels punts de la il·lustració @@ -6252,6 +6263,8 @@ breakText Glyphs Glifs File + Data Set Name Fitxer + nom del conjunt de dades Data Set Name Nom del conjunt de dades @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` ha de ser `fileNumber`, `fileDataSetName` o `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Cada conjunt de dades es desa sobre el mateix fitxer, que només conserva l'últim conjunt de dades. +@msg save the document with Save As before Save and Close deseu el document amb «Desa com a» abans de «Desa i tanca» @msg no free data set file name in destination folder No hi ha cap nom de fitxer de conjunt de dades lliure a la carpeta de destinació # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6300,6 +6313,9 @@ breakText Glyphs Glifs @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible +@msg the HEIC or HEIF file can't be read no es pot llegir el fitxer HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el fitxer HEIC o HEIF conté un tipus d'imatge que no es pot llegir +@msg HEIC and HEIF images can't be opened in this build les imatges HEIC i HEIF no es poden obrir en aquesta compilació @msg You must choose a side pattern. Heu de triar un patró lateral. @msg invalid pattern tile choice opció de mosaic de patró no vàlida @msg no pattern named `{choice}` no hi ha cap patró anomenat `{choice}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5c4d094d6..ac14feca6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -187,6 +187,11 @@ Ungroup Oddělit Lock Zamknout Selection Výběr +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Obdélníkový výběr + Touching Dotčené objekty + Fully Enclosed Zcela uvnitř + Drag Direction Směr tažení All Artwork Above Veškerou kresbu výše Other Layers Ostatní vrstvy Unlock All Odemknout vše @@ -673,6 +678,7 @@ Toolbars Panely nástrojů Advanced Rozšířené Double Column Dva sloupce + Keep Tools Beside the Canvas Nástroje vedle plátna Actions Akce Asset Export Export prvků Attributes Atributy @@ -3620,6 +3626,7 @@ theme Appearance Motiv Feet Stopy Files Soubory Fill New Type Objects With Placeholder Text Vyplňovat nové textové objekty zástupným textem + Start Lists From Typed Markers Začínat seznamy z napsaných značek Folder Složka Font Preview Size Velikost náhledu písma Get Preferences Získat předvolby @@ -3744,6 +3751,7 @@ theme Appearance Motiv Yards Yardy Zoom to Selection Zvětšit na výběr Zoom with Mouse Wheel Zvětšovat kolečkem myši + Scroll Sideways with Cmd/Ctrl + Wheel Posouvat vodorovně kolečkem s Cmd/Ctrl Scrub Numeric Fields by Dragging Měnit číselné hodnoty tažením Linux Display Server Zobrazovací server Linuxu Print Preview Náhled tisku @@ -5083,6 +5091,9 @@ imageTrace Outline Obrys @msg the Pixar file's image data is damaged obrazová data souboru Pixar jsou poškozená @msg the JPEG 2000 file can't be read soubor JPEG 2000 nelze přečíst @msg the JPEG 2000 file has an unsupported color space or JPX content soubor JPEG 2000 obsahuje nepodporovaný barevný prostor nebo obsah JPX +@msg the HEIC or HEIF file can't be read soubor HEIC nebo HEIF nelze přečíst +@msg the HEIC or HEIF file holds a kind of image that can't be read soubor HEIC nebo HEIF obsahuje druh obrázku, který nelze přečíst +@msg HEIC and HEIF images can't be opened in this build obrázky HEIC a HEIF nelze v tomto sestavení otevřít @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů) @msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst @msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst @@ -5711,6 +5722,9 @@ mark-style Japanese Japonské @msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů). +@msg byColor must be true or false byColor musí být true nebo false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nerozdělí skupiny z Automatického seskupení: vektorizujte bez Automatického seskupení +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nemůže přeskupit trasování s živým efektem Pathfinder: rozbalte bez byColor Snapping Přichytávání Snap to Tangent Přichytávat k tečně Snap to Perpendicular Přichytávat ke kolmici @@ -6271,6 +6285,8 @@ breakText Glyphs Glyfy File + Data Set Name Soubor + název sady dat Data Set Name Název sady dat @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musí být `fileNumber`, `fileDataSetName` nebo `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Každá sada dat se uloží přes stejný soubor, ve kterém zůstane jen poslední sada dat. +@msg save the document with Save As before Save and Close před „Uložit a zavřít“ uložte dokument pomocí „Uložit jako“ @msg no free data set file name in destination folder V cílové složce není volný název souboru sady dat # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 7b8785fc3..cfd826881 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -172,6 +172,11 @@ Ungroup Afgruppér Lock Lås Selection Markering +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rektangelmarkering + Touching Berørte objekter + Fully Enclosed Helt omsluttet + Drag Direction Trækretning All Artwork Above Al grafik ovenfor Other Layers Andre lag Unlock All Lås alle op @@ -622,6 +627,7 @@ Toolbars Værktøjslinjer Advanced Avanceret Double Column Dobbelt kolonne + Keep Tools Beside the Canvas Værktøjer ved siden af canvaset Actions Handlinger Asset Export Aktiveksport Attributes Attributter @@ -3362,6 +3368,7 @@ theme Appearance Udseende Feet Fod Files Filer Fill New Type Objects With Placeholder Text Udfyld nye tekstobjekter med pladsholdertekst + Start Lists From Typed Markers Start lister fra indtastede markører Folder Mappe Font Preview Size Størrelse på skrifttypeforhåndsvisning Get Preferences Hent indstillinger @@ -3472,6 +3479,7 @@ theme Appearance Udseende Yards Yards Zoom to Selection Zoom til markering Zoom with Mouse Wheel Zoom med musehjul + Scroll Sideways with Cmd/Ctrl + Wheel Rul vandret med Cmd/Ctrl + musehjul Scrub Numeric Fields by Dragging Juster numeriske felter ved at trække Linux Display Server Linux-visningsserver Print Preview Udskriftsvisning @@ -6114,6 +6122,8 @@ breakText Glyphs Glyffer File + Data Set Name Fil + datasætnavn Data Set Name Datasætnavn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` skal være `fileNumber`, `fileDataSetName` eller `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Hvert datasæt gemmes oven i den samme fil, som kun beholder det sidste datasæt. +@msg save the document with Save As before Save and Close gem dokumentet med Gem som før Gem og luk @msg no free data set file name in destination folder Intet ledigt filnavn til datasættet i destinationsmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fb8594bcf..a67b07b1b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1145,6 +1145,7 @@ font-technology Variable Variabel Stroke When Editable Kontur, wenn bearbeitbar Fill Color Flächenfarbe Fill New Type Objects With Placeholder Text Neue Textobjekte mit Platzhaltertext füllen + Start Lists From Typed Markers Listen aus getippten Aufzählungszeichen beginnen Fill Rule Füllregel Fill with Placeholder Text Mit Platzhaltertext füllen Fill: Fläche: @@ -3021,6 +3022,11 @@ digits Malayalam Malayalam Selected Swatches Ausgewählte Farbfelder Selected Swatches Only Nur ausgewählte Farbfelder Selection Auswahl +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rechteckauswahl + Touching Berührt + Fully Enclosed Vollständig eingeschlossen + Drag Direction Ziehrichtung Selection & Anchor Display Auswahl und Ankerpunktanzeige Selection Only Nur Auswahl Selection Tool Auswahl-Werkzeug @@ -3158,6 +3164,7 @@ digits Malayalam Malayalam Simulate Colored Paper Farbiges Papier simulieren Simulate Paper Papier simulieren Double Column Zweispaltig + Keep Tools Beside the Canvas Werkzeuge neben der Arbeitsfläche Single Left Quotation Mark Einfaches öffnendes Anführungszeichen Single Quotes: Einfache Anführungszeichen: Single Right Quotation Mark Einfaches schließendes Anführungszeichen @@ -3703,6 +3710,7 @@ effect Points Punkte Zoom to Selection Auf Auswahl zoomen Zoom Tool Zoom-Werkzeug Zoom with Mouse Wheel Mit Mausrad zoomen + Scroll Sideways with Cmd/Ctrl + Wheel Mit Cmd/Ctrl + Mausrad horizontal scrollen Scrub Numeric Fields by Dragging Zahlenfelder durch Ziehen ändern @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} Datei in {folder} {note}. Paketordner anzeigen?|{n} Dateien in {folder} {note}. Paketordner anzeigen? @plural {n} linked file not found|{n} linked files not found {n} verknüpfte Datei nicht gefunden|{n} verknüpfte Dateien nicht gefunden @@ -4824,6 +4832,9 @@ theme Appearance Erscheinungsbild @msg the Pixar file's image data is damaged die Bilddaten der Pixar-Datei sind beschädigt @msg the JPEG 2000 file can't be read die JPEG-2000-Datei kann nicht gelesen werden @msg the JPEG 2000 file has an unsupported color space or JPX content die JPEG-2000-Datei enthält einen nicht unterstützten Farbraum oder JPX-Inhalt +@msg the HEIC or HEIF file can't be read die HEIC- oder HEIF-Datei kann nicht gelesen werden +@msg the HEIC or HEIF file holds a kind of image that can't be read die HEIC- oder HEIF-Datei enthält eine Bildart, die nicht gelesen werden kann +@msg HEIC and HEIF images can't be opened in this build HEIC- und HEIF-Bilder können in diesem Build nicht geöffnet werden @msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel) @msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden @@ -5290,6 +5301,9 @@ effect Soft Weich @msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel). +@msg byColor must be true or false byColor muss true oder false sein +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kann Gruppen aus Automatisch gruppieren nicht aufteilen: ohne Automatisch gruppieren nachzeichnen +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kann eine Nachzeichnung mit einem aktiven Pathfinder-Effekt nicht neu gruppieren: ohne byColor umwandeln Snapping Einrasten Snap to Tangent An Tangente ausrichten Snap to Perpendicular An Senkrechter ausrichten @@ -5856,6 +5870,8 @@ breakText Glyphs Glyphen File + Data Set Name Datei + Datensatzname Data Set Name Datensatzname @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` muss `fileNumber`, `fileDataSetName` oder `dataSetName` sein + Each data set saves over the same file, which keeps only the last data set. Jeder Datensatz wird über dieselbe Datei gespeichert, die nur den letzten Datensatz behält. +@msg save the document with Save As before Save and Close speichern Sie das Dokument mit „Speichern unter“, bevor Sie „Speichern und schließen“ wählen @msg no free data set file name in destination folder Kein freier Dateiname für den Datensatz im Zielordner # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e487b81a7..8d4302d7a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1103,6 +1103,7 @@ font-technology Variable Μεταβλητές Live curve fitting Προσαρμογή καμπύλης σε πραγματικό χρόνο Fill Color Χρώμα γεμίσματος Fill New Type Objects With Placeholder Text Συμπλήρωση νέων αντικειμένων κειμένου με κείμενο υπόδειξης + Start Lists From Typed Markers Έναρξη λιστών από πληκτρολογημένα σύμβολα Fill Rule Κανόνας γεμίσματος Fill with Placeholder Text Γέμισμα με κείμενο υπόδειξης Fill: Γέμισμα: @@ -1934,6 +1935,11 @@ font-technology Variable Μεταβλητές Select All Επιλογή όλων Select: Επιλογή: Selection Επιλογή +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Ορθογώνια επιλογή + Touching Αντικείμενα που τέμνονται + Fully Enclosed Πλήρως εντός + Drag Direction Κατεύθυνση σύρσης Selection Tool Επιλογή (Selection Tool) Send Backward Μεταφορά ένα βήμα πίσω Send to Back Μεταφορά στο παρασκήνιο @@ -3612,6 +3618,7 @@ digits Malayalam Μαλαγιάλαμ Simulate Colored Paper Προσομοίωση χρωματιστού χαρτιού Simulate Paper Προσομοίωση χαρτιού Double Column Διπλή στήλη + Keep Tools Beside the Canvas Εργαλεία δίπλα στον καμβά Single Left Quotation Mark Μονό αριστερό εισαγωγικό Single Quotes: Μονά εισαγωγικά: Single Right Quotation Mark Μονό δεξί εισαγωγικό @@ -4036,6 +4043,7 @@ digits Malayalam Μαλαγιάλαμ Zig Zag… Ζιγκ-ζαγκ… Zoom to Selection Ζουμ στην επιλογή Zoom with Mouse Wheel Ζουμ με τον τροχό του ποντικιού + Scroll Sideways with Cmd/Ctrl + Wheel Οριζόντια κύλιση με Cmd/Ctrl + τροχό του ποντικιού Scrub Numeric Fields by Dragging Ρύθμιση αριθμητικών πεδίων με σύρσιμο @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} αρχείο στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας;|{n} αρχεία στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας; @plural {n} linked file not found|{n} linked files not found Δεν βρέθηκε {n} συνδεδεμένο αρχείο|Δεν βρέθηκαν {n} συνδεδεμένα αρχεία @@ -4764,6 +4772,9 @@ digits Malayalam Μαλαγιάλαμ @msg the Pixar file's image data is damaged τα δεδομένα εικόνας του αρχείου Pixar είναι κατεστραμμένα @msg the JPEG 2000 file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content το αρχείο JPEG 2000 έχει μη υποστηριζόμενο χρωματικό χώρο ή περιεχόμενο JPX +@msg the HEIC or HEIF file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου HEIC ή HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read το αρχείο HEIC ή HEIF περιέχει είδος εικόνας που δεν είναι δυνατό να διαβαστεί +@msg HEIC and HEIF images can't be opened in this build οι εικόνες HEIC και HEIF δεν μπορούν να ανοίξουν σε αυτή την έκδοση @msg the PSD image is too large to read ({_1} × {_2} pixels) η εικόνα PSD είναι πολύ μεγάλη για ανάγνωση ({_1} × {_2} εικονοστοιχεία) @msg zipped 32-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 32 bit με πρόβλεψη δεν διαβάζονται @msg {depth}-bit PSD files in this colour mode can't be read τα αρχεία PSD {depth} bit δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος @@ -5662,6 +5673,9 @@ effect Points Σημεία @msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel). +@msg byColor must be true or false το byColor πρέπει να είναι true ή false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping το byColor δεν μπορεί να χωρίσει τις ομάδες της Αυτόματης ομαδοποίησης: ανιχνεύστε χωρίς Αυτόματη ομαδοποίηση +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor το byColor δεν μπορεί να αναομαδοποιήσει μια ανίχνευση με ενεργό εφέ Pathfinder: επεκτείνετε χωρίς byColor Snapping Προσκόλληση Snap to Tangent Προσκόλληση σε εφαπτομένη Snap to Perpendicular Προσκόλληση σε κάθετο @@ -6222,6 +6236,8 @@ breakText Glyphs Γλύφοι File + Data Set Name Αρχείο + όνομα συνόλου δεδομένων Data Set Name Όνομα συνόλου δεδομένων @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` Το `datasetFileNaming` πρέπει να είναι `fileNumber`, `fileDataSetName` ή `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Κάθε σύνολο δεδομένων αποθηκεύεται πάνω στο ίδιο αρχείο, το οποίο κρατά μόνο το τελευταίο σύνολο δεδομένων. +@msg save the document with Save As before Save and Close αποθηκεύστε το έγγραφο με «Αποθήκευση ως» πριν από «Αποθήκευση και κλείσιμο» @msg no free data set file name in destination folder Δεν υπάρχει διαθέσιμο όνομα αρχείου συνόλου δεδομένων στον φάκελο προορισμού # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 924a46fa3..d67b4b3c9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1173,6 +1173,7 @@ font-technology Variable Variables Stroke When Editable Trazo si es editable Fill Color Color de relleno Fill New Type Objects With Placeholder Text Rellenar nuevos objetos de texto con texto de muestra + Start Lists From Typed Markers Iniciar listas a partir de marcadores escritos Fill Rule Regla de relleno Fill with Placeholder Text Rellenar con texto de muestra Insert Inline Symbol Insertar símbolo en el texto @@ -3061,6 +3062,11 @@ digits Malayalam Malayálam Selected Swatches Muestras seleccionadas Selected Swatches Only Solo muestras seleccionadas Selection Selección +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selección rectangular + Touching Objetos tocados + Fully Enclosed Totalmente incluidos + Drag Direction Dirección de arrastre Selection & Anchor Display Visualización de selección y anclas Selection Only Solo la selección Selection Tool Herramienta Selección @@ -3198,6 +3204,7 @@ digits Malayalam Malayálam Simulate Colored Paper Simular papel de color Simulate Paper Simular papel Double Column Columna doble + Keep Tools Beside the Canvas Herramientas junto al lienzo Single Left Quotation Mark Comilla simple de apertura Single Quotes: Comillas simples: Single Right Quotation Mark Comilla simple de cierre @@ -3793,6 +3800,7 @@ digits Malayalam Malayálam Zoom to Selection Zoom a la selección Zoom Tool Herramienta Zoom Zoom with Mouse Wheel Zoom con la rueda del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Desplazar horizontalmente con Cmd/Ctrl + rueda del mouse Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} archivo en {folder} {note}. ¿Mostrar la carpeta del paquete?|{n} archivos en {folder} {note}. ¿Mostrar la carpeta del paquete? @plural {n} linked file not found|{n} linked files not found No se encontró {n} archivo vinculado|No se encontraron {n} archivos vinculados @@ -4803,6 +4811,9 @@ axis Both Ambos @msg the Pixar file's image data is damaged los datos de imagen del archivo Pixar están dañados @msg the JPEG 2000 file can't be read no se puede leer el archivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el archivo JPEG 2000 tiene un espacio de color o contenido JPX no compatible +@msg the HEIC or HEIF file can't be read no se puede leer el archivo HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el archivo HEIC o HEIF contiene un tipo de imagen que no se puede leer +@msg HEIC and HEIF images can't be opened in this build las imágenes HEIC y HEIF no se pueden abrir en esta compilación @msg the PSD image is too large to read ({_1} × {_2} pixels) la imagen PSD es demasiado grande para leerla ({_1} × {_2} píxeles) @msg zipped 32-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 32 bits comprimidos en ZIP con predicción @msg {depth}-bit PSD files in this colour mode can't be read los archivos PSD de {depth} bits en este modo de color no se pueden leer @@ -5652,6 +5663,9 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo). +@msg byColor must be true or false byColor debe ser true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no puede dividir los grupos de Agrupación automática: calque sin Agrupación automática +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no puede reagrupar un calco con un efecto Pathfinder activo: expanda sin byColor Snapping Ajuste Snap to Tangent Ajustar a tangente Snap to Perpendicular Ajustar a perpendicular @@ -6213,6 +6227,8 @@ breakText Glyphs Glifos File + Data Set Name Archivo + nombre del conjunto de datos Data Set Name Nombre del conjunto de datos @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` debe ser `fileNumber`, `fileDataSetName` o `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Cada conjunto de datos se guarda sobre el mismo archivo, que solo conserva el último conjunto de datos. +@msg save the document with Save As before Save and Close guarde el documento con «Guardar como» antes de «Guardar y cerrar» @msg no free data set file name in destination folder No hay un nombre de archivo libre para el conjunto de datos en la carpeta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ac9b2153..277fd21a2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -194,6 +194,11 @@ Ungroup Pura ryhmitys Lock Lukitse Selection Valinta +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Suorakulmiovalinta + Touching Kosketetut + Fully Enclosed Kokonaan sisällä + Drag Direction Vetosuunta All Artwork Above Kaikki yllä oleva kuvitus Other Layers Muut tasot Unlock All Avaa kaikki lukitukset @@ -664,6 +669,7 @@ Toolbars Työkalurivit Advanced Lisäasetukset Double Column Kaksi saraketta + Keep Tools Beside the Canvas Työkalut kankaan vieressä Actions Toiminnot Asset Export Resurssien vienti Attributes Määritteet @@ -3558,6 +3564,7 @@ theme Appearance Ulkoasu Feet Jalat Files Tiedostot Fill New Type Objects With Placeholder Text Täytä uudet tekstiobjektit paikkamerkkitekstillä + Start Lists From Typed Markers Aloita luettelot kirjoitetuista merkeistä Folder Kansio Font Preview Size Fontin esikatselukoko Get Preferences Hae asetukset @@ -3668,6 +3675,7 @@ theme Appearance Ulkoasu Yards Jaardit Zoom to Selection Zoomaa valintaan Zoom with Mouse Wheel Zoomaa hiiren rullalla + Scroll Sideways with Cmd/Ctrl + Wheel Vieritä vaakasuunnassa Cmd/Ctrl + hiiren rullalla Scrub Numeric Fields by Dragging Muuta numerokenttiä vetämällä Linux Display Server Linuxin näyttöpalvelin Print Preview Tulostuksen esikatselu @@ -4985,6 +4993,9 @@ imageTrace Outline Ääriviiva @msg the Pixar file's image data is damaged Pixar-tiedoston kuvadata on vahingoittunut @msg the JPEG 2000 file can't be read tätä JPEG 2000 -tiedostoa ei voi lukea @msg the JPEG 2000 file has an unsupported color space or JPX content tässä JPEG 2000 -tiedostossa on tukematon väriavaruus tai JPX-sisältö +@msg the HEIC or HEIF file can't be read tätä HEIC- tai HEIF-tiedostoa ei voi lukea +@msg the HEIC or HEIF file holds a kind of image that can't be read tässä HEIC- tai HEIF-tiedostossa on kuvatyyppi, jota ei voi lukea +@msg HEIC and HEIF images can't be opened in this build HEIC- ja HEIF-kuvia ei voi avata tässä versiossa @msg the PSD image is too large to read ({_1} × {_2} pixels) tämä PSD-kuva on liian suuri luettavaksi ({_1} × {_2} pikseliä) @msg zipped 32-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 32-bittistä PSD-kuvadataa ei voi lukea @msg {depth}-bit PSD files in this colour mode can't be read tässä väritilassa olevia {depth}-bittisiä PSD-tiedostoja ei voi lukea @@ -5674,6 +5685,9 @@ mark-style Japanese Japanilainen @msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä). +@msg byColor must be true or false byColor-arvon on oltava true tai false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ei voi jakaa Automaattisen ryhmittelyn ryhmiä: jäljitä ilman Automaattista ryhmittelyä +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ei voi ryhmitellä uudelleen jäljitystä, jossa on aktiivinen Pathfinder-tehoste: laajenna ilman byColor-asetusta Snapping Tartunta Snap to Tangent Kohdista tangenttiin Snap to Perpendicular Kohdista kohtisuoraan @@ -6301,6 +6315,8 @@ breakText Glyphs Merkit File + Data Set Name Tiedosto + tietojoukon nimi Data Set Name Tietojoukon nimi @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`-arvon on oltava `fileNumber`, `fileDataSetName` tai `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Jokainen tietojoukko tallennetaan saman tiedoston päälle, johon jää vain viimeinen tietojoukko. +@msg save the document with Save As before Save and Close tallenna asiakirja komennolla Tallenna nimellä ennen kuin valitset Tallenna ja sulje @msg no free data set file name in destination folder Kohdekansiossa ei ole vapaata tietojoukon tiedostonimeä # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0c3564b1c..3c8f95b8b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1168,6 +1168,7 @@ font-technology Variable Variables Stroke When Editable Contour quand il est modifiable Fill Color Couleur de fond Fill New Type Objects With Placeholder Text Remplir les nouveaux objets texte avec un texte de substitution + Start Lists From Typed Markers Commencer des listes à partir des marqueurs saisis Fill Rule Règle de remplissage Fill with Placeholder Text Remplir avec un texte de substitution Fill: Fond : @@ -3053,6 +3054,11 @@ digits Malayalam Malayalam Selected Swatches Nuances sélectionnées Selected Swatches Only Nuances sélectionnées uniquement Selection Sélection +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Sélection rectangulaire + Touching Objets touchés + Fully Enclosed Entièrement inclus + Drag Direction Sens du glissement Selection & Anchor Display Affichage de la sélection et des points d’ancrage Selection Only Sélection uniquement Selection Tool Outil Sélection @@ -3190,6 +3196,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simuler un papier de couleur Simulate Paper Simuler le papier Double Column Deux colonnes + Keep Tools Beside the Canvas Outils à côté de la zone de travail Single Left Quotation Mark Guillemet simple ouvrant Single Quotes: Guillemets simples : Single Right Quotation Mark Guillemet simple fermant @@ -3785,6 +3792,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sur la sélection Zoom Tool Outil Zoom Zoom with Mouse Wheel Zoom avec la molette de la souris + Scroll Sideways with Cmd/Ctrl + Wheel Défilement horizontal avec Cmd/Ctrl + molette Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fichier dans {folder} {note}. Afficher le dossier de l’assemblage ?|{n} fichiers dans {folder} {note}. Afficher le dossier de l’assemblage ? @plural {n} linked file not found|{n} linked files not found {n} fichier lié introuvable|{n} fichiers liés introuvables @@ -4811,6 +4819,9 @@ axis Both Les deux @msg the Pixar file's image data is damaged les données d'image du fichier Pixar sont endommagées @msg the JPEG 2000 file can't be read le fichier JPEG 2000 ne peut pas être lu @msg the JPEG 2000 file has an unsupported color space or JPX content le fichier JPEG 2000 contient un espace colorimétrique ou un contenu JPX non pris en charge +@msg the HEIC or HEIF file can't be read le fichier HEIC ou HEIF ne peut pas être lu +@msg the HEIC or HEIF file holds a kind of image that can't be read le fichier HEIC ou HEIF contient un type d'image qui ne peut pas être lu +@msg HEIC and HEIF images can't be opened in this build les images HEIC et HEIF ne peuvent pas être ouvertes dans cette version @msg the PSD image is too large to read ({_1} × {_2} pixels) l'image PSD est trop grande pour être lue ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read les données d'image PSD 32 bits compressées en ZIP avec prédiction ne peuvent pas être lues @msg {depth}-bit PSD files in this colour mode can't be read les fichiers PSD {depth} bits dans ce mode colorimétrique ne peuvent pas être lus @@ -5706,6 +5717,9 @@ theme Appearance Apparence @msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus). +@msg byColor must be true or false byColor doit être true ou false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ne peut pas scinder les groupes du Groupement automatique : vectorisez sans Groupement automatique +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ne peut pas regrouper un tracé avec un effet Pathfinder actif : développez sans byColor Snapping Magnétisme Snap to Tangent Magnétisme de la tangente Snap to Perpendicular Magnétisme de la perpendiculaire @@ -6266,6 +6280,8 @@ breakText Glyphs Glyphes File + Data Set Name Fichier + nom du jeu de données Data Set Name Nom du jeu de données @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` doit être `fileNumber`, `fileDataSetName` ou `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Chaque jeu de données est enregistré sur le même fichier, qui ne conserve que le dernier jeu de données. +@msg save the document with Save As before Save and Close enregistrez le document avec Enregistrer sous avant Enregistrer et fermer @msg no free data set file name in destination folder Aucun nom de fichier libre pour le jeu de données dans le dossier de destination # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9ed4c6f39..6a7f5ef46 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7960,6 +7960,11 @@ swatch White, Black सफ़ेद, काला swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस + Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें + Marquee Selection मार्की चयन + Touching स्पर्श करते हुए + Fully Enclosed पूरी तरह घिरे हुए + Drag Direction खींचने की दिशा # Importer plug-ins (File › Open With, document.open) Open With इसके साथ खोलें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8b98d6d5d..b30001e07 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1111,6 +1111,7 @@ font-technology Variable Változó Double Bar Kettős vonal Double Click To Isolate Elkülönítés dupla kattintással Double Column Két oszlop + Keep Tools Beside the Canvas Eszközök a vászon mellett Double Left Quotation Marks Bal oldali dupla idézőjel Double Quotes: Dupla idézőjelek: Double Right Quotation Marks Jobb oldali dupla idézőjel @@ -1467,6 +1468,7 @@ digits Malayalam Malajálam Highlight: Kiemelés: Stroke When Editable Körvonal, ha szerkeszthető Fill New Type Objects With Placeholder Text Új szövegobjektumok kitöltése helykitöltő szöveggel + Start Lists From Typed Markers Listák indítása begépelt jelölőkből Fill Rule Kitöltési szabály Fill with Placeholder Text Kitöltés helykitöltő szöveggel Fill: Kitöltés: @@ -3666,6 +3668,11 @@ mark-style Japanese Japán Selected Swatches Only Csak a kijelölt színminták Selected Swatches Only (0) Csak a kijelölt színminták (0) Selection Kijelölés +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Téglalapos kijelölés + Touching Érintett objektumok + Fully Enclosed Teljesen befoglalt + Drag Direction Húzás iránya Selection & Anchor Display Kijelölés és horgonypontok megjelenítése Selection Only Csak a kijelölés Selection Tool Kijelölés @@ -4526,6 +4533,7 @@ mark-style Japanese Japán Zoom to Selection Nagyítás a kijelölésre Zoom Tool Nagyító Zoom with Mouse Wheel Nagyítás egérgörgővel + Scroll Sideways with Cmd/Ctrl + Wheel Vízszintes görgetés Cmd/Ctrl + görgővel @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Fájlok: {n}, mappa: {folder} {note}. Megnyitod a csomag mappáját? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read Nem olvasható mappák vagy fájlok: {n} @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Kihagyott mappák: {n} (például más alkalmazások, a rendszer és a rejtett mappák) @@ -5327,6 +5335,9 @@ imageTrace Outline Körvonal @msg the Pixar file's image data is damaged a Pixar-fájl képadatai sérültek @msg the JPEG 2000 file can't be read a JPEG 2000 fájl nem olvasható @msg the JPEG 2000 file has an unsupported color space or JPX content a JPEG 2000 fájl nem támogatott színteret vagy JPX-tartalmat tartalmaz +@msg the HEIC or HEIF file can't be read a HEIC- vagy HEIF-fájl nem olvasható +@msg the HEIC or HEIF file holds a kind of image that can't be read a HEIC- vagy HEIF-fájl olyan képtípust tartalmaz, amely nem olvasható +@msg HEIC and HEIF images can't be opened in this build a HEIC- és HEIF-képek ebben a buildben nem nyithatók meg @msg the PSD image is too large to read ({_1} × {_2} pixels) a PSD-kép túl nagy a beolvasáshoz ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open a PDF mentése párbeszédablak nem nyílt meg @msg the scale is not a usable number a méretarány nem használható szám @@ -5694,6 +5705,9 @@ imageTrace Outline Körvonal @msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel). +@msg byColor must be true or false a byColor értéke true vagy false lehet +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping a byColor nem bontja fel az Automatikus csoportosítás csoportjait: vektorizáljon Automatikus csoportosítás nélkül +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor a byColor nem csoportosíthatja át az élő Pathfinder-effektussal rendelkező vektorizálást: bővítse ki byColor nélkül Snapping Illesztés Snap to Tangent Illesztés érintőre Snap to Perpendicular Illesztés merőlegesre @@ -6255,6 +6269,8 @@ breakText Glyphs Glifák File + Data Set Name Fájl + adatkészlet neve Data Set Name Adatkészlet neve @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` A `datasetFileNaming` értéke `fileNumber`, `fileDataSetName` vagy `dataSetName` lehet + Each data set saves over the same file, which keeps only the last data set. Minden adatkészlet ugyanabba a fájlba mentődik, amely csak az utolsó adatkészletet őrzi meg. +@msg save the document with Save As before Save and Close mentse a dokumentumot a „Mentés másként” paranccsal a „Mentés és bezárás” előtt @msg no free data set file name in destination folder Nincs szabad adatkészlet-fájlnév a célmappában # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 814b0e369..662afcf9c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -172,6 +172,11 @@ Ungroup Buka grup Lock Kunci Selection Seleksi +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Seleksi persegi panjang + Touching Objek tersentuh + Fully Enclosed Sepenuhnya di dalam + Drag Direction Arah seret All Artwork Above Semua karya seni di atas Other Layers Lapisan lain Unlock All Buka kunci semua @@ -622,6 +627,7 @@ Toolbars Bilah alat Advanced Lanjutan Double Column Kolom ganda + Keep Tools Beside the Canvas Alat di samping kanvas Actions Tindakan Asset Export Ekspor aset Attributes Atribut @@ -3362,6 +3368,7 @@ theme Appearance Tampilan Feet Kaki Files File Fill New Type Objects With Placeholder Text Isi objek teks baru dengan teks placeholder + Start Lists From Typed Markers Mulai daftar dari penanda yang diketik Folder Folder Font Preview Size Ukuran pratinjau font Get Preferences Dapatkan preferensi @@ -3472,6 +3479,7 @@ theme Appearance Tampilan Yards Yard Zoom to Selection Zum ke seleksi Zoom with Mouse Wheel Zum dengan roda mouse + Scroll Sideways with Cmd/Ctrl + Wheel Gulir horizontal dengan Cmd/Ctrl + roda mouse Scrub Numeric Fields by Dragging Atur nilai kolom numerik dengan menyeret Linux Display Server Server tampilan Linux Print Preview Pratinjau cetak @@ -6114,6 +6122,8 @@ breakText Glyphs Glif File + Data Set Name File + nama set data Data Set Name Nama set data @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` harus `fileNumber`, `fileDataSetName`, atau `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Setiap set data disimpan menimpa file yang sama, sehingga hanya set data terakhir yang tersisa. +@msg save the document with Save As before Save and Close simpan dokumen dengan Simpan sebagai sebelum Simpan dan tutup @msg no free data set file name in destination folder Tidak ada nama file set data yang tersedia di folder tujuan # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index aa472e43e..f118233bc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1175,6 +1175,7 @@ font-technology Variable Variabili Stroke When Editable Traccia se modificabile Fill Color Colore riempimento Fill New Type Objects With Placeholder Text Riempi i nuovi oggetti di testo con testo segnaposto + Start Lists From Typed Markers Avvia elenchi dai marcatori digitati Fill Rule Regola di riempimento Fill with Placeholder Text Riempi con testo segnaposto Fill: Riempimento: @@ -3097,6 +3098,11 @@ digits Malayalam Malayalam Selected Swatches Campioni selezionati Selected Swatches Only Solo campioni selezionati Selection Selezione +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selezione rettangolare + Touching Oggetti toccati + Fully Enclosed Interamente inclusi + Drag Direction Direzione di trascinamento Selection & Anchor Display Visualizzazione selezione e punti di ancoraggio Selection Only Solo selezione Selection Tool Strumento Selezione @@ -3234,6 +3240,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simula carta colorata Simulate Paper Simula carta Double Column Colonna doppia + Keep Tools Beside the Canvas Strumenti accanto all'area di disegno Single Left Quotation Mark Virgoletta singola di apertura Single Quotes: Virgolette singole: Single Right Quotation Mark Virgoletta singola di chiusura @@ -3830,6 +3837,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sulla selezione Zoom Tool Strumento Zoom Zoom with Mouse Wheel Zoom con la rotellina del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Scorrimento orizzontale con Cmd/Ctrl + rotellina Scrub Numeric Fields by Dragging Regola i campi numerici trascinando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto?|{n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto? @plural {n} linked file not found|{n} linked files not found File collegati non trovati: {n}|File collegati non trovati: {n} @@ -4845,6 +4853,9 @@ axis Both Entrambi @msg the Pixar file's image data is damaged i dati immagine del file Pixar sono danneggiati @msg the JPEG 2000 file can't be read impossibile leggere il file JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content il file JPEG 2000 contiene uno spazio colore o un contenuto JPX non supportato +@msg the HEIC or HEIF file can't be read impossibile leggere il file HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read il file HEIC o HEIF contiene un tipo di immagine che non può essere letto +@msg HEIC and HEIF images can't be opened in this build le immagini HEIC e HEIF non possono essere aperte in questa build @msg the PSD image is too large to read ({_1} × {_2} pixels) l'immagine PSD è troppo grande per essere letta ({_1} × {_2} pixel) @msg zipped 32-bit PSD image data with prediction can't be read i dati immagine PSD a 32 bit compressi ZIP con previsione non possono essere letti @msg {depth}-bit PSD files in this colour mode can't be read i file PSD a {depth} bit in questo metodo colore non possono essere letti @@ -5324,6 +5335,9 @@ lighting Light Luce @msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel). +@msg byColor must be true or false byColor deve essere true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor non può dividere i gruppi di Raggruppamento automatico: vettorializza senza Raggruppamento automatico +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor non può raggruppare di nuovo una traccia con un effetto Pathfinder attivo: espandi senza byColor Snapping Aggancio Snap to Tangent Aggancia alla tangente Snap to Perpendicular Aggancia alla perpendicolare @@ -5890,6 +5904,8 @@ breakText Glyphs Glifi File + Data Set Name File + nome set di dati Data Set Name Nome set di dati @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve essere `fileNumber`, `fileDataSetName` o `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Ogni set di dati viene salvato sullo stesso file, che conserva solo l'ultimo set di dati. +@msg save the document with Save As before Save and Close salva il documento con «Salva con nome» prima di «Salva e chiudi» @msg no free data set file name in destination folder Nessun nome file libero per il set di dati nella cartella di destinazione # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cb93e5a58..3014955a8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -213,6 +213,11 @@ digits Malayalam マラヤーラム数字 Ungroup グループ解除 Lock ロック Selection 選択 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 矩形選択 + Touching 接触するオブジェクト + Fully Enclosed 完全に囲まれたオブジェクト + Drag Direction ドラッグ方向 All Artwork Above 前面のすべてのアートワーク Other Layers その他のレイヤー Unlock All すべてをロック解除 @@ -679,6 +684,7 @@ digits Malayalam マラヤーラム数字 Toolbars ツールバー Advanced 詳細 Double Column 2 列 + Keep Tools Beside the Canvas ツールをキャンバスの横に配置 Actions アクション Asset Export アセットの書き出し Attributes 属性 @@ -1635,6 +1641,7 @@ digits Malayalam マラヤーラム数字 Files of this standard tag their colours with ICC profiles. この規格のファイルでは、カラーに ICC プロファイルが付けられます。 Fill 塗り Fill New Type Objects With Placeholder Text 新規テキストオブジェクトをダミーテキストで埋める + Start Lists From Typed Markers 入力した記号からリストを開始 Fill Rule 塗りのルール Fill and Stroke 塗りと線 Fill new strokes 新しいストロークに塗りを適用 @@ -3408,6 +3415,7 @@ mark-style Japanese 日本式 Zoom Tool ズームツール Zoom to Selection 選択範囲にズーム Zoom with Mouse Wheel マウスホイールでズーム + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + ホイールで横スクロール Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整 [Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。 [Unnamed] [名称未設定] @@ -4886,6 +4894,9 @@ swatch Bright Violet ビビッドバイオレット @msg the Pixar file's image data is damaged Pixar ファイルの画像データが壊れています @msg the JPEG 2000 file can't be read JPEG 2000 ファイルを読み込めません @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ファイルには未対応の色空間または JPX コンテンツがあります +@msg the HEIC or HEIF file can't be read HEIC または HEIF ファイルを読み込めません +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC または HEIF ファイルには読み込めない種類の画像が含まれています +@msg HEIC and HEIF images can't be opened in this build このビルドでは HEIC と HEIF の画像を開けません @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 画像が大きすぎて読み込めません({_1} × {_2} ピクセル) @msg zipped 32-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 32 bit PSD 画像データは読み込めません @msg {depth}-bit PSD files in this colour mode can't be read このカラーモードの {depth} bit PSD ファイルは読み込めません @@ -5367,6 +5378,9 @@ lighting Light ライト @msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。 +@msg byColor must be true or false byColor は true または false にしてください +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor は自動グループ化のグループを分割できません: 自動グループ化なしでトレースしてください +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor はライブ Pathfinder 効果のあるトレースを再グループ化できません: byColor なしで拡張してください Snapping スナップ Snap to Tangent 接線にスナップ Snap to Perpendicular 垂線にスナップ @@ -5955,6 +5969,8 @@ breakText Glyphs 字形 File + Data Set Name ファイル名 + データセット名 Data Set Name データセット名 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` は `fileNumber`、`fileDataSetName`、`dataSetName` のいずれかである必要があります + Each data set saves over the same file, which keeps only the last data set. 各データセットが同じファイルに上書き保存されるため、最後のデータセットだけが残ります。 +@msg save the document with Save As before Save and Close 「保存して閉じる」の前に「別名で保存」でドキュメントを保存してください @msg no free data set file name in destination folder 保存先フォルダーに使用可能なデータセットのファイル名がありません # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4da31fcaf..899b1278c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -146,6 +146,11 @@ Ungroup დაჯგუფების მოხსნა Lock ჩაკეტვა Selection მონიშნული +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection მართკუთხა მონიშვნა + Touching შეხებული ობიექტები + Fully Enclosed სრულად მოქცეული + Drag Direction გადათრევის მიმართულება All Artwork Above ზემოთ მდებარე მთელი ნახატი Other Layers სხვა შრეები Unlock All ყველაფრის განბლოკვა @@ -575,6 +580,7 @@ Toolbars ხელსაწყოთა ზოლები Advanced დამატებითი Double Column ორი სვეტი + Keep Tools Beside the Canvas ხელსაწყოები ტილოს გვერდით Actions მოქმედებები Asset Export რესურსების ექსპორტირება Attributes ატრიბუტები @@ -3178,6 +3184,7 @@ theme Appearance იერსახე Feet ფუტები Files ფაილები Fill New Type Objects With Placeholder Text ახალი ტექსტური ობიექტების ჩანაცვლების ტექსტით შევსება + Start Lists From Typed Markers სიების დაწყება აკრეფილი მარკერებიდან Folder საქაღალდე Font Preview Size შრიფტის ნიმუშის ზომა Get Preferences პარამეტრების მიღება @@ -3288,6 +3295,7 @@ theme Appearance იერსახე Yards იარდები Zoom to Selection მონიშნულის მასშტაბირება Zoom with Mouse Wheel მაუსის ბორბლით მასშტაბირება + Scroll Sideways with Cmd/Ctrl + Wheel ჰორიზონტალური გადაადგილება Cmd/Ctrl + ბორბლით Scrub Numeric Fields by Dragging რიცხვითი ველების შეცვლა გადათრევით Linux Display Server Linux-ის ეკრანის სერვერი Print Preview ნახვა ამობეჭდვამდე @@ -4502,6 +4510,9 @@ font-technology Variable ცვლადი @msg the Pixar file's image data is damaged Pixar ფაილის გამოსახულების მონაცემები დაზიანებულია @msg the JPEG 2000 file can't be read JPEG 2000 ფაილის წაკითხვა შეუძლებელია @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ფაილს აქვს მხარდაუჭერელი ფერთა სივრცე ან JPX შიგთავსი +@msg the HEIC or HEIF file can't be read HEIC ან HEIF ფაილის წაკითხვა შეუძლებელია +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC ან HEIF ფაილი შეიცავს გამოსახულების ტიპს, რომლის წაკითხვაც შეუძლებელია +@msg HEIC and HEIF images can't be opened in this build ამ ვერსიაში HEIC და HEIF გამოსახულებების გახსნა შეუძლებელია @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD გამოსახულება ზედმეტად დიდია წასაკითხად ({_1} × {_2} პიქსელი) @msg zipped 32-bit PSD image data with prediction can't be read zip-ით შეკუმშული 32-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება @msg {depth}-bit PSD files in this colour mode can't be read ამ ფერის რეჟიმში {depth}-ბიტიანი PSD ფაილების წაკითხვა ვერ ხერხდება @@ -5557,6 +5568,9 @@ imageTrace Outline კონტური @msg `colorGroup` needs `mode: "color"` `colorGroup`-ს სჭირდება `mode: "color"` @msg no color group `{name}` in the document (see swatch.list) დოკუმენტში ფერების ჯგუფი `{name}` არ არის (იხ. swatch.list) @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. ბრტყელი ლოგოს რეჟიმში გამოსახულება ვექტორიზდა {_1} × {_2} პიქსელზე, საშუალოს გამოთვლით შემცირდა {_3} × {_4}-დან. +@msg byColor must be true or false byColor უნდა იყოს true ან false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ვერ დაყოფს ავტომატური დაჯგუფების ჯგუფებს: ვექტორიზება გააკეთეთ ავტომატური დაჯგუფების გარეშე +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ვერ გადააჯგუფებს ტრასირებას აქტიური Pathfinder ეფექტით: გააფართოვეთ byColor-ის გარეშე @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ბიბლიოთეკის პანელი არ არის (იხ. window.swatchLibrary) @msg `{key}` must be a #rrggbb color `{key}` უნდა იყოს #rrggbb ფერი @msg a key object sits on that point ამ წერტილზე საკვანძო ობიექტი მდებარეობს @@ -6268,6 +6282,8 @@ breakText Glyphs გლიფები File + Data Set Name ფაილი + მონაცემთა ნაკრების სახელი Data Set Name მონაცემთა ნაკრების სახელი @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` უნდა იყოს `fileNumber`, `fileDataSetName` ან `dataSetName` + Each data set saves over the same file, which keeps only the last data set. თითოეული მონაცემთა ნაკრები ერთსა და იმავე ფაილზე ინახება, რომელშიც მხოლოდ ბოლო მონაცემთა ნაკრები რჩება. +@msg save the document with Save As before Save and Close „შენახვა და დახურვამდე“ შეინახეთ დოკუმენტი „შენახვა როგორც“ ბრძანებით @msg no free data set file name in destination folder დანიშნულების საქაღალდეში მონაცემთა ნაკრებისთვის თავისუფალი ფაილის სახელი არ არის # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 797d6efb1..5ac7a66ce 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -206,6 +206,11 @@ digits Malayalam 말라얄람어 Ungroup 그룹 해제 Lock 잠그기 Selection 선택 영역 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 사각형 선택 + Touching 닿는 개체 + Fully Enclosed 완전히 포함된 개체 + Drag Direction 드래그 방향 All Artwork Above 위의 모든 아트워크 Other Layers 다른 레이어 Unlock All 모두 잠금 해제 @@ -672,6 +677,7 @@ digits Malayalam 말라얄람어 Toolbars 도구 모음 Advanced 고급 Double Column 2열 + Keep Tools Beside the Canvas 캔버스 옆에 도구 배치 Actions 액션 Asset Export 에셋 내보내기 Attributes 속성 @@ -1625,6 +1631,7 @@ digits Malayalam 말라얄람어 Files of this standard tag their colours with ICC profiles. 이 표준의 파일은 ICC 프로파일로 색상을 태그합니다. Fill 칠 Fill New Type Objects With Placeholder Text 새 문자 개체를 대체 텍스트로 채우기 + Start Lists From Typed Markers 입력한 기호로 목록 시작 Fill Rule 칠 규칙 Fill and Stroke 칠과 선 Fill new strokes 새 선 칠하기 @@ -3397,6 +3404,7 @@ mark-style Japanese 일본식 Zoom Tool 확대/축소 도구 Zoom to Selection 선택 항목에 맞춰 확대/축소 Zoom with Mouse Wheel 마우스 휠로 확대/축소 + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + 휠로 가로 스크롤 Scrub Numeric Fields by Dragging 드래그로 숫자 필드 조절 [Default] is protected: New… starts an editable copy. [Default]는 보호되어 있습니다. 새로 만들기…를 누르면 편집 가능한 복사본이 만들어집니다. [Unnamed] [이름 없음] @@ -4813,6 +4821,9 @@ theme Appearance 외관 @msg the Pixar file's image data is damaged Pixar 파일의 이미지 데이터가 손상되었습니다 @msg the JPEG 2000 file can't be read JPEG 2000 파일을 읽을 수 없습니다 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 파일에 지원되지 않는 색 공간 또는 JPX 콘텐츠가 있습니다 +@msg the HEIC or HEIF file can't be read HEIC 또는 HEIF 파일을 읽을 수 없습니다 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 또는 HEIF 파일에 읽을 수 없는 종류의 이미지가 있습니다 +@msg HEIC and HEIF images can't be opened in this build 이 빌드에서는 HEIC 및 HEIF 이미지를 열 수 없습니다 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 이미지가 너무 커서 읽을 수 없습니다({_1} × {_2}픽셀) @msg zipped 32-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 32비트 PSD 이미지 데이터는 읽을 수 없습니다 @msg {depth}-bit PSD files in this colour mode can't be read 이 색상 모드의 {depth}비트 PSD 파일은 읽을 수 없습니다 @@ -5285,6 +5296,9 @@ lighting Light 광원 @msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀). +@msg byColor must be true or false byColor는 true 또는 false여야 합니다 +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor는 자동 그룹화 그룹을 나눌 수 없습니다. 자동 그룹화 없이 추적하세요 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor는 라이브 Pathfinder 효과가 있는 추적을 다시 그룹화할 수 없습니다. byColor 없이 확장하세요 Snapping 스냅 Snap to Tangent 접선에 스냅 Snap to Perpendicular 수직선에 스냅 @@ -5851,6 +5865,8 @@ breakText Glyphs 글리프 File + Data Set Name 파일 + 데이터 세트 이름 Data Set Name 데이터 세트 이름 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`은 `fileNumber`, `fileDataSetName` 또는 `dataSetName`이어야 합니다 + Each data set saves over the same file, which keeps only the last data set. 각 데이터 세트가 같은 파일에 덮어써지므로 마지막 데이터 세트만 남습니다. +@msg save the document with Save As before Save and Close '저장 후 닫기' 전에 '다른 이름으로 저장'으로 문서를 저장하십시오 @msg no free data set file name in destination folder 대상 폴더에 사용할 수 있는 데이터 세트 파일 이름이 없습니다 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f41c2f95c..796f8e487 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -186,6 +186,11 @@ Ungroup Groepering opheffen Lock Vergrendelen Selection Selectie +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rechthoekselectie + Touching Geraakt + Fully Enclosed Volledig omsloten + Drag Direction Sleeprichting All Artwork Above Alle artwork erboven Other Layers Andere lagen Unlock All Alles ontgrendelen @@ -636,6 +641,7 @@ Toolbars Werkbalken Advanced Geavanceerd Double Column Dubbele kolom + Keep Tools Beside the Canvas Gereedschappen naast het canvas Actions Acties Asset Export Assetexport Attributes Kenmerken @@ -3376,6 +3382,7 @@ theme Appearance Vormgeving Feet Voet Files Bestanden Fill New Type Objects With Placeholder Text Nieuwe tekstobjecten met plaatshoudertekst vullen + Start Lists From Typed Markers Lijsten starten met getypte opsommingstekens Folder Map Font Preview Size Grootte van lettertypevoorbeelden Get Preferences Voorkeuren ophalen @@ -3486,6 +3493,7 @@ theme Appearance Vormgeving Yards Yards Zoom to Selection Inzoomen op selectie Zoom with Mouse Wheel Zoomen met muiswiel + Scroll Sideways with Cmd/Ctrl + Wheel Horizontaal scrollen met Cmd/Ctrl + muiswiel Scrub Numeric Fields by Dragging Numerieke velden aanpassen door te slepen Linux Display Server Linux-weergaveserver Print Preview Afdrukvoorbeeld @@ -6160,6 +6168,8 @@ breakText Glyphs Glyphs File + Data Set Name Bestand + naam gegevensset Data Set Name Naam gegevensset @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` moet `fileNumber`, `fileDataSetName` of `dataSetName` zijn + Each data set saves over the same file, which keeps only the last data set. Elke gegevensset wordt over hetzelfde bestand opgeslagen, dat alleen de laatste gegevensset bewaart. +@msg save the document with Save As before Save and Close sla het document op met Opslaan als voordat u Opslaan en sluiten kiest @msg no free data set file name in destination folder Geen vrije bestandsnaam voor de gegevensset in de doelmap # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1169df06e..8c948aef2 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -152,6 +152,11 @@ Ungroup Rozgrupuj Lock Zablokuj Selection Zaznaczenie +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Zaznaczanie prostokątne + Touching Dotknięte + Fully Enclosed Całkowicie wewnątrz + Drag Direction Kierunek przeciągania All Artwork Above Całą kompozycję powyżej Other Layers Pozostałe warstwy Unlock All Odblokuj wszystko @@ -623,6 +628,7 @@ Toolbars Paski narzędzi Advanced Zaawansowany Double Column Dwie kolumny + Keep Tools Beside the Canvas Narzędzia obok płótna Actions Operacje Asset Export Eksport zasobów Attributes Atrybuty @@ -3497,6 +3503,7 @@ theme Appearance Wygląd Feet Stopy Files Pliki Fill New Type Objects With Placeholder Text Wypełniaj nowe obiekty tekstowe tekstem zastępczym + Start Lists From Typed Markers Rozpoczynaj listy od wpisanych znaczników Folder Folder Font Preview Size Rozmiar podglądu czcionki Get Preferences Pobierz preferencje @@ -3607,6 +3614,7 @@ theme Appearance Wygląd Yards Jardy Zoom to Selection Powiększaj do zaznaczenia Zoom with Mouse Wheel Powiększaj kółkiem myszy + Scroll Sideways with Cmd/Ctrl + Wheel Przewijaj w poziomie kółkiem z Cmd/Ctrl Scrub Numeric Fields by Dragging Zmieniaj pola liczbowe przeciąganiem Linux Display Server Serwer wyświetlania Linux Print Preview Podgląd wydruku @@ -4909,6 +4917,9 @@ imageTrace Outline Kontur @msg the Pixar file's image data is damaged dane obrazu pliku Pixar są uszkodzone @msg the JPEG 2000 file can't be read nie można odczytać pliku JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content plik JPEG 2000 zawiera nieobsługiwaną przestrzeń barw lub zawartość JPX +@msg the HEIC or HEIF file can't be read nie można odczytać pliku HEIC lub HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read plik HEIC lub HEIF zawiera rodzaj obrazu, którego nie można odczytać +@msg HEIC and HEIF images can't be opened in this build obrazów HEIC i HEIF nie można otworzyć w tej wersji @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD jest zbyt duży, aby go odczytać ({_1} × {_2} pikseli) @msg zipped 32-bit PSD image data with prediction can't be read nie można odczytać spakowanych 32-bitowych danych obrazu PSD z predykcją @msg {depth}-bit PSD files in this colour mode can't be read nie można odczytać {depth}-bitowych plików PSD w tym trybie koloru @@ -5564,6 +5575,9 @@ mark-style Japanese Japońskie @msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli). +@msg byColor must be true or false byColor musi mieć wartość true lub false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nie może podzielić grup Automatycznego grupowania: obrysuj bez Automatycznego grupowania +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nie może ponownie zgrupować obrysu z aktywnym efektem Pathfinder: rozwiń bez byColor Snapping Przyciąganie Snap to Tangent Przyciągaj do stycznych Snap to Perpendicular Przyciągaj do prostopadłych @@ -6125,6 +6139,8 @@ breakText Glyphs Glify File + Data Set Name Plik + nazwa zestawu danych Data Set Name Nazwa zestawu danych @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musi mieć wartość `fileNumber`, `fileDataSetName` lub `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Każdy zestaw danych jest zapisywany w tym samym pliku, który zachowuje tylko ostatni zestaw danych. +@msg save the document with Save As before Save and Close zapisz dokument poleceniem „Zapisz jako” przed „Zapisz i zamknij” @msg no free data set file name in destination folder Brak wolnej nazwy pliku zestawu danych w folderze docelowym # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 19e24fa9a..97ff7a5cb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1045,6 +1045,7 @@ Fill & Stroke Preenchimento e Traçado Fill Color Cor de Preenchimento Fill New Type Objects With Placeholder Text Preencher Novos Objetos de Texto com Texto de Placeholder + Start Lists From Typed Markers Iniciar Listas a Partir de Marcadores Digitados Fill Rule Regra de Preenchimento Fill and Stroke Preenchimento e Traçado Fill new strokes Preencher novos traçados @@ -2563,6 +2564,11 @@ digits Malayalam Malaiala Selected Swatches Amostras Selecionadas Selected Swatches Only Somente Amostras Selecionadas Selection Seleção +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Seleção retangular + Touching Objetos tocados + Fully Enclosed Totalmente contidos + Drag Direction Direção do arrasto Selection & Anchor Display Exibição de Seleção e Âncora Selection Only Somente Seleção Selection Tool Ferramenta Seleção @@ -2681,6 +2687,7 @@ digits Malayalam Malaiala Simulate Colored Paper Simular Papel Colorido Simulate Paper Simular Papel Double Column Coluna Dupla + Keep Tools Beside the Canvas Ferramentas ao Lado da Tela Single Left Quotation Mark Aspa Esquerda Única Single Quotes: Aspas Simples: Single Right Quotation Mark Aspa Direita Única @@ -3115,6 +3122,7 @@ digits Malayalam Malaiala Zoom Tool Ferramenta Zoom Zoom to Selection Ampliar para a Seleção Zoom with Mouse Wheel Zoom com a Roda do Mouse + Scroll Sideways with Cmd/Ctrl + Wheel Rolar na Horizontal com Cmd/Ctrl + Roda Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando [Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável. [Unnamed] [Sem Nome] @@ -4347,6 +4355,9 @@ axis Both Ambos @msg the Pixar file's image data is damaged os dados de imagem do arquivo Pixar estão danificados @msg the JPEG 2000 file can't be read não é possível ler o arquivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content o arquivo JPEG 2000 contém um espaço de cores ou conteúdo JPX sem suporte +@msg the HEIC or HEIF file can't be read não é possível ler o arquivo HEIC ou HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read o arquivo HEIC ou HEIF contém um tipo de imagem que não pode ser lido +@msg HEIC and HEIF images can't be opened in this build imagens HEIC e HEIF não podem ser abertas nesta versão @msg the PSD image is too large to read ({_1} × {_2} pixels) a imagem PSD é grande demais para ser lida ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read dados de imagem PSD de 32 bits compactados em zip com previsão não podem ser lidos @msg {depth}-bit PSD files in this colour mode can't be read arquivos PSD de {depth} bits neste modo de cor não podem ser lidos @@ -5626,6 +5637,9 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels). +@msg byColor must be true or false byColor deve ser true ou false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor não pode dividir os grupos de Agrupamento Automático: trace sem Agrupamento Automático +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor não pode reagrupar um traçado com um efeito Pathfinder ativo: expanda sem byColor Snapping Ajuste Snap to Tangent Ajustar à Tangente Snap to Perpendicular Ajustar à Perpendicular @@ -6187,6 +6201,8 @@ breakText Glyphs Glifos File + Data Set Name Arquivo + nome do conjunto de dados Data Set Name Nome do conjunto de dados @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve ser `fileNumber`, `fileDataSetName` ou `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Cada conjunto de dados é salvo sobre o mesmo arquivo, que mantém apenas o último conjunto de dados. +@msg save the document with Save As before Save and Close salve o documento com Salvar Como antes de Salvar e fechar @msg no free data set file name in destination folder Não há nome de arquivo livre para o conjunto de dados na pasta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 45cfbfb40..5d16096d8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1172,6 +1172,7 @@ font-technology Variable Вариативные Stroke When Editable Редактируемая обводка Fill Color Цвет заливки Fill New Type Objects With Placeholder Text Заполнять новые текстовые объекты текстом-заполнителем + Start Lists From Typed Markers Начинать списки с введённых маркеров Fill Rule Правило заливки Fill with Placeholder Text Заполнить текстом-заполнителем Fill: Заливка: @@ -3092,6 +3093,11 @@ digits Malayalam Малаялам Selected Swatches Выбранные образцы Selected Swatches Only Только выбранные образцы Selection Выделение +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Выделение рамкой + Touching Пересекаемые + Fully Enclosed Полностью внутри + Drag Direction Направление перетаскивания Selection & Anchor Display Отображение выделения и опорных точек Selection Only Только выделение Selection Tool Инструмент «Выделение» @@ -3229,6 +3235,7 @@ digits Malayalam Малаялам Simulate Colored Paper Имитировать цветную бумагу Simulate Paper Имитировать бумагу Double Column Двойной столбец + Keep Tools Beside the Canvas Инструменты рядом с холстом Single Left Quotation Mark Одинарная открывающая кавычка Single Quotes: Одинарные кавычки: Single Right Quotation Mark Одинарная закрывающая кавычка @@ -3825,6 +3832,7 @@ digits Malayalam Малаялам Zoom to Selection Масштаб по выделению Zoom Tool Инструмент «Масштаб» Zoom with Mouse Wheel Масштабировать колесом мыши + Scroll Sideways with Cmd/Ctrl + Wheel Прокручивать по горизонтали колесом с Cmd/Ctrl Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета? @plural {n} linked file not found|{n} linked files not found Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n} @@ -4842,6 +4850,9 @@ axis Both Оба @msg the Pixar file's image data is damaged данные изображения в файле Pixar повреждены @msg the JPEG 2000 file can't be read не удается прочитать файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 содержит неподдерживаемое цветовое пространство или содержимое JPX +@msg the HEIC or HEIF file can't be read не удается прочитать файл HEIC или HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC или HEIF содержит изображение такого типа, которое не удается прочитать +@msg HEIC and HEIF images can't be opened in this build изображения HEIC и HEIF нельзя открыть в этой сборке @msg the PSD image is too large to read ({_1} × {_2} pixels) изображение PSD слишком велико для чтения ({_1} × {_2} пикс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битные файлы PSD в этом цветовом режиме не читаются @@ -5319,6 +5330,9 @@ lighting Light Свет @msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей). +@msg byColor must be true or false byColor должен иметь значение true или false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не может разделить группы автогруппировки: трассируйте без автогруппировки +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не может перегруппировать трассировку с действующим эффектом Pathfinder: разверните без byColor Snapping Привязка Snap to Tangent Привязать к касательной Snap to Perpendicular Привязать к перпендикуляру @@ -6248,6 +6262,8 @@ breakText Glyphs Глифы File + Data Set Name Файл + имя набора данных Data Set Name Имя набора данных @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` должно быть `fileNumber`, `fileDataSetName` или `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Каждый набор данных сохраняется поверх одного и того же файла, в котором остаётся только последний набор данных. +@msg save the document with Save As before Save and Close сохраните документ командой «Сохранить как» перед «Сохранить и закрыть» @msg no free data set file name in destination folder В папке назначения нет свободного имени файла для набора данных # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d2fe0cb91..da4013c17 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1138,6 +1138,7 @@ font-technology Variable Variabla Live curve fitting Kurvanpassning medan du ritar Fill Color Fyllnadsfärg Fill New Type Objects With Placeholder Text Fyll nya textobjekt med platshållartext + Start Lists From Typed Markers Starta listor från inskrivna punkttecken Fill Rule Fyllningsregel Fill with Placeholder Text Fyll med platshållartext Fill: Fyllning: @@ -2985,6 +2986,11 @@ digits Malayalam Malayalam Selected Swatches Markerade färgrutor Selected Swatches Only Endast markerade färgrutor Selection Markering +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rektangelmarkering + Touching Berörda objekt + Fully Enclosed Helt omslutna + Drag Direction Dragriktning Selection & Anchor Display Visning av markering och ankarpunkter Selection Only Endast markering Selection Tool Markeringsverktyg @@ -3120,6 +3126,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simulera färgat papper Simulate Paper Simulera papper Double Column Dubbel kolumn + Keep Tools Beside the Canvas Verktyg bredvid arbetsytan Single Left Quotation Mark Enkelt vänster citattecken Single Quotes: Enkla citattecken: Single Right Quotation Mark Enkelt höger citattecken @@ -3715,6 +3722,7 @@ digits Malayalam Malayalam Zoom to Selection Zooma till markering Zoom Tool Zoomverktyg Zoom with Mouse Wheel Zooma med mushjulet + Scroll Sideways with Cmd/Ctrl + Wheel Rulla horisontellt med Cmd/Ctrl + mushjulet Scrub Numeric Fields by Dragging Ändra numeriska fält genom att dra @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fil i {folder} {note}. Visa paketmappen?|{n} filer i {folder} {note}. Visa paketmappen? @plural {n} linked file not found|{n} linked files not found {n} länkad fil hittades inte|{n} länkade filer hittades inte @@ -4720,6 +4728,9 @@ axis Both Båda @msg the Pixar file's image data is damaged Pixar-filens bilddata är skadade @msg the JPEG 2000 file can't be read JPEG 2000-filen kan inte läsas @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000-filen har en färgrymd eller ett JPX-innehåll som inte stöds +@msg the HEIC or HEIF file can't be read HEIC- eller HEIF-filen kan inte läsas +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen innehåller en typ av bild som inte kan läsas +@msg HEIC and HEIF images can't be opened in this build HEIC- och HEIF-bilder kan inte öppnas i den här versionen @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-bilden är för stor för att läsas ({_1} × {_2} pixlar) @msg zipped 32-bit PSD image data with prediction can't be read zippad 32-bitars PSD-bilddata med prediktion kan inte läsas @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitars PSD-filer i det här färgläget kan inte läsas @@ -5733,6 +5744,9 @@ swatch Yellow Green Gulgrön @msg “{name}” is not a file ”{name}” är inte en fil @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar). +@msg byColor must be true or false byColor måste vara true eller false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kan inte dela upp grupper från Automatisk gruppering: vektorisera utan Automatisk gruppering +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kan inte gruppera om en kalkering med en aktiv Pathfinder-effekt: expandera utan byColor Snapping Fäst Snap to Tangent Fäst mot tangent Snap to Perpendicular Fäst mot vinkelrät @@ -6323,6 +6337,8 @@ breakText Glyphs Glyfer File + Data Set Name Fil + datauppsättningens namn Data Set Name Datauppsättningens namn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` måste vara `fileNumber`, `fileDataSetName` eller `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Varje datauppsättning sparas över samma fil, som bara behåller den sista datauppsättningen. +@msg save the document with Save As before Save and Close spara dokumentet med Spara som före Spara och stäng @msg no free data set file name in destination folder Inget ledigt filnamn för datauppsättningen i målmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ed95d4e8e..1ccbe1484 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -184,6 +184,16 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// Preference choices and the marquee's E hint share the same translated labels. +#[test] +fn marquee_selection_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in ["Marquee Selection", "Touching", "Fully Enclosed", "Drag Direction"] { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8d9bb97f..b20d8ee1e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1127,6 +1127,7 @@ font-technology Variable Değişken Merge only with selection Yalnızca seçimle birleştir Fill Color Dolgu Rengi Fill New Type Objects With Placeholder Text Yeni Yazım Nesnelerini Yer Tutucu Metinle Doldurun + Start Lists From Typed Markers Yazılan İşaretlerden Liste Başlat Fill Rule Doldurma Kuralı Fill with Placeholder Text Yer Tutucu Metinle Doldur Fill: Doldurun: @@ -2889,6 +2890,11 @@ digits Malayalam Malayalam Selected Swatches Seçilen Renk Örnekleri Selected Swatches Only Yalnızca Seçilen Renk Örnekleri Selection Seçim +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Dikdörtgen seçim + Touching Dokunulanlar + Fully Enclosed Tamamen içeride + Drag Direction Sürükleme yönü Selection & Anchor Display Seçim ve Bağlantı Ekranı Selection Only Yalnızca Seçim Selection Tool Seçim Aracı @@ -3023,6 +3029,7 @@ digits Malayalam Malayalam Simulate Colored Paper Renkli Kağıdı Simüle Et Simulate Paper Kağıdı Simüle Et Double Column Çift Sütun + Keep Tools Beside the Canvas Araçları Tuvalin Yanında Tut Single Left Quotation Mark Tek Sol Tırnak İşareti Single Quotes: Tek Alıntılar: Single Right Quotation Mark Tek Sağ Tırnak İşareti @@ -3602,6 +3609,7 @@ digits Malayalam Malayalam Zoom to Selection Seçimi Yakınlaştır Zoom Tool Yakınlaştırma Aracı Zoom with Mouse Wheel Fare Tekerleği ile Yakınlaştırma + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + Tekerlek ile Yatay Kaydırma Scrub Numeric Fields by Dragging Sayısal Alanları Sürükleyerek Fırçalayın @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} içinde {n} dosya {note}. Paket klasörü gösterilsin mi? @plural {n} linked file not found|{n} linked files not found {n} bağlantılı dosya bulunamadı @@ -4553,6 +4561,9 @@ axis Both Her İkisi @msg the Pixar file's image data is damaged Pixar dosyasının görüntü verileri bozuk @msg the JPEG 2000 file can't be read JPEG 2000 dosyası okunamıyor @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 dosyasında desteklenmeyen bir renk alanı veya JPX içeriği var +@msg the HEIC or HEIF file can't be read HEIC veya HEIF dosyası okunamıyor +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC veya HEIF dosyası okunamayan türde bir görüntü içeriyor +@msg HEIC and HEIF images can't be opened in this build HEIC ve HEIF görüntüleri bu derlemede açılamıyor @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel) @msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor @msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz @@ -5712,6 +5723,9 @@ theme Appearance Görünüm @msg library panel `{given}` isn't missing its library `{given}` kitaplık panelinin kitaplığı eksik değil @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Görüntü {_1} × {_2} piksel, bir kenarı {LARGE_TRACE_SIDE} değerinden büyük: izlenir, ancak daha yavaş. Daha düşük doğruluklu bir ön ayar veya görüntünün daha küçük bir kopyası daha hızlı izlenir. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Görüntü {_3} × {_4} boyutundan küçültülerek {_1} × {_2} pikselde izlendi (en fazla {_5} megapiksel). +@msg byColor must be true or false byColor true veya false olmalıdır +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor, Otomatik Gruplama gruplarını bölemez: Otomatik Gruplama olmadan izleyin +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor, canlı Pathfinder efekti olan bir izlemeyi yeniden gruplayamaz: byColor olmadan genişletin Gap Length: Boşluk Uzunluğu: @msg `colorGroup` needs `library: "document"` `colorGroup` için `library: "document"` gerekir @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` için `palette: "documentLibrary"` gerekir @@ -6228,6 +6242,8 @@ breakText Glyphs Glifler File + Data Set Name Dosya + veri kümesi adı Data Set Name Veri kümesi adı @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` değeri `fileNumber`, `fileDataSetName` veya `dataSetName` olmalıdır + Each data set saves over the same file, which keeps only the last data set. Her veri kümesi aynı dosyanın üzerine kaydedilir ve dosyada yalnızca son veri kümesi kalır. +@msg save the document with Save As before Save and Close Kaydet ve Kapat'tan önce belgeyi Farklı Kaydet ile kaydedin @msg no free data set file name in destination folder Hedef klasörde veri kümesi için boş bir dosya adı yok # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 58b489926..c538d6fb2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1169,6 +1169,7 @@ font-technology Variable Варіативні Stroke When Editable Обведення, яке можна редагувати Fill Color Колір заливки Fill New Type Objects With Placeholder Text Заповнювати нові текстові об’єкти текстом-заповнювачем + Start Lists From Typed Markers Починати списки з введених маркерів Fill Rule Правило заливки Fill with Placeholder Text Заповнити текстом-заповнювачем Fill: Заливка: @@ -3089,6 +3090,11 @@ digits Malayalam Малаялам Selected Swatches Вибрані зразки Selected Swatches Only Тільки вибрані зразки Selection Виділення +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Виділення рамкою + Touching Перетнуті + Fully Enclosed Повністю всередині + Drag Direction Напрямок перетягування Selection & Anchor Display Відображення виділення та опорних точок Selection Only Тільки виділення Selection Tool Інструмент «Виділення» @@ -3226,6 +3232,7 @@ digits Malayalam Малаялам Simulate Colored Paper Імітувати кольоровий папір Simulate Paper Імітувати папір Double Column Подвійний стовпець + Keep Tools Beside the Canvas Інструменти поруч із полотном Single Left Quotation Mark Одинарна лапка, що відкриває Single Quotes: Одинарні лапки: Single Right Quotation Mark Одинарна лапка, що закриває @@ -3823,6 +3830,7 @@ effect Points Точки Zoom to Selection Масштаб виділення Zoom Tool Інструмент «Масштаб» Zoom with Mouse Wheel Масштабувати колесом миші + Scroll Sideways with Cmd/Ctrl + Wheel Прокручувати по горизонталі колесом із Cmd/Ctrl Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета? @plural {n} linked file not found|{n} linked files not found не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n} @@ -4829,6 +4837,9 @@ axis Both Обидві @msg the Pixar file's image data is damaged дані зображення у файлі Pixar пошкоджені @msg the JPEG 2000 file can't be read не вдається прочитати файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 містить непідтримуваний колірний простір або вміст JPX +@msg the HEIC or HEIF file can't be read не вдається прочитати файл HEIC або HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC або HEIF містить зображення такого типу, яке не вдається прочитати +@msg HEIC and HEIF images can't be opened in this build зображення HEIC і HEIF не можна відкрити в цій збірці @msg the PSD image is too large to read ({_1} × {_2} pixels) зображення PSD завелике для читання ({_1} × {_2} пікс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються @msg {depth}-bit PSD files in this colour mode can't be read {depth}-бітні файли PSD у цьому колірному режимі не читаються @@ -5304,6 +5315,9 @@ lighting Light Світло @msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів). +@msg byColor must be true or false byColor має мати значення true або false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не може розділити групи автогрупування: трасуйте без автогрупування +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не може перегрупувати трасування з активним ефектом Pathfinder: розгорніть без byColor Snapping Прив’язка Snap to Tangent Прив’язати до дотичної Snap to Perpendicular Прив’язати до перпендикуляра @@ -5870,6 +5884,8 @@ breakText Glyphs Гліфи File + Data Set Name Файл + назва набору даних Data Set Name Назва набору даних @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` має бути `fileNumber`, `fileDataSetName` або `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Кожен набір даних зберігається поверх того самого файлу, у якому лишається тільки останній набір даних. +@msg save the document with Save As before Save and Close збережіть документ командою «Зберегти як» перед «Зберегти й закрити» @msg no free data set file name in destination folder У папці призначення немає вільного імені файлу набору даних # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb02d3834..0f95b334e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -997,6 +997,7 @@ font-technology Variable Biến thiên Dots Chấm Double Click To Isolate Bấm đúp để cô lập Double Column Hai cột + Keep Tools Beside the Canvas Công cụ cạnh vùng vẽ Double Left Quotation Marks Dấu ngoặc kép trái đôi Double Quotes: Dấu ngoặc kép: Double Right Quotation Marks Dấu ngoặc kép phải đôi @@ -1345,6 +1346,7 @@ digits Malayalam Malayalam Highlight: Làm nổi bật: Stroke When Editable Nét khi có thể chỉnh sửa Fill New Type Objects With Placeholder Text Tô đối tượng chữ mới bằng văn bản giữ chỗ + Start Lists From Typed Markers Bắt đầu danh sách từ ký hiệu đã gõ Fill Rule Quy tắc tô Fill with Placeholder Text Tô bằng văn bản giữ chỗ Fill: Tô: @@ -3417,6 +3419,11 @@ mark-style Japanese Kiểu Nhật Selected Swatches Mẫu màu đã chọn Selected Swatches Only Chỉ mẫu màu đã chọn Selection Vùng chọn +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Chọn bằng khung + Touching Đối tượng chạm khung + Fully Enclosed Nằm hoàn toàn trong khung + Drag Direction Hướng kéo Selection & Anchor Display Hiển thị vùng chọn & điểm neo Selection Only Chỉ vùng chọn Selection Tool Công cụ Chọn @@ -4189,6 +4196,7 @@ mark-style Japanese Kiểu Nhật Zoom to Selection Thu phóng đến vùng chọn Zoom Tool Công cụ thu phóng Zoom with Mouse Wheel Thu phóng bằng con lăn chuột + Scroll Sideways with Cmd/Ctrl + Wheel Cuộn ngang bằng Cmd/Ctrl + con lăn @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} tệp trong {folder} {note}. Hiện thư mục gói? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read {n} thư mục hoặc tệp không thể đọc được @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Đã bỏ qua {n} thư mục, chẳng hạn thư mục của ứng dụng khác, thư mục hệ thống và thư mục ẩn @@ -4986,6 +4994,9 @@ theme Appearance Giao diện @msg the Pixar file's image data is damaged dữ liệu ảnh của tệp Pixar bị hỏng @msg the JPEG 2000 file can't be read không thể đọc tệp JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content tệp JPEG 2000 có không gian màu hoặc nội dung JPX không được hỗ trợ +@msg the HEIC or HEIF file can't be read không thể đọc tệp HEIC hoặc HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read tệp HEIC hoặc HEIF chứa loại ảnh không thể đọc được +@msg HEIC and HEIF images can't be opened in this build không thể mở ảnh HEIC và HEIF trong bản dựng này @msg the PSD image is too large to read ({_1} × {_2} pixels) ảnh PSD quá lớn để đọc ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open hộp thoại Lưu PDF không mở @msg the scale is not a usable number tỷ lệ không phải là số dùng được @@ -5305,6 +5316,9 @@ theme Appearance Giao diện @msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel). +@msg byColor must be true or false byColor phải là true hoặc false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor không thể tách các nhóm của Tự động nhóm: hãy dò mà không bật Tự động nhóm +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor không thể nhóm lại bản dò có hiệu ứng Pathfinder đang hoạt động: mở rộng mà không dùng byColor Snapping Hút Snap to Tangent Hút vào tiếp tuyến Snap to Perpendicular Hút vào đường vuông góc @@ -5871,6 +5885,8 @@ breakText Glyphs Glyph File + Data Set Name Tệp + tên tập dữ liệu Data Set Name Tên tập dữ liệu @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` phải là `fileNumber`, `fileDataSetName` hoặc `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Mỗi tập dữ liệu được lưu đè lên cùng một tệp, nên tệp chỉ giữ lại tập dữ liệu cuối cùng. +@msg save the document with Save As before Save and Close hãy lưu tài liệu bằng Lưu thành trước khi dùng Lưu và đóng @msg no free data set file name in destination folder Không có tên tệp tập dữ liệu còn trống trong thư mục đích # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 60f48f17b..5dce4df49 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1114,6 +1114,7 @@ Stroke When Editable 可编辑时的描边 Fill Color 填色 Fill New Type Objects With Placeholder Text 用占位文本填充新文字对象 + Start Lists From Typed Markers 从键入的标记开始列表 Fill Rule 填充规则 Fill with Placeholder Text 用占位文本填充 Insert Inline Symbol 插入行内符号 @@ -2933,6 +2934,11 @@ digits Malayalam 马拉雅拉姆数字 Selected Swatches 所选色板 Selected Swatches Only 仅限选择的色板 Selection 所选对象 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 框选 + Touching 接触对象 + Fully Enclosed 完全包含 + Drag Direction 拖动方向 Selection & Anchor Display 选择和锚点显示 Selection Only 仅所选内容 Selection Tool 选择工具 @@ -3067,6 +3073,7 @@ digits Malayalam 马拉雅拉姆数字 Simulate Colored Paper 模拟有色纸张 Simulate Paper 模拟纸张 Double Column 双列 + Keep Tools Beside the Canvas 工具置于画布旁 Single Left Quotation Mark 左单引号 Single Quotes: 单引号: Single Right Quotation Mark 右单引号 @@ -3680,6 +3687,7 @@ digits Malayalam 马拉雅拉姆数字 Zoom to Selection 缩放到所选对象 Zoom Tool 缩放工具 Zoom with Mouse Wheel 使用鼠标滚轮缩放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滚轮水平滚动 Scrub Numeric Fields by Dragging 拖动以调整数值字段 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 个文件{note}。要显示打包文件夹吗? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 个链接文件 @@ -4596,6 +4604,9 @@ imageTrace Outline 轮廓 @msg the Pixar file's image data is damaged Pixar 文件的图像数据已损坏 @msg the JPEG 2000 file can't be read 无法读取 JPEG 2000 文件 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 文件包含不受支持的色彩空间或 JPX 内容 +@msg the HEIC or HEIF file can't be read 无法读取 HEIC 或 HEIF 文件 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 文件包含无法读取的图像类型 +@msg HEIC and HEIF images can't be opened in this build 此版本无法打开 HEIC 和 HEIF 图像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 @msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 @@ -5542,6 +5553,9 @@ lighting Light 光源 @msg too many library panels were opened in this session 本次会话中打开的库面板过多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。 +@msg byColor must be true or false byColor 必须是 true 或 false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 无法拆分自动编组生成的编组:请在不使用自动编组的情况下描摹 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 无法重新编组带有实时 Pathfinder 效果的描摹:请不使用 byColor 进行扩展 Snapping 对齐 Snap to Tangent 对齐切线 Snap to Perpendicular 对齐垂线 @@ -6112,6 +6126,8 @@ breakText Glyphs 字形 File + Data Set Name 文件 + 数据集名称 Data Set Name 数据集名称 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必须是 `fileNumber`、`fileDataSetName` 或 `dataSetName` + Each data set saves over the same file, which keeps only the last data set. 每个数据集都会存储到同一个文件并覆盖它,该文件只保留最后一个数据集。 +@msg save the document with Save As before Save and Close 请先用“另存为”保存文档,再使用“保存并关闭” @msg no free data set file name in destination folder 目标文件夹中没有可用的数据集文件名 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 864f1dba2..3b4e5b152 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1139,6 +1139,7 @@ font-technology Variable 可變 Highlight: 強調顯示: Stroke When Editable 可編輯時的筆畫 Fill New Type Objects With Placeholder Text 以預留位置文字填滿新文字物件 + Start Lists From Typed Markers 從輸入的標記開始清單 Fill Rule 填色規則 Fill with Placeholder Text 以預留位置文字填滿 Insert Inline Symbol 插入行內符號 @@ -2453,6 +2454,9 @@ file-filter Templates 範本 @msg the Pixar file's image data is damaged Pixar 檔案的影像資料已損毀 @msg the JPEG 2000 file can't be read 無法讀取 JPEG 2000 檔案 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 檔案包含不支援的色彩空間或 JPX 內容 +@msg the HEIC or HEIF file can't be read 無法讀取 HEIC 或 HEIF 檔案 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 檔案包含無法讀取的影像類型 +@msg HEIC and HEIF images can't be opened in this build 此版本無法開啟 HEIC 和 HEIF 影像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料 @msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案 @@ -2553,6 +2557,9 @@ file-filter Templates 範本 @msg `colorGroup` needs `mode: "color"` `colorGroup` 需要 `mode: "color"` @msg no color group `{name}` in the document (see swatch.list) 文件中沒有顏色群組 `{name}` (請參閱 swatch.list) @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. 「扁平標誌」模式已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 平均縮小而來。 +@msg byColor must be true or false byColor 必須是 true 或 false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 無法拆分自動群組產生的群組:請不使用自動群組來描圖 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 無法重新編組帶有即時 Pathfinder 效果的描圖:請不使用 byColor 進行擴充 # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 「編輯原稿」只能開啟影像與文件檔案;「{name}」不屬於這兩類 @msg only http, https and mailto URLs open in the browser, not “{url}” 只有 http、https 和 mailto URL 會在瀏覽器中開啟,「{url}」不會 @@ -3960,6 +3967,11 @@ digits Malayalam 馬來亞拉姆數字 Selected Swatches 選取的色票 Selected Swatches Only 僅限選取的色票 Selection 選取範圍 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 框選 + Touching 接觸物件 + Fully Enclosed 完全包含 + Drag Direction 拖曳方向 Selection & Anchor Display 選取和錨點顯示 Selection Only 僅限選取範圍 Selection Tool 選取工具 @@ -4097,6 +4109,7 @@ digits Malayalam 馬來亞拉姆數字 Simulate Colored Paper 模擬有色紙張 Simulate Paper 模擬紙張 Double Column 雙欄 + Keep Tools Beside the Canvas 工具置於畫布旁 Single Left Quotation Mark 左單引號 Single Quotes: 單引號: Single Right Quotation Mark 右單引號 @@ -4713,6 +4726,7 @@ digits Malayalam 馬來亞拉姆數字 Zoom to Selection 縮放至選取範圍 Zoom Tool 縮放工具 Zoom with Mouse Wheel 使用滑鼠滾輪縮放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滾輪水平捲動 Scrub Numeric Fields by Dragging 拖曳以調整數值欄位 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 個檔案{note}。要顯示封裝資料夾嗎? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 個連結檔案 @@ -5908,6 +5922,8 @@ breakText Glyphs 字符 File + Data Set Name 檔案 + 資料集名稱 Data Set Name 資料集名稱 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必須是 `fileNumber`、`fileDataSetName` 或 `dataSetName` + Each data set saves over the same file, which keeps only the last data set. 每個資料集都會儲存到同一個檔案並覆寫它,該檔案只保留最後一個資料集。 +@msg save the document with Save As before Save and Close 請先用「另存新檔」儲存文件,再使用「儲存並關閉」 @msg no free data set file name in destination folder 目的地資料夾中沒有可用的資料集檔案名稱 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 572c6b175..d481ba389 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -132,7 +132,7 @@ fn vc_binding(text: &str) -> Option { /// Every listed entry as the crate sees it, in dispatch priority: commands (engine, then UI), /// panels, tools. Each tool is its own group: tools never cycle. -fn catalogue() -> Vec { +fn catalogue(p: Platform) -> Vec { let rank = |e: &Entry| { if e.is_tool { 2 @@ -159,7 +159,7 @@ fn catalogue() -> Vec { if let Some(d) = shortcut_editor::default_of(&e.key).and_then(vc_binding) { b = b.with_default(d); } - for alias in default_aliases(&e.key) { + for alias in default_aliases(&e.key, p) { if let Some(d) = vc_binding(alias) { b = b.with_default(d); } @@ -170,14 +170,22 @@ fn catalogue() -> Vec { } /// Additional defaults for commands whose primary shortcut remains in their command spec. -fn default_aliases(key: &str) -> &'static [&'static str] { +fn default_aliases(key: &str, p: Platform) -> &'static [&'static str] { match key { + "edit.cut" if !p.is_mac() => &["F2"], + "edit.copy" if !p.is_mac() => &["F3"], + "edit.paste" if !p.is_mac() => &["F4"], "paint.lastColor" => &["Shift+,"], "paint.lastGradient" => &["Shift+."], _ => &[], } } +/// Cut, Copy and Paste with their primary shortcut and the function key [`default_aliases`] adds +/// outside macOS. +pub(crate) const EDIT_FUNCTION_KEYS: [(&str, &str, &str); 3] = + [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")]; + /// On the web, the browser's operating system once egui reports it ([`note_os`]). static WEB_PLATFORM: RwLock> = RwLock::new(None); @@ -220,13 +228,38 @@ pub fn template() -> &'static Keymap { } /// The default keymap of `p`. The chords the operating system takes can't be assigned. -fn keymap_for(p: Platform) -> Keymap { - Keymap::new(catalogue(), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) +pub(crate) fn keymap_for(p: Platform) -> Keymap { + Keymap::new(catalogue(p), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) } /// The keymap with `overrides` applied. pub fn keymap_with(overrides: &Overrides) -> Keymap { - template().clone().with_overrides(overrides.clone()) + let defaults = template(); + defaults.clone().with_overrides(edit_alias_overrides(overrides, defaults.platform())) +} + +/// When a saved override assigns F2, F3 or F4 elsewhere without changing its edit command, +/// keep only that edit command's primary shortcut so the saved assignment runs. +pub(crate) fn edit_alias_overrides(overrides: &Overrides, p: Platform) -> Overrides { + let mut resolved = overrides.clone(); + if p.is_mac() { + return resolved; + } + for (key, primary, alias) in EDIT_FUNCTION_KEYS { + if overrides.get(key).is_some() { + continue; + } + let Some(binding) = vc_binding(alias) else { continue }; + let assigned_elsewhere = overrides.iter().any(|(other, texts)| { + other != key + && shortcut_editor::entry(&entry_of_stored(other)).is_some() + && texts.iter().any(|text| Binding::parse(text).is_ok_and(|b| b == binding)) + }); + if assigned_elsewhere { + let _ = resolved.insert(key, vec![vc_to_crate(primary)]); + } + } + resolved } /// The command a chord runs, the first for tools sharing it. @@ -424,7 +457,7 @@ mod tests { .filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d))) .filter(|(key, d)| { let mut expected: Vec = vc_binding(d).into_iter().collect(); - expected.extend(default_aliases(key).iter().filter_map(|alias| vc_binding(alias))); + expected.extend(default_aliases(key, platform()).iter().filter_map(|alias| vc_binding(alias))); expected != template().default_bindings(&command_of(key)) }) .collect(); @@ -451,6 +484,50 @@ mod tests { assert!(template().conflicts().is_empty()); } + #[test] + fn edit_function_keys_follow_platform_and_reset_restores_both_bindings() { + for p in [Platform::Windows, Platform::Linux, Platform::Mac] { + let km = keymap_for(p); + for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { + let command = command_of(key); + let mut expected = vec![vc_binding(primary).unwrap()]; + if !p.is_mac() { + expected.push(vc_binding(alternate).unwrap()); + } + assert_eq!(km.default_bindings(&command), expected, "{p:?} {key}"); + assert_eq!(lookup(&km, &vc_chord(primary).unwrap()), Some(command.clone()), "{p:?} {primary}"); + assert_eq!(lookup(&km, &vc_chord(alternate).unwrap()), (!p.is_mac()).then_some(command.clone()), "{p:?} {alternate}"); + assert_eq!(effective_with(&km, key).as_deref(), Some(primary)); + let mut changed = km.clone(); + shortcut_editor::assign_in(&mut changed, key, Some("Ctrl+Alt+Shift+F12"), true).unwrap(); + assert_eq!(changed.bindings(&command), vec![vc_binding("Ctrl+Alt+Shift+F12").unwrap()]); + changed.reset(&command); + assert_eq!(changed.bindings(&command), expected, "{p:?} {key} reset"); + let mut overrides = Overrides::new(); + set_override(&mut overrides, key, "Ctrl+Alt+Shift+F12"); + shortcut_editor::reset_one(&mut overrides, key); + assert_eq!(km.clone().with_overrides(overrides).bindings(&command), expected, "{p:?} {key} use default"); + } + assert!(km.conflicts().is_empty(), "{p:?}"); + assert!(km.problems().is_empty(), "{p:?}"); + + for (alias, edit, primary) in [("F2", "edit.cut", "Cmd+X"), ("F3", "edit.copy", "Cmd+C"), ("F4", "edit.paste", "Cmd+V")] { + let mut saved = Overrides::new(); + set_override(&mut saved, "tool:pen", alias); + let assigned = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p)); + assert_eq!(lookup(&assigned, &vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{p:?} saved {alias} assignment"); + assert_eq!(lookup(&assigned, &vc_chord(primary).unwrap()), Some(command_of(edit)), "{p:?} primary {edit} binding"); + shortcut_editor::reset_one(&mut saved, "tool:pen"); + let reset = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p)); + assert_eq!(lookup(&reset, &vc_chord(alias).unwrap()), (!p.is_mac()).then_some(command_of(edit)), "{p:?} reset saved {alias}"); + } + let mut unknown = Overrides::new(); + set_override(&mut unknown, "missing.command", "F2"); + let km = keymap_for(p).with_overrides(edit_alias_overrides(&unknown, p)); + assert_eq!(lookup(&km, &vc_chord("F2").unwrap()), (!p.is_mac()).then_some(command_of("edit.cut")), "{p:?} unknown saved entry"); + } + } + /// The chords the operating system takes are refused, and no default uses one. #[test] fn system_shortcuts_are_refused() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb8912455..9b054a683 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -198,6 +198,10 @@ pub struct VectorcraftApp { /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, + /// The window has the desktop's own title bar: the OS or the window manager draws its frame, + /// icon and title. Set by the desktop app when it opens the window; `false` for embedders + /// without a window of their own. + pub desktop_frame: bool, /// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`): /// sent again only when it changes, so idle frames don't spam the backend. pub(crate) last_window_title: String, diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index fd3e3c089..f0f15c7d5 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -478,6 +478,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.toolbar_advanced), + "window.toolsBesideCanvas" => opt_bool(p, "on").map(|on| { + let on = on.unwrap_or(!app.ui.tools_beside_canvas); + app.ui.tools_beside_canvas = on; + json!(on) + }), "imageTrace.preview" => opt_bool(p, "on").map(|on| { let on = on.unwrap_or(!app.ui.image_trace_preview); app.ui.image_trace_preview = on; diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ecaca0c6e..0c392d191 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -64,6 +64,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { } "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, + "window.toolsBesideCanvas" => app.ui.tools_beside_canvas, "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) { None => app.ui.toolbar_double, Some(name) => crate::custom_toolbars::find(&app.ui, name).is_some_and(|b| b.double), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index d310e9318..d9f95db8a 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -467,6 +467,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", ), ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ( + "window.toolsBesideCanvas", + "Keep Tools Beside the Canvas", + "", + "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted or null; any other value is an error); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state", + ), ( "window.floatTools", "Float Tool Group", @@ -602,7 +608,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window. Returns {layout, closed, floating}", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on. Returns {layout, closed, floating}", ), ( "window.collapseDock", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 73d981000..a0205be88 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1095,7 +1095,7 @@ fn known(action: &Action) -> bool { } } -/// Draw the canvas and the panels in the space left by the bars and the Tools panel, and apply +/// Draw the canvas, its docked Tools panel and the other panels below the bars, and apply /// what the user did to them. pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let area = area_id(); @@ -1346,6 +1346,7 @@ struct PanelContent<'a> { impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { + crate::toolbar::show_beside_canvas(self.app, ui); self.app.canvas_area(ui); return; } @@ -1389,7 +1390,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { - let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; + let min = + if id == CANVAS { egui::vec2(200.0 + crate::toolbar::docked_width(&self.app.ui), 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; craft_ui::docking::PanelLimits { min, ..Default::default() } } diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 60b59a2a8..98c625743 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -274,7 +274,7 @@ fn switching_workspace_during_a_tab_drag_keeps_the_new_workspace_layout() { #[test] fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { let mut h = Harness::new(); - let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).expect("the Tools panel's docking strip"); + let zone = docked_tools(&h); let title = zone.min + vec2(20.0, 12.0); h.drag(title, pos2(600.0, 400.0)); let [x, y] = h.app.ui.toolbar_pos.expect("the Tools panel floats"); @@ -284,6 +284,244 @@ fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { assert_eq!(h.app.ui.toolbar_pos, None, "dropped on the left edge, it docks"); } +/// The docked Tools panel as last drawn. +fn docked_tools(h: &Harness) -> Rect { + h.ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).expect("the docked Tools panel") +} + +fn settle(h: &mut Harness) { + for _ in 0..3 { + h.frame(vec![]); + } +} + +fn keep_tools_beside_canvas(h: &mut Harness, on: bool) { + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({"on": on})).unwrap(), json!(on)); + settle(h); +} + +/// Where docked Tools sits with a left-side dock: `Some(true)` between the dock and the canvas, +/// `Some(false)` at the window's edge outside the dock. +fn tools_place(h: &Harness) -> Option { + let tools = docked_tools(h); + let layers = h.tab("layers")?; + let canvas = h.app.canvas_rect?; + if layers.right() <= tools.left() && tools.right() <= canvas.left() && canvas.left() - tools.right() < 40.0 { + Some(true) + } else if tools.left() < 10.0 && tools.right() <= layers.left() { + Some(false) + } else { + None + } +} + +/// Off by default: docked Tools stays at the window's edge, outside a left-side dock, and the +/// setting moves it between the dock and the canvas and back. +#[test] +fn docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas() { + let mut h = Harness::new(); + assert!(!h.app.ui.tools_beside_canvas); + assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(false)); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + settle(&mut h); + assert_eq!(tools_place(&h), Some(false), "Tools, then the left dock, then the canvas"); + assert_eq!(crate::toolbar::docked_width(&h.app.ui), 0.0, "the canvas reserves no room for Tools"); + keep_tools_beside_canvas(&mut h, true); + assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(true)); + assert_eq!(tools_place(&h), Some(true), "the left dock, then Tools, then the canvas"); + keep_tools_beside_canvas(&mut h, false); + assert_eq!(tools_place(&h), Some(false), "back at the window's edge"); +} + +#[test] +fn keep_tools_beside_the_canvas_toggles_and_rejects_a_value_that_is_not_true_or_false() { + let mut h = Harness::new(); + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(true)); + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(false)); + assert!(h.app.run("window.toolsBesideCanvas", json!({"on": "yes"})).is_err()); + assert!(!h.app.ui.tools_beside_canvas, "a bad value changes nothing"); +} + +/// Off, only the window's edge docks a floating Tools panel; on, the canvas's edge does too, and +/// both dock it beside the canvas. +#[test] +fn tools_redock_at_the_targets_of_the_chosen_placement() { + for beside in [false, true] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + for at_window_edge in [false, true] { + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + settle(&mut h); + let canvas = h.app.canvas_rect.unwrap(); + let canvas_edge = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())); + assert_eq!(canvas_edge.is_some(), beside, "a canvas-edge target only while kept beside the canvas"); + let canvas_edge = canvas_edge.map_or(pos2(canvas.left() + 12.0, canvas.center().y), |r| r.center()); + let target = if at_window_edge { pos2(12.0, canvas.center().y) } else { canvas_edge }; + h.drag(pos2(620.0, 307.0), target); + if !beside && !at_window_edge { + assert!(h.app.ui.toolbar_pos.is_some(), "the canvas's edge does not dock Tools while the setting is off"); + continue; + } + assert_eq!(h.app.ui.toolbar_pos, None, "beside: {beside}, at the window edge: {at_window_edge}"); + settle(&mut h); + assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, at the window edge: {at_window_edge}"); + } + } +} + +/// Turning the setting off while Tools floats forgets the canvas-edge target, and turning it on +/// leaves a floating Tools panel floating. +#[test] +fn toggling_while_tools_floats_keeps_it_floating_and_drops_the_canvas_target() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + keep_tools_beside_canvas(&mut h, true); + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + settle(&mut h); + let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).expect("the canvas-edge target"); + keep_tools_beside_canvas(&mut h, false); + assert!(h.app.ui.toolbar_pos.is_some(), "turning it off leaves Tools floating"); + assert!(h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).is_none(), "the canvas-edge target is gone"); + h.drag(pos2(620.0, 307.0), zone.center()); + assert!(h.app.ui.toolbar_pos.is_some(), "the old canvas-edge strip docks nothing"); + let [x, y] = h.app.ui.toolbar_pos.unwrap(); + keep_tools_beside_canvas(&mut h, true); + assert_eq!(h.app.ui.toolbar_pos, Some([x, y]), "turning it on leaves Tools floating where it was"); + keep_tools_beside_canvas(&mut h, false); + h.drag(pos2(x + 20.0, y + 7.0), pos2(12.0, zone.center().y)); + assert_eq!(h.app.ui.toolbar_pos, None); + settle(&mut h); + assert_eq!(tools_place(&h), Some(false)); +} + +/// The setting is kept with a workspace; state and workspaces saved without it keep Tools at the +/// window's edge. +#[test] +fn keep_tools_beside_the_canvas_is_saved_and_off_in_state_saved_without_it() { + let mut saved = serde_json::to_value(crate::state::UiState { tools_beside_canvas: true, ..Default::default() }).unwrap(); + assert_eq!(saved["tools_beside_canvas"], json!(true)); + let reloaded: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert!(reloaded.tools_beside_canvas); + saved.as_object_mut().unwrap().remove("tools_beside_canvas"); + let old: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert!(!old.tools_beside_canvas && old.toolbar_pos.is_none(), "older state docks Tools at the window's edge"); + + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + keep_tools_beside_canvas(&mut h, true); + let mut workspace = serde_json::to_value(crate::workspaces::capture(&h.app.ui, "Mine")).unwrap(); + assert_eq!(workspace["toolsBesideCanvas"], json!(true)); + workspace.as_object_mut().unwrap().remove("toolsBesideCanvas"); + let old: crate::workspaces::Workspace = serde_json::from_value(workspace).unwrap(); + assert!(!old.tools_beside_canvas, "a workspace saved without it docks Tools at the window's edge"); + + h.app.run("window.workspace.new", json!({"name": "Mine"})).unwrap(); + h.app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + settle(&mut h); + assert!(!h.app.ui.tools_beside_canvas, "built-in workspaces keep Tools at the window's edge"); + h.app.run("window.workspace", json!({"name": "Mine"})).unwrap(); + settle(&mut h); + assert!(h.app.ui.tools_beside_canvas); + assert_eq!(tools_place(&h), Some(true), "the saved workspace brings Tools back beside the canvas"); +} + +#[test] +fn tools_follow_left_panels_when_resized_hidden_and_restored() { + for (beside, double) in [(false, false), (false, true), (true, false), (true, true)] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + gesture(&mut h.app, Action::ResizeColumn { panel: "layers".into(), width: 430.0 }).unwrap(); + h.app.ui.toolbar_double = double; + let width = if double { 80.0 } else { 48.0 }; + for hidden in [false, true, false] { + if hidden == h.app.ui.dock { + h.app.run("window.panels", json!({})).unwrap(); + } + settle(&mut h); + let tools = docked_tools(&h); + assert!((tools.width() - (width + 6.0)).abs() < 1.0, "{} columns: {tools:?}", if double { 2 } else { 1 }); + if hidden { + assert!(h.tab("layers").is_none()); + assert!(tools.left() < 10.0); + } else { + assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, double: {double}"); + assert!(h.tab("layers").unwrap().width() > 0.0); + } + let canvas = h.app.canvas_rect.unwrap(); + assert!(canvas.left() - tools.left() >= width, "Tools has its own room: {tools:?}, {canvas:?}"); + assert!(canvas.width() > 200.0); + } + } +} + +/// A layout with panels above or below the canvas: the full-height strip along the window's left +/// edge docks Tools in both placements; off, Tools runs the window's height beside those panels, +/// and on, it stays in the canvas's row. +#[test] +fn tools_redock_at_window_edge_above_and_below_canvas() { + for (beside, above) in [(false, true), (false, false), (true, true), (true, false)] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + let tabs = |panel: &str| Box::new(Node::Tabs { panels: vec![panel.to_string()], active: 0 }); + h.app.ui.docking = Some(Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Vertical, + size: craft_ui::layout::SplitSize::FixedFirst(180.0), + first: tabs("layers"), + second: Box::new(Node::Split { + axis: craft_ui::layout::SplitAxis::Vertical, + size: craft_ui::layout::SplitSize::FixedSecond(180.0), + first: tabs(CANVAS), + second: tabs("properties"), + }), + }), + floating: vec![], + }); + assert!(valid(&h.layout())); + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + for _ in 0..3 { + h.frame(vec![]); + } + let panel = h.tab(if above { "layers" } else { "properties" }).unwrap(); + let canvas = h.app.canvas_rect.unwrap(); + assert!(panel.center().y < canvas.top() || panel.center().y > canvas.bottom()); + h.drag(pos2(620.0, 307.0), pos2(12.0, panel.center().y)); + assert_eq!(h.app.ui.toolbar_pos, None, "window-edge drop, beside: {beside}, above the canvas: {above}"); + settle(&mut h); + let tools = docked_tools(&h); + let panel = h.tab(if above { "layers" } else { "properties" }).unwrap(); + assert!(tools.left() < 10.0 && tools.right() <= panel.left() + 60.0, "{tools:?}"); + match (beside, above) { + (false, true) => assert!(tools.top() <= panel.top(), "Tools runs past the panel above: {tools:?}, {panel:?}"), + (false, false) => assert!(tools.bottom() >= panel.bottom(), "Tools runs past the panel below: {tools:?}, {panel:?}"), + (true, true) => assert!(tools.top() >= panel.bottom(), "Tools stays below the panel above: {tools:?}, {panel:?}"), + (true, false) => assert!(tools.bottom() <= panel.top(), "Tools stays above the panel below: {tools:?}, {panel:?}"), + } + } +} + +#[test] +#[ignore = "manual visual check; set LEFT_TOOLS_SCREENSHOTS to an existing harness directory"] +fn capture_tools_beside_left_panels() { + let dir = std::path::PathBuf::from(std::env::var_os("LEFT_TOOLS_SCREENSHOTS").unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.toolsBesideCanvas", json!({"on": true})).unwrap(); + app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1200.0, 800.0)) + .with_accessibility_check(false) + .wgpu() + .build_ui_state(|ui, app: &mut VectorcraftApp| app.ui(ui), app); + h.run_steps(6); + h.render().unwrap().save(dir.join("tools-inside-left-dock.png")).unwrap(); + h.state_mut().ui.toolbar_double = true; + h.run_steps(3); + h.render().unwrap().save(dir.join("tools-inside-left-dock-double.png")).unwrap(); +} + #[test] fn a_library_panel_opens_renames_and_closes() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 0bd0efdde..cad2530e5 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -494,14 +494,14 @@ fn rename_opens_the_name_field_of_the_selected_object() { assert_eq!(name(&app), before, "one undo step"); } -/// Rename (`ui.rename`) has no default key, F2 being free: it is listed in Keyboard Shortcuts, +/// Rename (`ui.rename`) has no default key (F2 is Cut's alternate on Windows and Linux): it is listed in Keyboard Shortcuts, /// where a key can be assigned to it. #[test] fn rename_has_no_default_key() { use crate::shortcut_editor::{command_for_key, default_command_shortcut}; crate::shortcut_editor::sync(&crate::state::UiState::default()); assert_eq!(default_command_shortcut("ui.rename"), None); - assert_eq!(command_for_key("F2"), None); + assert_ne!(command_for_key("F2"), Some("ui.rename")); let (mut app, _, _) = two_rects(); let listed = app.run("shortcuts.list", json!({"query": "ui.rename"})).unwrap(); let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| r["label"].clone()).collect(); diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 8cb29ef30..4c3241608 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -828,11 +828,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification -/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the -/// Every-line Composer, Roman Hanging Punctuation off and no kashidas. +/// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi +/// Set, Regular burasagari, the Hard kinsoku set, the Every-line Composer, Roman Hanging +/// Punctuation off, no kashidas and no tab stops, as one undo step named Reset Panel (inside an +/// undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); + // The Type tool's typing session stays an undo step of its own. + super::character::end_typing(app); + let open = app.session.active().is_some_and(|st| st.undo_group.is_some()); + if !open { + app.session.begin_named_undo_group(Some("Reset Panel".into())); + } para_cmd(app, "text.setStyle", json!({"justify": "auto"})); format( app, @@ -842,10 +850,16 @@ fn reset_panel(app: &mut VectorcraftApp) { "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, "kashida": "none", "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", - "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine", - "romanHangingPunctuation": false + "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", + "composer": "everyLine", "romanHangingPunctuation": false }), ); + para_cmd(app, "text.tabs.clear", json!({})); + // Closes the active document's undo group only, so an undo group open in another document + // stays open. + if !open && let Some(st) = app.session.active_mut() { + st.undo_group = None; + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index a1df5a927..5facaee19 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -1,4 +1,5 @@ use super::*; +use vectorcraft_doc::Mojikumi; use vectorcraft_doc::NodeId; use vectorcraft_engine::Session; @@ -691,3 +692,143 @@ fn the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right() assert!(seen.iter().all(|s| *s), "{case}: a wide bar shows every group: {seen:?}"); } } + +/// Reset Panel gives the paragraphs it acts on new type's Mojikumi Set (Line-end Punctuation +/// Half Width) and no tab stops, as one undo step named Reset Panel: while the Type tool edits, +/// the paragraph at its caret (the typing before it stays a step of its own), and then the +/// selected type's paragraphs. In an undo group that is already open, the reset is part of +/// that group's step. +#[test] +fn reset_panel_resets_the_mojikumi_set_and_tab_stops_in_one_step() { + let (mut app, id) = editing_text("First\nSecond"); + app.run("text.setFormat", json!({"id": id, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "stops": [{"position": 36}, {"position": 90, "align": "right"}]})).unwrap(); + let text = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.as_ref().clone() + }; + let paras = |app: &VectorcraftApp| { + let text = text(app); + [text.para_at(0), text.para_at(1)].map(|p| (p.mojikumi, p.tabs.len())) + }; + let custom = (Mojikumi::None, 2); + let reset = (Mojikumi::LineEndHalf, 0); + assert_eq!(paras(&app), [custom, custom]); + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let last_step = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|e| e.label.clone()); + // Type over the second paragraph's text: the typing session is still open. + select_range(&mut app, 6, 12); + let before = steps(&app); + let ctx = egui::Context::default(); + let raw = egui::RawInput { events: vec![egui::Event::Text("X".into())], ..Default::default() }; + ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())).textures_delta.clear(); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + reset_panel(&mut app); + assert_eq!(paras(&app), [custom, reset], "only the paragraph at the caret"); + assert_eq!((steps(&app), last_step(&app)), (before + 2, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paras(&app), [custom, custom]); + assert_eq!(text(&app).plain_text(), "First\nX", "the typing stays"); + app.run("edit.redo", json!({})).unwrap(); + app.session.set_tool_option("stopEditing", &json!(true)); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app), [reset, reset]); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + // In an undo group that is already open (MCP `command_batch` with `atomic`), the reset is + // part of that group's step. + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app), [reset, reset]); + app.run("text.setFormat", json!({"id": id, "mojikumi": "none"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Batch".to_string()))); +} + +/// Reset Panel gives paragraphs that start with different Mojikumi Sets and tab stops the same +/// Mojikumi Set and no tab stops, as one undo step, and one Undo gives each paragraph its own +/// values back: while the Type tool edits, the paragraphs the selection touches, and then the +/// paragraphs of every selected type object. +#[test] +fn reset_panel_resets_paragraphs_with_different_values_in_one_step() { + let (mut app, id) = editing_text("First\nSecond\nThird"); + app.run("text.setFormat", json!({"id": id, "start": 0, "end": 1, "mojikumi": "none"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 6, "end": 7, "mojikumi": "lineEndHalf"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 13, "end": 14, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "start": 0, "end": 1, "stops": [{"position": 36}, {"position": 90, "align": "right"}]})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "start": 6, "end": 7, "stops": [{"position": 120, "align": "decimal", "leader": "."}]})).unwrap(); + let paras = |app: &VectorcraftApp, id: &Value| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.paragraph_styles().into_iter().map(|p| (p.mojikumi, p.tabs.iter().map(|t| t.position).collect::>())).collect::>() + }; + let first = (Mojikumi::None, vec![36.0, 90.0]); + let second = (Mojikumi::LineEndHalf, vec![120.0]); + let third = (Mojikumi::None, vec![]); + let reset = (Mojikumi::LineEndHalf, vec![]); + assert_eq!(paras(&app, &id), [first.clone(), second.clone(), third.clone()]); + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let last_step = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|e| e.label.clone()); + // A selection from the first paragraph into the second. + select_range(&mut app, 2, 8); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app, &id), [reset.clone(), reset.clone(), third.clone()], "only the paragraphs the selection touches"); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + assert_eq!(paras(&app, &id), [first.clone(), second.clone(), third.clone()]); + // Two selected type objects. + app.session.set_tool_option("stopEditing", &json!(true)); + let other = app.run("text.create", json!({"x": 40, "y": 120, "text": "Other"})).unwrap()["id"].clone(); + app.run("text.setFormat", json!({"id": other, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": other, "stops": [{"position": 48, "align": "center"}]})).unwrap(); + let other_values = (Mojikumi::None, vec![48.0]); + app.run("select.set", json!({"ids": [id, other]})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app, &id), [reset.clone(), reset.clone(), reset.clone()]); + assert_eq!(paras(&app, &other), [reset]); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + assert_eq!(paras(&app, &id), [first, second, third]); + assert_eq!(paras(&app, &other), [other_values]); +} + +/// Reset Panel closes only the undo group it opens: an undo group open in another document +/// stays open, and that document's later edits stay part of its step. +#[test] +fn reset_panel_leaves_an_undo_group_open_in_another_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + // The second document is open before the group begins, so switching to it opens no group in it. + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap(); + app.session.set_active(0); + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "mojikumi": "none"})).unwrap(); + let steps = |st: &vectorcraft_engine::DocState| (st.history.undo.len(), st.history.undo.last().map(|e| e.label.clone())); + let a_steps = steps(&app.session.documents()[0]); + assert_eq!(a_steps.1, Some("Batch".to_string())); + app.session.set_active(1); + assert!(app.session.active().unwrap().undo_group.is_none()); + let b_steps = steps(app.session.active().unwrap()).0; + reset_panel(&mut app); + assert_eq!(steps(app.session.active().unwrap()), (b_steps + 1, Some("Reset Panel".to_string()))); + assert!(app.session.active().unwrap().undo_group.is_none()); + assert!(app.session.documents()[0].undo_group.is_some(), "the other document's undo group stays open"); + app.session.set_active(0); + app.run("text.setFormat", json!({"id": a, "mojikumi": "lineEndHalf"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step"); +} diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index c3532da83..8c3fa35d6 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -456,6 +456,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null); let mark = found_mark(ui, d, sp.key); match sp.kind { + // General › Scroll Sideways with Cmd/Ctrl + Wheel acts only while Zoom with Mouse Wheel + // is off: dimmed while it is on. + PrefKind::Bool if sp.key == "ctrlWheelScrollsSideways" => { + ui.add_enabled_ui(!d.bool("zoomWithMouseWheel"), |ui| bool_row(ui, d, sp.key, sp.label)); + } // Units › Numbers Without Units Are Points only tells points from picas: dimmed // unless a unit is Picas. PrefKind::Bool if sp.key == "numbersWithoutUnitsArePoints" => { diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index a43ec6677..8e0db81d9 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -2,6 +2,24 @@ use super::*; use crate::Services; use vectorcraft_engine::Session; +/// The three marquee choices are available in Preferences and saved through the same path as +/// other selection preferences. +#[test] +fn marquee_selection_choices_are_saved_from_preferences() { + let mut app = app(); + open(&mut app, Some("Selection & Anchor Display")); + let text = crate::tests_labels::painted_text(&mut app, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Marquee Selection") && text.contains("Touching"), "{text}"); + app.ui.dialog.as_mut().unwrap().fields.insert("marqueeSelection".into(), json!("directional")); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.marquee_selection, "directional"); + let saved: Prefs = serde_json::from_value(app.ui.engine_prefs.clone()).unwrap(); + assert_eq!(saved.marquee_selection, "directional"); + open(&mut app, Some("Selection & Anchor Display")); + let text = crate::tests_labels::painted_text(&mut app, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Drag Direction"), "{text}"); +} + #[test] fn geometric_snapping_is_a_saved_smart_guides_preference() { let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); @@ -16,6 +34,27 @@ fn geometric_snapping_is_a_saved_smart_guides_preference() { assert!(a.run("view.snapping", json!({})).is_err()); } +/// Type › Start Lists From Typed Markers is a checkbox in the Type panel, on by default; turning +/// it off is saved, and every bundled language translates its label. +#[test] +fn start_lists_from_typed_markers_is_a_saved_type_preference() { + const LABEL: &str = "Start Lists From Typed Markers"; + let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + assert!(a.session.prefs.typed_lists); + open(&mut a, Some("Type")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains(LABEL), "{text}"); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields.get("typedLists"), Some(&json!(true))); + a.ui.dialog.as_mut().unwrap().fields.insert("typedLists".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(!a.session.prefs.typed_lists); + let saved: Prefs = serde_json::from_value(a.ui.engine_prefs.clone()).unwrap(); + assert!(!saved.typed_lists); + for lang in crate::i18n::Lang::all().filter(|l| l.code() != crate::i18n::Lang::EN.code()) { + assert!(crate::i18n::has(lang, LABEL), "{}", lang.code()); + } +} + /// A resting checkbox must have a visible outline in both states and every brightness. #[test] fn preference_checkboxes_draw_outlines_without_hover() { @@ -503,6 +542,41 @@ fn numbers_without_units_are_points_reaches_the_fields_and_dims_without_picas() assert!(picas_in_use(d), "enabled with a unit in picas"); } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel: its checkbox is dimmed while Zoom with +/// Mouse Wheel is checked in the dialog, and a click on it then leaves the setting as it was. +#[test] +fn scroll_sideways_with_cmd_wheel_dims_with_zoom_with_mouse_wheel() { + fn label(s: &egui::Shape, text: &str) -> Option<(egui::Color32, egui::Rect)> { + match s { + egui::Shape::Text(t) if t.galley.text() == text => t.galley.job.sections.first().map(|s| (s.format.color, t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().find_map(|s| label(s, text)), + _ => None, + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut a = app(); + let mut frame = |d: &mut Dialog, events: Vec| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| category_fields(ui, &mut a, d, "General")); + out.textures_delta.clear(); + out.shapes.iter().find_map(|c| label(&c.shape, "Scroll Sideways with Cmd/Ctrl + Wheel")) + }; + let t = Tokens::get(&ctx); + for (zoom, color, what) in [(false, t.text, "enabled while Zoom with Mouse Wheel is off"), (true, t.text_disabled, "dimmed while it is on")] { + let mut d = Dialog::new("preferences", json!({"zoomWithMouseWheel": zoom, "ctrlWheelScrollsSideways": true})); + let (shown, at) = frame(&mut d, vec![]).unwrap_or_else(|| panic!("no label ({what})")); + assert_eq!(shown, color, "{what}"); + let press = + |pressed| egui::Event::PointerButton { pos: at.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut d, vec![egui::Event::PointerMoved(at.center()), press(true)]); + frame(&mut d, vec![press(false)]); + // A click turns it off while it is enabled, and leaves it on while it is dimmed. + assert_eq!(d.fields["ctrlWheelScrollsSideways"], json!(zoom), "after a click, {what}"); + } +} + /// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the /// choices this version offers are kept (#502). #[test] diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index d5ed1ade2..01deb3be8 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -65,14 +65,14 @@ pub(crate) use ui_mirror; /// The keymap with the user's overrides, and the effective text of every entry they change. pub(crate) struct Mirror { pub keymap: Keymap, + source_overrides: Overrides, text: HashMap>, } impl Mirror { fn new(overrides: Overrides) -> Mirror { - let keymap = crate::keymap::template().clone().with_overrides(overrides); - let text = keymap - .overrides() + let keymap = crate::keymap::keymap_with(&overrides); + let text = overrides .iter() .map(|(k, _)| { let key = entry_of_stored(k); @@ -80,7 +80,7 @@ impl Mirror { (key, t) }) .collect(); - Mirror { keymap, text } + Mirror { keymap, source_overrides: overrides, text } } } @@ -113,7 +113,7 @@ pub fn sync(ui: &UiState) { let same = store() .read() .map(|m| match &*m { - Some(m) => *m.keymap.overrides() == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), + Some(m) => m.source_overrides == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), None => ui.shortcut_overrides.is_empty(), }) .unwrap_or(false); @@ -612,10 +612,32 @@ pub fn conflicts_in(km: &Keymap) -> Vec<(String, Vec)> { pub fn assign(overrides: &mut Overrides, key: &str, chord: Option<&str>, force: bool) -> Result, String> { let mut km = keymap_with(overrides); let removed = assign_in(&mut km, key, chord, force)?; - *overrides = km.overrides().clone(); + *overrides = saved_overrides(km.overrides(), overrides); Ok(removed) } +/// The overrides to save from a working keymap's `current` overrides. An entry for Cut, Copy or +/// Paste that `source`, the set before the change, did not hold is left out when it is the entry +/// [`crate::keymap::keymap_with`] makes from the other entries of `source` or of `current`. +pub(crate) fn saved_overrides(current: &Overrides, source: &Overrides) -> Overrides { + let mut saved = current.clone(); + for (key, _, _) in crate::keymap::EDIT_FUNCTION_KEYS { + let Some(now) = current.get(key) else { continue }; + if source.get(key).is_some() { + continue; + } + let made = |from: &Overrides| { + let mut rest = from.clone(); + rest.remove(key); + crate::keymap::edit_alias_overrides(&rest, crate::keymap::platform()).get(key).is_some_and(|t| t == now) + }; + if made(current) || made(source) { + saved.remove(key); + } + } + saved +} + /// [`assign`] on a keymap. pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) -> Result, String> { let command = command_of(key); @@ -789,7 +811,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option return Some(Err(e)), }; let km = keymap_with(&app.ui.shortcut_overrides); - let ov = km.overrides(); + let ov = &app.ui.shortcut_overrides; Ok(Value::Array( entries() .iter() @@ -943,7 +965,7 @@ impl DialogView { let backtick = backtick_text(); let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); let conflicts = conflicts_in(&keymap).len(); - DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts } + DialogView { source: stored::to_value(&overrides), backtick, keymap, texts, conflicts } } /// The view of `d`'s working set, from the cache unless the set or the backtick key's @@ -972,6 +994,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut ok = false; let mut cancel = false; drop_hidden(&mut d, km.overrides()); + let saved = dialog_overrides(&d); let recording = d.str("__recording"); // Record a chord (before widgets see the keys). Modifiers held down are part of the next key's // chord, not a chord of their own. @@ -1089,7 +1112,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { d.fields.insert("__selected".into(), json!(e.key)); } let label = if rec == e.key { tl!("Press keys…").to_string() } else { sc.unwrap_or_else(|| "—".into()) }; - let changed = crate::keymap::has_override(km.overrides(), &e.key); + let changed = crate::keymap::has_override(&saved, &e.key); let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text }); let b = ui .add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key)) @@ -1161,7 +1184,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); if let Cow::Owned(km) = &km { - d.fields.insert("overrides".into(), stored::to_value(km.overrides())); + d.fields.insert("overrides".into(), stored::to_value(&saved_overrides(km.overrides(), &saved))); } let import = d.fields.remove("__import").is_some(); let export = d.fields.remove("__export").is_some(); @@ -1667,6 +1690,126 @@ mod tests { assert_eq!(view.conflicts, 0); } + #[test] + fn edit_dialog_rows_show_primary_and_function_keys() { + for p in [craft_keymap::Platform::Windows, craft_keymap::Platform::Linux, craft_keymap::Platform::Mac] { + let km = crate::keymap::keymap_for(p); + for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { + let shown = dialog_shortcuts_with(&km, key, None).unwrap(); + let primary = menus::pretty_shortcut(primary); + assert!(shown.contains(&primary), "{p:?} {key}: {shown}"); + assert_eq!(shown.contains(alternate), !p.is_mac(), "{p:?} {key}: {shown}"); + } + } + for (key, primary) in [("edit.cut", "Cmd+X"), ("edit.copy", "Cmd+C"), ("edit.paste", "Cmd+V")] { + let shown = menus::item_shortcut(key, &Value::Null).map(menus::pretty_shortcut); + assert_eq!(shown, Some(menus::pretty_shortcut(primary)), "{key} menu shortcut"); + } + } + + #[test] + fn saved_function_key_assignment_does_not_change_cut_in_the_editor() { + if crate::keymap::platform().is_mac() { + return; + } + let mut saved = Overrides::new(); + crate::keymap::set_override(&mut saved, "tool:pen", "F2"); + let mirror = Mirror::new(saved.clone()); + assert!(!mirror.text.contains_key("edit.cut")); + assert!(conflicts_in(&mirror.keymap).is_empty()); + + let view = DialogView::new(saved.clone()); + assert_eq!(view.source, stored::to_value(&saved)); + assert_eq!(view.conflicts, 0); + assert_eq!(dialog_shortcuts_with(&view.keymap, "edit.cut", None), Some(menus::pretty_shortcut("Cmd+X"))); + + let mut other = saved.clone(); + assign(&mut other, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert!(!crate::keymap::has_override(&other, "edit.cut")); + let (set, imported) = import_json(&export_json("Mine", &other)).unwrap(); + assert_eq!(set, "Mine"); + assert_eq!(imported, other); + assert!(!crate::keymap::has_override(&imported, "edit.cut")); + + let mut km = view.keymap.clone(); + assign_in(&mut km, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert_eq!(saved_overrides(km.overrides(), &saved), other); + } + + #[test] + fn reset_stolen_edit_function_keys_restores_their_defaults() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut saved = Overrides::new(); + assert_eq!(assign(&mut saved, "tool:pen", Some(alias), true).unwrap(), vec![edit]); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + reset_one(&mut saved, "tool:pen"); + assert_eq!(crate::keymap::lookup(&keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut d = Dialog::new("shortcuts", json!({"overrides": {}})); + let source = dialog_overrides(&d); + let mut km = keymap_with(&source); + record(&mut d, &mut km, "tool:pen", alias); + d.fields.insert("overrides".into(), stored::to_value(&saved_overrides(km.overrides(), &source))); + let mut dialog_saved = dialog_overrides(&d); + assert!(!crate::keymap::has_override(&dialog_saved, edit), "{alias} dialog edit override"); + reset_one(&mut dialog_saved, "tool:pen"); + assert_eq!(crate::keymap::lookup(&keymap_with(&dialog_saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut changed = Overrides::new(); + crate::keymap::set_override(&mut changed, edit, "Ctrl+Alt+Shift+F12"); + assign(&mut changed, "tool:pen", Some(alias), true).unwrap(); + assert_eq!(crate::keymap::override_text(&changed, edit).as_deref(), Some("Ctrl+Alt+Shift+F12")); + } + } + + #[test] + fn reassigning_a_saved_function_key_gives_it_back_to_its_edit_command() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut saved = Overrides::new(); + crate::keymap::set_override(&mut saved, "edit.preferences", alias); + assign(&mut saved, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + assert_eq!(crate::keymap::lookup(&keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut source = Overrides::new(); + crate::keymap::set_override(&mut source, "edit.preferences", alias); + let mut d = Dialog::new("shortcuts", json!({"overrides": stored::to_value(&source)})); + let mut km = keymap_with(&source); + record(&mut d, &mut km, "edit.preferences", "Cmd+Alt+Shift+F12"); + let dialog_saved = saved_overrides(km.overrides(), &source); + assert!(!crate::keymap::has_override(&dialog_saved, edit), "{alias} dialog edit override"); + assert_eq!(crate::keymap::lookup(&keymap_with(&dialog_saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + } + } + + #[test] + fn assigning_an_edit_function_key_elsewhere_reports_the_conflict() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let label = entry(edit).map(|e| e.label.clone()).unwrap(); + assert_eq!(conflicts_with(&Overrides::new(), alias, "tool:pen"), vec![edit.to_string()], "{alias}"); + let mut ov = Overrides::new(); + let err = assign(&mut ov, "tool:pen", Some(alias), false).unwrap_err(); + assert!(err.ends_with(&format!("is already used by {label}")), "{alias}: {err}"); + assert!(!crate::keymap::has_override(&ov, "tool:pen"), "{alias}: a refused assignment saves nothing"); + + let mut d = Dialog::new("shortcuts", json!({"overrides": {}})); + let mut km = keymap_with(&Overrides::new()); + record(&mut d, &mut km, "tool:pen", alias); + assert_eq!(d.str("__conflict"), edit, "{alias}"); + assert!(d.str("__message").contains(&label), "{alias}: {}", d.str("__message")); + assert_eq!(crate::keymap::lookup(&km, &crate::keymap::vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{alias}"); + } + } + #[test] fn entries_cover_tools_and_menu_commands() { assert!(entries().iter().any(|e| e.key == "tool:selection")); diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index b64dd4f7a..3d40a5427 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -28,6 +28,53 @@ fn named_panel_default_chords_toggle_their_panels() { } } +#[test] +fn edit_function_keys_dispatch_outside_text_editing_only() { + if crate::keymap::platform().is_mac() { + return; + } + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let shape = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [shape]})).unwrap(); + crate::shortcut_editor::sync(&app.ui); + frame(&mut app, press(Key::F3, Modifiers::NONE)); + assert_eq!(app.session.clipboard.nodes.len(), 1, "F3 copies the selection"); + frame(&mut app, press(Key::F2, Modifiers::NONE)); + assert!(app.session.active().unwrap().selection.objects.is_empty(), "F2 cuts the selection"); + frame(&mut app, press(Key::F4, Modifiers::NONE)); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "F4 pastes the copied object"); + + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + for key in [Key::F2, Key::F3, Key::F4] { + frame(&mut app, press(key, Modifiers::NONE)); + assert_eq!(text_and_layers(&mut app, id).0, "Hi", "{key:?} leaves edited text alone"); + assert!(app.session.tool_wants_text(), "{key:?} keeps editing"); + } + + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let mut field = String::from("Field text"); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let response = ui.text_edit_singleline(&mut field); + ui.ctx().memory_mut(|m| m.request_focus(response.id)); + }); + out.textures_delta.clear(); + for key in [Key::F2, Key::F3, Key::F4] { + let mut out = ctx.run_ui(egui::RawInput { events: press(key, Modifiers::NONE), ..Default::default() }, |ui| { + handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut field); + }); + out.textures_delta.clear(); + assert_eq!(field, "Field text", "{key:?} leaves focused text alone"); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "{key:?} leaves the canvas selection alone"); + } +} + #[test] fn other_panel_shortcuts_keep_plain_panel_params() { for panel in ["character", "swatches"] { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 85f87b723..64ead689f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -460,9 +460,14 @@ pub struct UiState { #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option, /// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`: - /// docked at the window's left edge. + /// docked at the window's left edge, or beside the canvas with [`Self::tools_beside_canvas`]. #[serde(default)] pub toolbar_pos: Option<[f32; 2]>, + /// The docked Tools panel sits between the left-side panels and the canvas instead of at the + /// window's left edge (Window › Toolbars › Keep Tools Beside the Canvas, + /// `window.toolsBesideCanvas`). Off by default, and off in state saved without it. + #[serde(default)] + pub tools_beside_canvas: bool, pub status_bar: bool, pub dock: bool, /// The bars that Tab (`window.dock`) hid, which the next Tab shows again; none when Tab has not @@ -992,6 +997,7 @@ impl Default for UiState { docking_generation: 0, dock_width: None, toolbar_pos: None, + tools_beside_canvas: false, status_bar: true, dock: true, tab_hid: None, diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index be04485d0..dce5912ba 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -95,24 +95,50 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) { crate::custom_toolbars::remember(&mut app.ui, id); } +/// The docked Tools panel's rect as last laid out; removed while it floats. +pub(crate) fn docked_rect_id() -> egui::Id { + egui::Id::unique("toolbar-docked-rect") +} + +fn panel_width(ui: &crate::state::UiState) -> f32 { + if ui.toolbar_double { 80.0 } else { WIDTH } +} + +/// Space reserved inside the protected canvas panel for a visible Tools panel docked beside the +/// canvas (`UiState::tools_beside_canvas`); none while Tools docks at the window's edge. +pub(crate) fn docked_width(ui: &crate::state::UiState) -> f32 { + if ui.tools_beside_canvas && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() { panel_width(ui) + 6.0 } else { 0.0 } +} + +/// With `UiState::tools_beside_canvas` on, the canvas-edge redocking target, registered even +/// while Tools floats, and the docked panel, which follows the canvas's left edge as dock columns +/// resize, collapse or hide. With it off, `app.rs` draws Tools before the docking layout. +pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) { + if app.ui.tools_beside_canvas && app.ui.toolbar && app.ui.screen_mode < 3 { + crate::tools_float::set_dock_zones(ui.ctx(), ui.available_rect_before_wrap(), WIDTH); + if app.ui.toolbar_pos.is_none() { + show(app, ui); + } + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // The user's choice alone: a window too short for the tools scrolls them. let cols = if app.ui.toolbar_double { 2 } else { 1 }; // Two 36 pt buttons with 2 pt either side inside the border and separator line (at 76 they // overran the right border). - let w = if cols == 2 { 80.0 } else { WIDTH }; - // Where the panel docks: along the window's left edge, under the bars. - let edge = ui.available_rect_before_wrap(); - ui.ctx().data_mut(|d| d.insert_temp(crate::tools_float::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height())))); + let w = panel_width(&app.ui); match app.ui.toolbar_pos { None => { let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 }; - egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| { + let docked = egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| { crate::theme::card(ui, &t, margin, |ui| body(app, ui, cols, None)); }); + ui.ctx().data_mut(|d| d.insert_temp(docked_rect_id(), docked.response.rect)); } Some(pos) => { + ui.ctx().data_mut(|d| d.remove::(docked_rect_id())); // Floating, kept inside the window, its tools scrolling when they don't fit under it. let ctx = ui.ctx().clone(); let id = egui::Id::unique("toolbar-floating"); diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 25d86b69f..13108368f 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -1,8 +1,10 @@ //! The Tools panel floats by its title bar and docks again on the window's left edge //! ([`crate::toolbar`]). It stays outside the docking layout: dragging its title bar out of the //! docked panel floats it under the pointer, a floating panel's title bar moves it, and a release -//! over the strip along the window's left edge (lit up) docks it there. Escape or losing the -//! window's focus puts it back where the drag began. +//! over the strip along the window's left edge (lit up) docks it there. With Keep Tools Beside the +//! Canvas on (`UiState::tools_beside_canvas`), a release over the canvas's left edge docks it too, +//! and either release docks it beside the canvas. Escape or losing the window's focus puts it back +//! where the drag began. //! //! `window.panel.float {panel: "tools"}` / `window.panel.dock {panel: "tools"}` do the same for //! agents. Its position is kept with the preferences and in user workspaces, and a position saved @@ -41,11 +43,35 @@ pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) } -/// The strip along the window's left edge where the Tools panel docks, as last laid out. +/// The strip along the canvas's left edge where the Tools panel docks while Keep Tools Beside the +/// Canvas is on, as last laid out. pub(crate) fn tools_zone_id() -> Id { Id::unique("tools-float-zone") } +fn window_zone_id() -> Id { + Id::unique("tools-float-window-zone") +} + +/// Accept a drop next to panels docked left, independently of the window-edge gesture. +pub(crate) fn set_dock_zones(ctx: &egui::Context, canvas: Rect, width: f32) { + ctx.data_mut(|d| { + d.insert_temp(tools_zone_id(), Rect::from_min_size(canvas.min, vec2(width, canvas.height()))); + }); +} + +/// Forget the canvas-edge target, so a strip laid out while Keep Tools Beside the Canvas was on +/// does not dock Tools after it is turned off. +pub(crate) fn clear_canvas_dock_zone(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::(tools_zone_id())); +} + +/// The legacy 48-point window-edge target spans the full layout below the bars, including +/// panels above and below the canvas. Its width does not follow the toolbar's column count. +pub(crate) fn set_window_dock_zone(ctx: &egui::Context, window: Rect) { + ctx.data_mut(|d| d.insert_temp(window_zone_id(), Rect::from_min_size(window.min, vec2(48.0, window.height())))); +} + /// `pos` moved so a panel of `size` stays inside `screen` (a position saved on a bigger window). pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 { let [x, y] = pos; @@ -90,9 +116,13 @@ pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: & } } -/// The docking strip under `at`, if any. -fn drop_zone(ctx: &egui::Context, at: Pos2) -> Option { - ctx.data(|d| d.get_temp::(tools_zone_id())).filter(|r| r.contains(at)) +/// The docking strip under `at`, if any: the window's left edge, and with `beside_canvas` the +/// canvas's left edge as well. +fn drop_zone(ctx: &egui::Context, at: Pos2, beside_canvas: bool) -> Option { + ctx.data(|d| { + let canvas = d.get_temp::(tools_zone_id()).filter(|_| beside_canvas); + [canvas, d.get_temp::(window_zone_id())].into_iter().flatten().find(|r| r.contains(at)) + }) } fn highlight(ctx: &egui::Context, zone: Rect) { @@ -116,7 +146,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } let at = ctx.input(|i| i.pointer.interact_pos()); - let zone = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_zone(ctx, at)); + let zone = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_zone(ctx, at, app.ui.tools_beside_canvas)); if ctx.input(|i| i.pointer.primary_down()) { if let Some(p) = at.map(|at| at - m.grab) && p.x.is_finite() diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index d7590ff3b..d13e6a688 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -57,6 +57,9 @@ pub struct Workspace { pub docking_collapsed: std::collections::BTreeSet, /// Where the Tools panel floats (built-in workspaces: docked). pub toolbar_pos: Option<[f32; 2]>, + /// The docked Tools panel sits beside the canvas, inside any left-side panels (built-in + /// workspaces and workspaces saved without it: at the window's left edge). + pub tools_beside_canvas: bool, /// Main dock column width in screen points while `docking` is none (#1753). #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option, @@ -197,6 +200,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { docking_icons: Default::default(), docking_collapsed: Default::default(), toolbar_pos: ui.toolbar_pos, + tools_beside_canvas: ui.tools_beside_canvas, dock_width: ui.dock_width, toolbars: ui.custom_toolbars.clone(), } @@ -223,6 +227,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.docking_hidden = w.docking_hidden.clone(); ui.docking_hidden_groups = w.docking_hidden_groups.clone(); ui.toolbar_pos = w.toolbar_pos; + ui.tools_beside_canvas = w.tools_beside_canvas; ui.dock_width = w.dock_width; crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos); ui.custom_toolbars = w.toolbars.clone(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ce944a78b..0cd8d4ffa 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -100,7 +100,7 @@ the two can still win that race. | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | -| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | +| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | @@ -330,7 +330,8 @@ not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (th modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar -drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged. +drops its brand mark. On Linux the app bar also drops its brand mark whenever the desktop draws the window's frame, with +System Title Bar on or off. On macOS the window buttons are centred on the app bar; the web build is unchanged. Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); @@ -430,7 +431,9 @@ below). Dragging a tab, a group's drag bar or an icon out of the dock and droppi floats it; dropping it on a group's tabs, on a group's upper or lower edge or beside a column docks it there. On the desktop each floating window is an OS window of its own; on the web and on Wayland it floats inside the app window. `ui.toolbar_pos` in `ui.inspect` is the Tools panel's corner (null: docked); `window.panel.float` and -`window.panel.dock` with `panel: "tools"` float it at `x`, `y` or dock it on the window's left edge. +`window.panel.dock` with `panel: "tools"` float it at `x`, `y` or dock it on the window's left edge. With +`window.toolsBesideCanvas {on?}` (Window › Toolbars › Keep Tools Beside the Canvas, off by default; `ui.tools_beside_canvas`) +the docked Tools panel sits between left-side panels and the canvas, and `window.panel.dock` docks it there. Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection (fields `preset`: a preset name, setting it loads that preset's options; the option keys of diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7c8a4d227..f716d693b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,6 +17,8 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| +| Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | @@ -251,7 +253,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | PSB | open | yes (merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | -| HEIC / HEIF | open, place | no | no | | +| HEIC / HEIF | open, place | yes (HEVC-coded still images: single pictures and grids of tiles, 8 to 10 bits, with an alpha image; image sequences, other codings and grids of cropped tiles are refused) | no | `.heic`, `.heif`; decoded by heic-rs in the engine's default `heif` feature (a build without it refuses the files with a message); the container's rotation, mirror and crop are applied; without an `nclx` box the HEVC stream's video signal type gives the matrix and range; an RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; EXIF resolution tags size the image, or 72 ppi; bounded decoding; `tests_heifread.rs` | | PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` | | JPEG 2000 | open, place | yes (JP2 and raw codestream images; multiple codestreams and JPX composition or layers are refused) | no | `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c`, `.jpc`; resolution uses the valid `resd` (display) value on each axis, or else the valid `resc` (capture) value, or 72 ppi; bounded decoding; `tests_jp2read.rs` | | PICT | open, place (legacy) | no | no | rarely used | diff --git a/docs/gaps.md b/docs/gaps.md index 75f838f04..661a678d5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,9 +1,10 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +**Feature evidence last updated:** 2026-11-02. | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -37,6 +38,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | @@ -904,15 +907,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-22 | minor | Affinity import: master pages, tables, paragraph formats, text scale, shadows, glows, outlines, overlays, underlines, strikethrough, CMYK pixel layers through the document's profile and editable embedded documents, text placed as Affinity places it (AF.5, from storytold/designcraft#291). No entry closed, estimates unchanged. Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | | 2026-10-21 | minor | G17: filter plug-ins keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own; engine and unit regressions. No entry closed, estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | G8: stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw: Roughen, Zig Zag, Free Distort, Twist, the warps and Transform build within it, and an effect that would still pass it is left out (#3578). No entry closed, estimates unchanged. | -| 2026-10-22 | minor | G13: gradient Method (30.0), Classic or Perceptual, in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports (with Dither, a Perceptual fill takes its noise); G13 estimate 14–24 h → 13–23 h. | -| 2026-10-22 | minor | G8: SVG images in SVG images share the depth cap (any data URL type usvg reads as SVG) and SVGZ images one 512 MB unpacking cap; past either, an image is left out with a warning (#3562, #3563). No entry closed, estimates unchanged. | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | G19: PDF export asks viewers to display the document title only when File Info has a Document Title or an explicit export title is supplied; untitled documents keep the PDF file name in the viewer title bar (#3646); scores and estimates unchanged. | -| 2026-10-22 | minor | G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged. | -| 2026-10-22 | minor | G13: gradient Dither (30.0) in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports; G13 estimate 15–25 h → 14–24 h. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index d0ac9fda7..92e5f42ac 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -32,15 +32,6 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe its own palettes are chosen; this is VectorCraft's own method (see [image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)). -**Flat Logo colors.** Flat Logo traces white as a color of its own beside the colors named in -`logoColors`, and takes a named color with every channel 245 or more as that white. Each pixel is read -as a blend of the traced colors and goes to the color with the largest share, so a near-white area can -go to a named light color instead of white. With `"logoWhite": false` in the params of `imageTrace.make` or -`imageTrace.makeAndExpand`, it traces the named colors only: white areas take the nearest named color, -a named white or near-white is used as named, and enclosed transparent areas stay holes instead of -being painted white. When Flat Logo finds the colors itself, `logoWhite` has no effect, and with -`ignoreWhite` white areas are left out whatever `logoWhite` says. - **Document Library color groups.** With Palette › Document Library and Library › Document Swatches, the panel's **Colors** menu traces with every document swatch (**All**) or with the colors of one color group from the Swatches panel, as the public diff --git a/docs/mcp.md b/docs/mcp.md index af54e1b54..5a61871ec 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -459,6 +459,8 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version`, `preview` or `precision`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | @@ -481,6 +483,12 @@ for horizontal writing. This is the same option as `text.create` / `text.createI on the OpenType `vert` feature, so a font's vertical glyph alternates are used; it does not change the engine's CJK composition. +`type.orientation.vertical` and `type.orientation.horizontal` (Type › Type Orientation) switch existing type objects +(the selection, or `ids`) and reply `{vertical, ids}`. The writing direction is a setting of the whole thread: the +commands set every frame of a selected frame's thread, and `ids` lists them all. Threading frames gives the thread its +first frame's direction, frames taken out of a thread keep theirs, and a file whose frames of one thread differ opens +with the first frame's direction in all of them. + For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text). @@ -1685,6 +1693,21 @@ changes neither option. Behavior reference: [View artwork](https://helpx.adobe.c {"name":"invoke_menu","arguments":{"command":"ui.navigatorPanelOptions","params":{"color":"Light Blue","solidDashes":true}}} ``` +## Tools panel placement + +By default the docked Tools panel sits at the window's left edge, outside any panels docked on the left. +`window.toolsBesideCanvas {on?}` (Window › Toolbars › Keep Tools Beside the Canvas, remote mode, `invoke_menu`) +docks it between those panels and the canvas instead (`on: true`), back at the window's edge (`on: false`), or +toggles it (`on` omitted or null), and answers the new state. Any other value of `on` is an error and changes +nothing. With the setting on, a floating Tools panel docks when it is dropped on the canvas's left edge as well as +on the window's left edge, and `window.panel.dock {panel: "tools"}` docks it beside the canvas. A floating Tools +panel stays floating when the setting changes. The setting is VectorCraft's own layout option, off by default; it is +kept with the workspace, and built-in workspaces and workspaces saved without it keep Tools at the window's edge. + +```json +{"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} +``` + ## Ruler guides Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a @@ -2015,6 +2038,17 @@ already walks Image Trace. Short agent entry points: - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. + With `byColor: true`, `imageTrace.expand` and `imageTrace.makeAndExpand` put each color's paths in a group of their + own, named by the global swatch the color links to (a tint with its percentage, as `Sky 50%`) or as `#rrggbb`; + gradient fills share a group named `Gradients`. The groups stack in the order of the lowest path in each, and each + keeps its paths' order. For either Expand command, a path that would move under a path of another + color whose painted bounds, effects and shadows included, come within one document point of it starts another group + of its color instead of joining an earlier one. Paths whose painted bounds come within one document point of an + intervening color stay in separate groups to preserve their stacking order; at small view or raster-export scales, + regrouped paths farther apart can still share antialiased pixels, so edge pixels may differ. In a Flat Logo trace of a + black ring around a white disc around a black dot, black gets two groups. `layer.releaseToLayers` on the expanded group then + gives each group a sublayer. A trace made with `autoGrouping` is refused. Expanding by color also refuses a trace + with a live Pathfinder effect. `imageTrace.presets.save {name?, newName?, preset?, enhanced?, params?}` saves tracing settings as a preset kept with the preferences: `params` over `preset`'s (default: the saved preset `name`, else Default), checked as `imageTrace.make` checks them; without `name` it is a new "Tracing Preset N", and `newName` renames a saved preset (the reply's @@ -3503,7 +3537,16 @@ Actions panel plays an action as a `command.batch`, omitting steps whose playbac cleared. An action set saved as `.vcactions` keeps each excluded step with `excluded: true`; loading an earlier version 1 set without that flag includes every step. The Actions panel menu's Batch… runs `action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with -destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. For a Data Sets source, +destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. With a Data Sets source, +`saveAndClose` saves the open document over its own file after each data set, so the file keeps only the last one, +and a document that was never saved, was converted from an older version or was restored by Data Recovery is +refused before a data set is selected, unless a recorded Save or Save As step (not in the template format) writes a +file and makes it the document's own first. When `destinationFolder` is set and Override Save points a recorded Save or +Save As step into it, that step makes each data set's file the document's own, and `saveAndClose` saves each data +set to its own file. The result carries `warning` when every data set saves over the same file: +with `saveAndClose` without such a step, or with a Save, Save a Copy, Save as Template or export step (PDF, EPS, DXF, Save for Web and +Save for Office Documents included) that writes a file and that the batch does not point into `destinationFolder`. The dialog shows the same +warning. For a Data Sets source, `datasetFileNaming?: fileNumber | fileDataSetName | dataSetName` chooses File + Number (`source001`), File + Data Set Name (`source_Alpha`), or Data Set Name (`Alpha`). An omitted parameter keeps File + Data Set Name for existing callers; the dialog starts with File + Number. A folder source ignores this parameter. The folder @@ -3673,6 +3716,12 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. +`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while +`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac +trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a +trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's +`ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held. + `gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets the grid of every open document. `file.new` gives a new document that grid, and `document.open`, `file.newFromTemplate` and `file.revert` give it to @@ -3767,6 +3816,9 @@ Pen's second anchor at (150, 431), in line with the square's centre. - `placeholderText` (on by default): type the Type tools place (`pointer_gesture` with `type`, `areaType`…) starts with placeholder text, selected, so `type_text` replaces it; `text.create` and `text.createInPath` take `placeholder: true`. +- `typedLists` (on by default; Type › Start Lists From Typed Markers): a list marker and a space typed with the Type tool + at the start of a paragraph start a list (see `text.startList`). Off, the Type tool leaves them as typed text; + `text.startList`, `text.setFormat {list}` and the Bullets and Numbered List buttons work as before. - `typeSizeIncrement`, `trackingIncrement`, `baselineShiftIncrement`: what `type.step {attribute: "size" | "leading" | "tracking" | "kerning" | "baselineShift", by?}` steps by, on the text range given, the type objects in `ids`, the Type tool's selected text or the selected type objects. `type.size.increase` / `type.size.decrease` (Cmd+Shift+. and @@ -4365,7 +4417,7 @@ image `object.rasterize` makes with the same options. ## Glass textures Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or -`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 file, decoded within the pixel allowance the +`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000 or HEIC/HEIF file, decoded within the pixel allowance the renderer draws images in, counted as RGBA. `effect.apply` and `effect.setParams` store the image's gray levels in the effect as a grayscale PNG in base64 and set `texture: "loaded"` unless the params give a `texture`. `effect.list` reports the stored string, and a command given that string stores it unchanged. Any other string, a file that is not an image, or an image past @@ -4680,8 +4732,13 @@ document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symb embeddedImages, repeats), `brushNames` (the Brushes panel's brushes; with `selectionOnly`, the brushes the selected objects' strokes use), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes, -and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection) -and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left +and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects` +(`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects +whose drawn characters use a pattern included, hidden fills and strokes, strokes with no width, guides, a compound path +member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a +shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with +the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection), +`patternNames` (the patterns those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left out, which objects lists as Path Length in the document's units, say for a cut line or an LED strip along the letters); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?, selectionOnly?, category?}`: only `category` when given, else every category) and the package report write. @@ -5720,12 +5777,14 @@ next to the same character, and that frame becomes the selection when it was not Typing starts a list too. When the Type tool types a space on its own (one key press, or `type_text {text: " "}`) right after `1.` or `1)`, `A.` or `A)`, or one of `*`, `#`, `>` and `+` at the start of a paragraph that is in no list, the typed characters and the space are removed and the paragraph becomes a numbered list in 1, 2, 3 or A, B, C, or a -bulleted list with the filled dot. A list started with `1)` or `A)` also writes its numbers with a period. A paragraph +bulleted list: `*` gives the filled dot (•), `+` the hollow circle (◦), `#` the filled square (▪) and `>` the dash +(–). A list started with `1)` or `A)` also writes its numbers with a period. A paragraph whose left and first-line indents are both 0 gets 18 pt and -18 pt. The tool runs `text.startList {id, at}`, with `at` the byte after the space, as an undo step of its own after the typing: one Undo puts the characters back as text with no list, and typing goes on after them; Redo removes them again, and typing goes on before the paragraph's text. The same characters later in a paragraph start nothing, and so do list characters that arrive in one piece with the space: a -paste, an IME commit, or `type_text` of a longer string. From a script, `text.startList {id, at?}` (`at` defaults to the end of the text) does +paste, an IME commit, or `type_text` of a longer string. With the `typedLists` preference off (Preferences › Type › +Start Lists From Typed Markers), the Type tool starts no list from typed characters. From a script, `text.startList {id, at?}` (`at` defaults to the end of the text) does the same with the text before `at` and returns `started` and `caret`. When the list starts, `caret` is the byte where the paragraph's text now starts; when the text before `at` starts no list, nothing changes, `started` is false and `caret` is `at`. @@ -6505,7 +6564,8 @@ bounding-box handles scale it in plane space (`perspective.transform`; Shift kee centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch back). Holding Shift during an in-plane move constrains each object's displacement to an axis or 45° diagonal of its own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained -direction on the first object's plane and depth. +direction on the first object's plane and depth. With Snap to Grid, every object on the first object's plane moves +by the same plane offset, whatever its depth, and the edge of their joint bounds nearer a gridline lands on it. `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. diff --git a/docs/releasing.md b/docs/releasing.md index b28ada6d6..64c177618 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -164,7 +164,7 @@ to the target triple, so host build scripts aren't affected. AppStream metainfo, the MIME type for VectorCraft documents, the icons and the licence files. **Document types.** The desktop entry's `MimeType` lists every format File › Open reads but PSD, -PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, so file managers list VectorCraft under Open With for them. +PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, so file managers list VectorCraft under Open With for them. `ai.storyteller.vectorcraft.mime.xml` declares the types that shared-mime-info lacks: VectorCraft documents and templates, `.ait` templates and Affinity files (`application/x-affinity`). An `.af` file gets the Affinity type by its content, and `.afdesign` and `.afpub` files by their extension. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5e6ee6dda..68b15aa3d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | @@ -136,7 +137,6 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; Flat Logo with named colors can leave out white with `logoWhite: false`; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | @@ -192,8 +192,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-03 | minor | M7: Touch Type moves right-to-left characters along their line while keeping text editable; first-logical-character restriction retained. Estimates unchanged. | | 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | | 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | -| 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | -| 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | | 2026-10-26 | minor | M4/G9: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and estimates unchanged | | 2026-10-25 | minor | M10/G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); estimates unchanged | @@ -212,9 +210,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | | 2026-10-21 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset (UI.7); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 037381019..6db5b0d81 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -31,6 +31,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | @@ -364,7 +366,7 @@ what keeps it from beta, not from alpha. | Dimension | % | One agent (h) | Kind | Doc | |---|---:|---:|---|---| | Features (depth, weighted) | 75% | 220–360 | estimated | this file | -| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring) | | File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | @@ -621,7 +623,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/). - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending. -- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. +- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** @@ -643,6 +645,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). + - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Unless a recorded Save or Save As step writes a file and makes it the document's own first, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. @@ -691,6 +694,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). + - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). @@ -735,6 +739,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). + - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). @@ -758,6 +763,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. + - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). + - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). @@ -835,6 +842,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. + Escape while an arc, spiral or grid is dragged out cancels it and puts back the options its keys, Alt and Ctrl/Cmd changed (slope, open or closed, base axis, segments, decay, dividers and skews), so the next drag and the tool's options start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. @@ -1033,8 +1041,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. + The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. @@ -1044,7 +1054,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted. While no type is being edited, a press with Shift held makes new type with the horizontal or vertical counterpart of the Type, Area Type or Type on a Path tool chosen, and a press with Alt (Option) held makes it with Type on a Path in place of Area Type or the reverse, in the chosen tool's writing direction; with both keys held, both switch ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). The pointer shows the tool the press acts as, and the tool chosen stays selected. - The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. + The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `+`, `#` or `>` makes it a bulleted list with the filled dot, the hollow circle, the filled square or the dash (a VectorCraft choice: the page does not say which bullet each character gives). The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. PDF export writes bullets and numbers as text, in the glyphs and at the places the canvas draws them, in area type, type on a path, right-to-left paragraphs and threaded frames, and a PDF viewer selects, copies and finds them; a marker glyph that stands for no character is drawn as a path. The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting. In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. @@ -1170,6 +1180,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). + Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). + Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes. With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. @@ -1316,6 +1328,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). + - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). @@ -1378,6 +1391,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. + The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD (on Linux, of the process's cgroup memory limit where that is lower, and in a 32-bit build at most a quarter of the address space), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. @@ -1406,6 +1420,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. + With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing, and the edge of their joint bounds nearer a gridline lands on it. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. @@ -1459,7 +1474,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it. -- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000 or HEIC/HEIF file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. - **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Crystallize Cell Size 3..300 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. - **Color Halftone in raster pixels (#3260):** Max Radius is 4–127 px of the Document Raster Effects resolution, as public documentation gives it, shown in px whatever the document units; documents from before open with their radii in points converted (document version 3), and a file saved for an older app version holds them in points again. @@ -1836,12 +1851,10 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-31 | minor | Selection tools: with Alt held at the press, a Direct Selection marquee selects every path it touches whole and keeps the Group Selection pointer (#4890); a press on a selected object's centre point selects the object whole and a drag from it moves it (#4891); regression tests in `direct/tests.rs`; scores and estimates unchanged. | | 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): evidence added; scores unchanged. | | 2026-10-31 | minor | Guides and snapping: Snap to Perpendicular and Snap to Tangent apply to an existing line's end dragged with Direct Selection, as the public pages describe for editing existing lines (the end anchor of a straight segment, not mid-line anchors or curved segments); a regression test covers both snaps, both toggles and non-snapping controls (#2530); scores and estimates unchanged. | -| 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; evidence added; scores unchanged. | | 2026-10-31 | minor | UI chrome: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; evidence added; score and hours unchanged (estimated). | | 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores unchanged. | | 2026-10-30 | patch | Performance: opaque solid-fill knockout paths avoid redundant erase/add compositing in hard-edge frames, including supersampled raster export; analytical antialiasing keeps its existing rounding; scores and estimates unchanged | -| 2026-10-30 | minor | File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; evidence added; scores unchanged. | | 2026-10-31 | minor | Path Precision Tools add-on (a VectorCraft addition): live Path Effects; evidence added; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): text tools; evidence added; scores unchanged. | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | @@ -1932,14 +1945,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Method, Classic or Perceptual (Gradient panel menu, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | Dimension tool: in a CMYK document a dimension's lines, arrowheads and label are drawn in CMYK, black as K only (`0 0 0 1`) and other colours through the active colour settings, instead of an RGB black that printed as a four-ink rich black (#3233); RGB documents unchanged; `dimensions::dimension_colours_follow_the_document_colour_model`. Scores and hours unchanged. | -| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Dither (Gradient panel box, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). | -| 2026-10-22 | minor | #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged. | -| 2026-10-22 | minor | #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Plug-ins: filters keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own (G17); engine and unit regressions. Scores and hours unchanged. | | 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) | diff --git a/docs/type-parity.md b/docs/type-parity.md index 010c74aa5..69ea7c373 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -21,6 +21,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4efbee4aa..b7b2250c5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -39,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) | | 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color follows the hex and a double-click opens the Color Picker over the dialog, whose OK fills Color (#5364); scores and estimates unchanged | @@ -594,9 +595,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. | -| 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout; score unchanged (estimated) | | 2026-10-21 | minor | Panels dragged by the mouse join a dock group as tabs, split it top/bottom or become icons in the icon column; dock tabs close from their context menu; score unchanged (estimated) |