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/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/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 ae2ecde66..3f881b8e4 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -33,6 +33,7 @@ pub(crate) mod imagemap; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +mod heifread; mod jp2read; mod metafile; mod naming; @@ -203,6 +204,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/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 33ecfa8ab..ef87ea1b6 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 ae63ba918..03bfde85b 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); } @@ -726,6 +729,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 @@ -744,6 +749,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/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/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index de1b65755..0267725ef 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -156,6 +156,13 @@ pub const PREF_SPECS: &[PrefSpec] = &[ 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), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1e5147582..fc2530c03 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -67,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, @@ -378,6 +380,7 @@ impl Default for Prefs { 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, diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 7a17fe413..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(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 12d595263..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, 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/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 074756d84..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, diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 2893c06ab..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, 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/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/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 c37d6b868..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(); 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/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3ae7712f6..981975808 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2980,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 Инструмент „Селекция“ @@ -3115,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 Дясна единична кавичка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index aba170ea4..4fd69733d 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 @@ -5023,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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 370d9b677..f2a467fc5 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 @@ -3798,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ó @@ -6307,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 5fa8b5bd0..15cd33ed0 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 @@ -5085,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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 382df11e6..978a0971a 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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dfa4a3a33..e61e26c81 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3022,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 @@ -3159,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 @@ -4826,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2aa0a151f..bad5543ba 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1935,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 Μεταφορά στο παρασκήνιο @@ -3613,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 Μονό δεξί εισαγωγικό @@ -4766,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 δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1db3e9aab..651886e05 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3062,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 @@ -3199,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 @@ -4805,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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 0752a3fbb..f79d04cea 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 @@ -4987,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 755beef3e..b9c9c1165 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3054,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 @@ -3191,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 @@ -4813,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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b7cb6fcce..9f6c6807f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7961,3 +7961,7 @@ swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें + Marquee Selection मार्की चयन + Touching स्पर्श करते हुए + Fully Enclosed पूरी तरह घिरे हुए + Drag Direction खींचने की दिशा diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index eae9829b9..2de1183cc 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 @@ -3667,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 @@ -5329,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 433380d53..bffe798c5 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 46c0bc7e5..49f8130ae 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3098,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 @@ -3235,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 @@ -4847,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6df9879ea..94b2e408a 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 属性 @@ -4888,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 ファイルは読み込めません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b23ea6b0c..77b99070f 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 ატრიბუტები @@ -4504,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 ფაილების წაკითხვა ვერ ხერხდება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4a026cd0d..ca570d3cc 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 속성 @@ -4815,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 파일은 읽을 수 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3a0ffaf16..bfbdef06c 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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d858f8081..668a55ed7 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 @@ -4911,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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 49a4edc7c..73cf2c8ee 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2564,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 @@ -2682,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 @@ -4349,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e37f1db9d..9b462f1b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3093,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 Инструмент «Выделение» @@ -3230,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 Одинарная закрывающая кавычка @@ -4844,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 в этом цветовом режиме не читаются diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f0cb484b2..44dfd2563 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2986,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 @@ -3121,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 @@ -4722,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 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 73ba123e0..6ce1191c8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2890,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ı @@ -3024,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 @@ -4555,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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fe0edb479..81d168096 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3090,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 Інструмент «Виділення» @@ -3227,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 Одинарна лапка, що закриває @@ -4831,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 у цьому колірному режимі не читаються diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a567d484a..3b1169439 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 @@ -3418,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 @@ -4988,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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index be6b5fd2b..d9fa2f182 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2934,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 选择工具 @@ -3068,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 右单引号 @@ -4598,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 文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fd5d3df2b..6a34a843a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2454,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 檔案 @@ -3964,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 選取工具 @@ -4101,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 右單引號 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 f44359973..10a0015fe 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -477,6 +477,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 208abcfeb..5813da723 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -461,6 +461,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", @@ -596,7 +602,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/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index da1814b35..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()); 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 9357927f0..a799f22ff 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -328,7 +328,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); @@ -428,7 +429,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 dc6971cf5..a73d82c73 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. | @@ -899,15 +902,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 b1c23a94d..71e7978bd 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}` | 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). `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}` | 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). `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. | @@ -1691,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 @@ -4400,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 @@ -4715,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. 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 5b4039044..816df8d0f 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:** @@ -692,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`). @@ -1177,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. @@ -1323,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`). @@ -1468,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. @@ -1845,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 | @@ -1941,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/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) |