From d5f0fe3a3fa7f36ae682e833f04c46a0aeb62bb5 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:15:40 -0500 Subject: [PATCH 01/34] PDF import: glyphs in a span with an empty ActualText come in as outlines (#6971) --- crates/pdf/src/import/actual_text.rs | 14 +++++++++++++- crates/pdf/src/import/builder.rs | 5 +++-- crates/pdf/src/tests/actual_text.rs | 25 +++++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/import/actual_text.rs b/crates/pdf/src/import/actual_text.rs index d6d18a7566..6d7ddbaf9d 100644 --- a/crates/pdf/src/import/actual_text.rs +++ b/crates/pdf/src/import/actual_text.rs @@ -11,6 +11,9 @@ pub(super) struct ActualText { depth: usize, owner: Option, pending: Option, + /// The owner's replacement is empty: its content has no text, so its glyphs stay art + /// ([`Self::textless`], #6971). + empty: bool, } impl ActualText { @@ -22,6 +25,7 @@ impl ActualText { if self.owner == Some(self.depth) { self.owner = None; self.pending = None; + self.empty = false; } self.depth = self.depth.saturating_sub(1); } @@ -30,6 +34,12 @@ impl ActualText { self.owner.is_some() } + /// Inside a span whose replacement text is empty: the glyphs there are no text, but they + /// still draw, so they come in as outlines rather than as type with no characters (#6971). + pub(super) fn textless(&self) -> bool { + self.owner.is_some() && self.empty + } + pub(super) fn set(&mut self, bytes: &[u8]) -> bool { if self.active() { return true; @@ -39,8 +49,10 @@ impl ActualText { if self.depth == 0 || self.depth > 128 || bytes.len() > 64 * 1024 { return false; } + let text = crate::import_scan::text_string(bytes); self.owner = Some(self.depth); - self.pending = Some(crate::import_scan::text_string(bytes)); + self.empty = text.is_empty(); + self.pending = Some(text); true } } diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 6d47168716..21d862d2fd 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -800,7 +800,8 @@ impl<'p> Builder<'p> { } /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps - /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, + /// its outline (no Unicode text, mirrored, not type mode, or in a span whose ActualText is + /// empty). No `paint`: invisible text, /// type with neither fill nor stroke. pub(super) fn type_glyph( &mut self, @@ -811,7 +812,7 @@ impl<'p> Builder<'p> { stroke: Option<&StrokeProps>, overprint: bool, ) -> bool { - if self.text_as != TextAs::Text || self.nested > 0 { + if self.text_as != TextAs::Text || self.nested > 0 || self.actual.textless() { return false; } let authored = self.actual.active(); diff --git a/crates/pdf/src/tests/actual_text.rs b/crates/pdf/src/tests/actual_text.rs index 8e6bd4810b..7ff4599a57 100644 --- a/crates/pdf/src/tests/actual_text.rs +++ b/crates/pdf/src/tests/actual_text.rs @@ -121,6 +121,31 @@ fn actual_text_empty_and_nested_owners() { } } +/// Glyphs in a span whose ActualText is empty have no text, but they still draw: they stay on the +/// page as outlines in their paint, filled and stroked as the text rendering mode says (#6971). +#[test] +fn empty_actual_text_keeps_the_glyphs_art() { + let outlines = |report: &ImportReport| { + let mut found = vec![]; + report.document.walk(|node| { + if matches!(node.kind, NodeKind::Path { .. }) { + found.push((node.appearance.fill().is_some(), node.appearance.stroke().is_some(), node.geometric_bounds())); + } + }); + found + }; + for (mode, fill, stroke) in [("0", true, false), ("1", false, true), ("2", true, true)] { + let content = format!("/Span << /ActualText () >> BDC 1 0 0 rg 0 0 1 RG 3 w {mode} Tr (Halo) Tj 0 Tr EMC 0 0 0 rg (C) Tj"); + let report = open(&content); + assert_eq!(plain(&report), "C", "mode {mode}: the span adds no text"); + let found = outlines(&report); + assert!(found.iter().any(|(f, _, _)| *f == fill) && found.iter().any(|(_, s, _)| *s == stroke), "mode {mode}: the glyphs' art: {found:?}"); + let ink = found.iter().filter_map(|(_, _, b)| *b).fold(None, |acc: Option, b| Some(acc.map_or(b, |a| a.union(b)))); + let ink = ink.unwrap_or_else(|| panic!("mode {mode}: no outlines")); + assert!(ink.x0 >= 15.0 && ink.x1 > 40.0 && ink.y0 > 50.0 && ink.y1 < 140.0, "mode {mode}: where the word is set: {ink:?}"); + } +} + #[test] fn actual_text_decodes_pdf_strings_and_preserves_authored_order() { for (encoded, expected) in [ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 20cce4636a..05a709042c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines instead of being left out, #6971; previously minor: `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -308,7 +308,7 @@ Stop opacity on translated artboards remains outside this regression coverage. ## PDF editable imports -- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder. +- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Glyphs inside a span whose ActualText is empty add no text but still draw: they come in as outlines in their fill and stroke (#6971, `empty_actual_text_keeps_the_glyphs_art`). Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder. - [x] Authored ordering is protected per stretch; ordinary stretches still reconstruct visual-order text. Outlines mode is unchanged. - [ ] Exact redistribution of one replacement over several lines/styles, and general mixed-direction paragraph layout. Replacement uses the first placed glyph's line/style. Tags deeper than 128 and replacements over 64 KiB encoded (at most 192 KiB decoded) fall back to glyph mappings with a warning; an outer accepted owner survives deeper tags. @@ -322,6 +322,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` were left out of the page; they now come in as outlines in their fill and stroke, adding no text (#6971); scores and estimates unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | From 25c1a56755df8efc871484578ab49f384bf6f7d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:42:59 -0800 Subject: [PATCH 02/34] Patterns: canceling a new pattern leaves no swatch or paint of it Cancel on a pattern that Make created for the edit now removes every swatch of it, a color group's included, and turns every paint of it left in the document into no paint: art outside the editing layer, opacity mask art, type runs, symbol definitions, graph designs, other patterns' tiles (a copy Save a Copy made included), graphic and character styles, brush art, and the fill, stroke and appearance new art takes. Delete Pattern clears its paints in the same places, and a Pattern Options rename renames them there; both reached only the layers before. Delete's `unpainted` counts the document paints it cleared. Cancel after a Pattern Options rename during the edit gives the pattern its old name back with its swatches and paints, unless another pattern, swatch or color group has taken that name; before, the definition went back to the old name while its swatches and paints kept the new one. Undo and Redo restore new art's paints with the document, including the appearance inherited from the selection with New Art Has Basic Appearance off. A rename preview that is replaced or canceled starts again from the paints the interaction began with, and a canceled preview takes the selection's appearance again. --- crates/doc/src/swatches.rs | 137 ++++++++---- crates/engine/src/cmd/colormgmt.rs | 8 +- crates/engine/src/cmd/patterncmds.rs | 155 ++++++++------ crates/engine/src/cmd/swatch.rs | 70 +++--- crates/engine/src/history.rs | 3 +- crates/engine/src/lib.rs | 7 +- crates/engine/src/session/edit.rs | 10 +- crates/engine/src/tests/pattern.rs | 308 ++++++++++++++++++++++++++- docs/mcp.md | 11 + docs/target-app-parity.md | 1 + 10 files changed, 574 insertions(+), 136 deletions(-) diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 80db3d923a..e245e66156 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -5,7 +5,7 @@ //! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a //! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites //! those links (and colours) wherever art and swatches hold them, copying only the nodes that -//! change. +//! change; [`Document::map_paints`] replaces whole paints of any kind in the same places. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -124,6 +124,52 @@ impl Document { /// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are /// copied. Returns the number of changed paints. pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize { + let mut n = self.map_art_paints(&mut relink(f)); + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(item_paint_mut(it).map_links(f)); + } + } + for w in self.swatches_iter_mut() { + n += usize::from(w.paint.map_links(f)); + } + let mut g = relink(f); + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, &mut g)); + } + } + n + } + + /// Visit every paint in the places [`Document::map_solid_paints`] visits (fills, strokes, text + /// runs, image colorize paints and dynamic symbol instances' own paints in the art, opacity masks + /// included, symbol definitions, graph designs, pattern tiles and the tile that pattern + /// editing's Cancel puts back; graphic styles; the swatches; character styles' fill and + /// stroke), whatever its kind. `f(paint)` gives the paint to put in its place, or `None` to keep + /// it. Only nodes holding a changed paint (and the paths to them) are copied. Returns the number + /// of changed paints. + pub fn map_paints(&mut self, f: &mut PaintFn) -> usize { + let mut n = self.map_art_paints(f); + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(replace_paint(item_paint_mut(it), f)); + } + } + for w in self.swatches_iter_mut() { + n += usize::from(replace_paint(&mut w.paint, f)); + } + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, f)); + } + } + n + } + + /// [`Document::map_paints`] limited to the node trees: the art, symbol definitions, graph + /// designs, pattern tiles and the tile that pattern editing's Cancel puts back. + fn map_art_paints(&mut self, f: &mut PaintFn) -> usize { let mut n = map_trees(&mut self.layers, f); for s in &mut self.symbols { n += map_tree(&mut s.art, f); @@ -134,19 +180,6 @@ impl Document { for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) { n += map_trees(&mut p.art, f); } - for gs in &mut self.graphic_styles { - for it in &mut gs.appearance.items { - n += usize::from(item_paint_mut(it).map_links(f)); - } - } - for w in self.swatches_iter_mut() { - n += usize::from(w.paint.map_links(f)); - } - for def in &mut self.char_styles { - for key in ["fill", "stroke"] { - n += usize::from(map_style_paint(&mut def.attrs, key, f)); - } - } n } @@ -156,9 +189,10 @@ impl Document { let roots: Vec = ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect(); let mut n = 0; + let mut g = relink(f); for id in roots { let Some(node) = self.node(id) else { continue }; - if let Some((new, count)) = map_node(node, f) + if let Some((new, count)) = map_node(node, &mut g) && let Some(slot) = self.node_mut(id) { *slot = new; @@ -258,14 +292,39 @@ impl FreeSwatchNames { /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; -/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`] -/// does, and store the result in the attribute. True when it changed. -fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut LinkFn) -> bool { - let Some(value) = attrs.get_mut(key) else { return false }; - let Ok(mut paint) = serde_json::from_value::(value.clone()) else { return false }; - if !paint.map_links(f) { - return false; +/// What [`Document::map_paints`] applies to each paint: the paint to put in its place, or `None` +/// to keep it. +pub type PaintFn<'a> = dyn FnMut(&Paint) -> Option + 'a; + +/// `f` as a [`PaintFn`]: a solid or gradient paint with `f` applied ([`Paint::map_links`]) when +/// `f` changed it, `None` for every other paint. +fn relink<'a>(f: &'a mut LinkFn<'_>) -> impl FnMut(&Paint) -> Option + 'a { + move |p| match p { + Paint::Solid { .. } | Paint::Gradient(_) => { + let mut q = p.clone(); + q.map_links(f).then_some(q) + } + _ => None, } +} + +/// Put `f(p)` in place of `p` when it gives a paint. True when it did. +fn replace_paint(p: &mut Paint, f: &mut PaintFn) -> bool { + match f(p) { + Some(q) => { + *p = q; + true + } + None => false, + } +} + +/// Apply `f` to the paint a character style's attribute `key` holds and store the result in the +/// attribute. An attribute that is not a paint stays as it is. True when it changed. +fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut PaintFn) -> bool { + let Some(value) = attrs.get_mut(key) else { return false }; + let Ok(paint) = serde_json::from_value::(value.clone()) else { return false }; + let Some(paint) = f(&paint) else { return false }; match serde_json::to_value(&paint) { Ok(changed) => { *value = changed; @@ -333,20 +392,11 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator { n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize) } -/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then -/// nothing was copied). Also returns the number of changed paints. -fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { - // Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never - // cloned. - let mut changes: Vec<(usize, Paint)> = vec![]; - for (i, p) in node_paints(n).enumerate() { - if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) { - let mut q = p.clone(); - if q.map_links(f) { - changes.push((i, q)); - } - } - } +/// `n` with `f` applied to its subtree's paints, or `None` when nothing changed (then nothing was +/// copied). Also returns the number of changed paints. +fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> { + // `f` gives each own paint's replacement first, so unchanged nodes are never cloned. + let changes: Vec<(usize, Paint)> = node_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); let mut count = changes.len(); let mut out: Option = None; if !changes.is_empty() { @@ -366,9 +416,7 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { let mut overrides = n.extras().symbol_overrides.clone(); let mut k = 0; for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { - if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) { - k += 1; - } + k += usize::from(replace_paint(p, f)); } if k > 0 { out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides; @@ -415,6 +463,13 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { /// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only /// the nodes holding a changed paint are copied. Returns the number of changed paints. pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { + map_node_paints(n, &mut relink(f)) +} + +/// [`Document::map_paints`] for art a document keeps outside its trees, such as the art of a brush: +/// `f` applied to every paint of `n` and its subtree (opacity masks included). Only the nodes +/// holding a changed paint are copied. Returns the number of changed paints. +pub fn map_node_paints(n: &mut Node, f: &mut PaintFn) -> usize { match map_node(n, f) { Some((new, count)) => { *n = new; @@ -424,7 +479,7 @@ pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { } } -fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { +fn map_tree(n: &mut Arc, f: &mut PaintFn) -> usize { match map_node(n, f) { Some((new, count)) => { *n = Arc::new(new); @@ -434,7 +489,7 @@ fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { } } -fn map_trees(v: &mut [Arc], f: &mut LinkFn) -> usize { +fn map_trees(v: &mut [Arc], f: &mut PaintFn) -> usize { v.iter_mut().map(|n| map_tree(n, f)).sum() } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 18d4e26704..16990fc2ea 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -397,7 +397,9 @@ pub(crate) fn commit_new_art_colors(s: &mut Session) -> Result<()> { Ok(()) } -fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> { +/// Record new-art paints `before` and the session's current ones with the step just made (or the +/// interaction or undo group in progress), so Undo puts `before` back and Redo the current ones. +pub(super) fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> { let after = s.paint.clone(); if before != after || s.batch_stash.is_some() || s.doc()?.undo_group.is_some() { let st = s.doc_mut()?; @@ -422,7 +424,9 @@ fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaul Ok(()) } -fn new_art_colors_before(s: &Session) -> Result { +/// The new-art paints an undo step that starts now puts back: those at the start of the undo group +/// or interaction in progress, else the session's current ones. +pub(super) fn new_art_colors_before(s: &Session) -> Result { let st = s.doc()?; Ok(st.undo_group.as_ref().map(|g| &g.paint).or_else(|| st.interaction.as_ref().map(|it| &it.paint)).cloned().unwrap_or_else(|| s.paint.clone())) } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 4075676ca6..dbf726f528 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint}; +use vectorcraft_doc::swatches::PaintFn; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Point, Rect}; @@ -54,7 +55,15 @@ pub fn specs() -> Vec { in_pattern_edit, pattern_done ), - cmd!("object.pattern.cancel", "Cancel", [], None, "{} leave pattern editing mode, discarding changes", in_pattern_edit, pattern_cancel), + cmd!( + "object.pattern.cancel", + "Cancel", + [], + None, + "{} leave pattern editing mode, discarding changes; a pattern made for this edit is removed with its swatches, and every paint of it, new art's fill and stroke included, becomes none", + in_pattern_edit, + pattern_cancel + ), cmd!( "object.pattern.saveCopy", "Save a Copy", @@ -78,7 +87,7 @@ pub fn specs() -> Vec { "Delete Pattern", [], None, - "{name} delete a pattern swatch (objects painted with it lose that paint)", + "{name} delete a pattern and its swatches; every paint of it, new art's fill and stroke included, becomes none → {unpainted: the number of document paints cleared}", has_doc, pattern_delete ), @@ -403,26 +412,96 @@ pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result Result { - s.edit("Cancel Pattern Editing", |d, sel| { + let changed = s.edit("Cancel Pattern Editing", |d, sel| { let (pe, _) = leave_edit(d, sel)?; match pe.original { - Some(orig) => { + Some(mut orig) => { + if orig.name != pe.pattern && (d.pattern(&orig.name).is_some() || swatch_name_clash(d, &pe.pattern, &orig.name)) { + orig.name = pe.pattern.clone(); + } + let back = (orig.name != pe.pattern).then(|| orig.name.clone()); if let Some(def) = d.pattern_mut(&pe.pattern) { *def = orig; } + if let Some(old) = &back { + rename_pattern_uses(d, &pe.pattern, old); + } + Ok(back.map(|old| (pe.pattern, Some(old)))) } None => { - d.patterns.retain(|p| p.name != pe.pattern); - d.swatches.retain(|sw| !matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == pe.pattern)); + forget_pattern(d, &pe.pattern); + Ok(Some((pe.pattern, None))) } } - Ok(()) })?; after_edit_mode(s, None)?; + if let Some((name, to)) = changed { + map_new_art_paints(s, &mut pattern_paints(&name, to.as_deref()))?; + } ok() } +/// A [`PaintFn`] that puts a paint of pattern `to` in place of each paint of pattern `name`, with +/// its placement kept, or no paint for `to` `None`. +fn pattern_paints<'a>(name: &'a str, to: Option<&'a str>) -> impl FnMut(&Paint) -> Option + 'a { + move |p| match p { + Paint::Pattern { pattern, xf } if pattern == name => Some(match to { + Some(n) => Paint::Pattern { pattern: n.to_string(), xf: *xf }, + None => Paint::None, + }), + _ => None, + } +} + +/// Remove pattern `name` from `d`: its definition and every swatch, grouped or not, whose paint is +/// the pattern. Every paint of the pattern left anywhere in the document (the art, opacity masks, +/// type, symbol definitions, graph designs, other patterns' tiles, graphic and character styles and +/// the art of brushes) becomes no paint. Returns the number of such paints. +fn forget_pattern(d: &mut Document, name: &str) -> usize { + d.patterns.retain(|p| p.name != name); + let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if pattern == name); + d.swatches.retain(|sw| !is_it(sw)); + for g in &mut d.swatch_groups { + g.swatches.retain(|sw| !is_it(sw)); + } + super::swatch::map_all_paints(d, &mut pattern_paints(name, None)) +} + +/// Apply `f` to the fill and stroke new art takes and to the paints of its template appearance. A +/// change is recorded with the step just made, so Undo and Redo put these paints back with the +/// document. +fn map_new_art_paints(s: &mut Session, f: &mut PaintFn) -> Result<()> { + let start = super::colormgmt::new_art_colors_before(s)?; + let old = s.paint.clone(); + let pd = &mut s.paint; + let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, + vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, + }); + for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) { + if let Some(q) = f(p) { + *p = q; + } + } + if s.paint != old { + super::colormgmt::record_new_art_colors(s, start)?; + // The step takes the paints the command finally leaves, after the selection's appearance + // is inherited. + let st = s.doc()?; + if st.interaction.is_none() { + let uid = st.uid; + s.batch_paint_docs.push(uid); + } + } + Ok(()) +} + fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Save a Copy", |d, _| { @@ -524,6 +603,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + if let Some(nn) = &new_name { + map_new_art_paints(s, &mut pattern_paints(&name, Some(nn)))?; + } Ok(json!({ "name": out_name })) } @@ -552,47 +634,19 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool { } /// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the -/// pattern's own take the new name. +/// pattern's own take the new name. The paints are renamed wherever [`forget_pattern`] clears them. fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) { - let fix = |p: &mut Paint| { - if let Paint::Pattern { pattern, .. } = p - && pattern == old - { - *pattern = new.to_string(); - } - }; - for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) { + for sw in d.swatches_iter_mut() { if sw.name == old && pattern_name(&sw.paint).is_some_and(|p| p == old) { sw.name = new.to_string(); } - fix(&mut sw.paint); } if let Some(pe) = &mut d.pattern_edit && pe.pattern == old { pe.pattern = new.to_string(); } - let ids = nodes_using(d, Some(old)); - for id in ids { - if let Some(n) = d.node_mut(id) { - for it in n.appearance.items.iter_mut() { - match it { - vectorcraft_doc::AppearanceItem::Fill(f) => fix(&mut f.paint), - vectorcraft_doc::AppearanceItem::Stroke(s) => fix(&mut s.paint), - } - } - } - } -} - -fn nodes_using(d: &Document, name: Option<&str>) -> Vec { - let mut ids = vec![]; - d.walk(|n| { - if pattern::uses_pattern(n, name) { - ids.push(n.id); - } - }); - ids + super::swatch::map_all_paints(d, &mut pattern_paints(old, Some(new))); } fn pattern_delete(s: &mut Session, p: &Value) -> Result { @@ -604,29 +658,8 @@ fn pattern_delete(s: &mut Session, p: &Value) -> Result { if editing(s).is_some_and(|e| e.pattern == name) { return Err(bad(C, "finish editing the pattern first")); } - let n = s.edit("Delete Pattern", |d, _| { - d.patterns.retain(|p| p.name != name); - let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == name); - d.swatches.retain(|sw| !is_it(sw)); - for g in &mut d.swatch_groups { - g.swatches.retain(|sw| !is_it(sw)); - } - let ids = nodes_using(d, Some(&name)); - for id in &ids { - if let Some(n) = d.node_mut(*id) { - for it in n.appearance.items.iter_mut() { - let p = match it { - vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, - }; - if matches!(p, Paint::Pattern { pattern, .. } if *pattern == name) { - *p = Paint::None; - } - } - } - } - Ok(ids.len()) - })?; + let n = s.edit("Delete Pattern", |d, _| Ok(forget_pattern(d, &name)))?; + map_new_art_paints(s, &mut pattern_paints(&name, None))?; Ok(json!({ "unpainted": n })) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 751313957f..748bcb4df2 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; -use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors}; +use vectorcraft_doc::swatches::{LinkFn, PaintFn, color_name, map_node_paints, map_node_solid_paints, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; @@ -681,32 +681,16 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) { /// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does /// not read are kept at every level. pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { - use vectorcraft_brush::{Brush, BrushKind, Colorization}; - let mut n = d.map_solid_paints(f); - // Each stored entry, its brush when it parses, the brush as written before the edit (only - // when its paint changed), and whether its paint changed. - let mut lib: Vec<(Value, Option, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) { - Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(), - None => { - vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect() - } - }; - let mut in_brushes = 0; - for (_, b, base, changed) in lib.iter_mut() { - let Some(b) = b else { continue }; - let before_edit = serde_json::to_value(&*b).unwrap_or_default(); + use vectorcraft_brush::{BrushKind, Colorization}; + let n = d.map_solid_paints(f); + n + edit_brush_library(d, &mut |b| { let key = match b.kind.colorization() { Colorization::HueShift { key } => Some(key), _ => None, }; let before = key.map(|_| art_paint_colors(b.art())); - let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::(); - *changed = in_brush > 0; - if *changed { - *base = before_edit; - } - in_brushes += in_brush; - let (Some(key), Some(before)) = (key, before) else { continue }; + let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, &mut *f)).sum::(); + let (Some(key), Some(before)) = (key, before) else { return in_brush }; // Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them. let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex(); let after = art_paint_colors(b.art()); @@ -719,10 +703,47 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { BrushKind::Art(a) => &mut a.colorization, BrushKind::Scatter(s) => &mut s.colorization, BrushKind::Pattern(p) => &mut p.colorization, - BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue, + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return in_brush, }; *c = Colorization::HueShift { key }; } + in_brush + }) +} + +/// Apply a paint rewrite ([`Document::map_paints`]) to the document and to the art of the brushes +/// in its library ([`edit_brush_library`]). Returns the number of changed paints. +pub(super) fn map_all_paints(d: &mut Document, f: &mut PaintFn) -> usize { + let n = d.map_paints(f); + n + edit_brush_library(d, &mut |b| b.art_mut().into_iter().map(|art| map_node_paints(art, &mut *f)).sum()) +} + +/// Apply `edit` to each brush of the document's library, the stored entries or, with none stored, +/// the built-in library; `edit` returns how many paints of the brush's art it changed. The library +/// is stored again only when a brush's paint changed. Stored entries are stored again as they were +/// read, except that a brush whose paint changed is merged into its entry ([`merge_stored`]), so +/// fields the brush definition does not read are kept at every level. Returns the number of +/// changed paints. +fn edit_brush_library(d: &mut Document, edit: &mut dyn FnMut(&mut vectorcraft_brush::Brush) -> usize) -> usize { + use vectorcraft_brush::Brush; + // Each stored entry, its brush when it parses, the brush as written before the edit (only + // when its paint changed), and whether its paint changed. + let mut lib: Vec<(Value, Option, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) { + Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(), + None => { + vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect() + } + }; + let mut in_brushes = 0; + for (_, b, base, changed) in lib.iter_mut() { + let Some(b) = b else { continue }; + let before_edit = serde_json::to_value(&*b).unwrap_or_default(); + let in_brush = edit(b); + *changed = in_brush > 0; + if *changed { + *base = before_edit; + } + in_brushes += in_brush; } if in_brushes > 0 { let lib = lib @@ -733,9 +754,8 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { }) .collect(); d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib)); - n += in_brushes; } - n + in_brushes } /// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index cc545b179f..3ab6a7dcb8 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -27,7 +27,8 @@ pub struct HistoryEntry { pub(crate) named: Option, /// A number no other step takes ([`History::step`]). serial: u64, - /// Original and converted new-art colors, only for steps that change document color mode. + /// Original and changed new-art paints, only for steps that change them: a document color mode + /// change, and a pattern Cancel, Delete or rename that clears or renames a pattern paint. pub(crate) color_mode_paints: Option<(vectorcraft_tools::PaintDefaults, vectorcraft_tools::PaintDefaults)>, /// Owned new-art snapshots included in both directional estimates. color_mode_bytes: usize, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a891fb9599..2011b32419 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -216,9 +216,10 @@ pub struct Session { /// groups: for each step, the documents ([`DocState::uid`]) whose undo groups it belongs to, and /// the index of the document it left active when it changed the active document. pub(crate) batch_steps: Option, Option)>>, - /// After `command.batch` kept a color-mode step's new-art colors, until the top-level command - /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest - /// history step takes the colors the batch finally left. + /// After `command.batch` kept a color-mode step's new-art colors, or a pattern command recorded + /// the new-art paints it changed, until the top-level command has inherited the selection's + /// appearance: the documents ([`DocState::uid`]) whose newest history step takes the colors the + /// command finally left. pub(crate) batch_paint_docs: Vec, /// The composite fonts (lowercase names) that the open documents defined when the font /// database was last brought in step with them ([`cmd::compositefonts::sync`]). diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 61a4cc8a01..b1db85f802 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -288,7 +288,10 @@ impl Session { st.revision += 1; } st.doc = it.doc.clone(); - let restore_paint = cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch"); + // A batch, or a preview that changed the new-art paints (a pattern rename), started from + // the paints the interaction began with. + let restore_paint = + cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some(); let paint = it.paint.clone(); it.preview_paint = None; it.color_mode_paints = None; @@ -421,7 +424,7 @@ impl Session { if let Some(paint) = self .active() .and_then(|st| st.interaction.as_ref()) - .filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch")) + .filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some()) .map(|it| it.paint.clone()) { self.paint = paint; @@ -431,6 +434,9 @@ impl Session { st.undo_interaction(); // Guides locked and slices locked or hidden during the interaction stay deselected. deselect_hidden(&menu, &st.doc, &mut st.selection); + // With New Art Has Basic Appearance off, new art takes the appearance of the selection as + // the document has it again, not as a preview left it. + self.inherit_new_art(); Ok(()) } diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 0b948242db..c03284f60f 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; +use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType, pattern_paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -212,6 +212,186 @@ fn cancel_new_pattern_removes_it() { assert_eq!(doc(&s).layers.len(), 1); } +fn doc_mut(s: &mut Session) -> &mut vectorcraft_doc::Document { + Arc::make_mut(&mut s.doc_mut().unwrap().doc) +} + +fn is_pattern(p: &Paint, name: &str) -> bool { + matches!(p, Paint::Pattern { pattern, .. } if pattern == name) +} + +/// How many paints of pattern `name` the document holds: in the art and the definitions +/// ([`vectorcraft_doc::Document::visit_paints`]), the swatches, the character styles and the +/// stored brush library. +fn pattern_uses(d: &vectorcraft_doc::Document, name: &str) -> usize { + let mut n = 0; + d.visit_paints(&mut |p| n += usize::from(is_pattern(p, name))); + n += d.swatches_iter().filter(|w| is_pattern(&w.paint, name)).count(); + let styled = d.char_styles.iter().flat_map(|c| ["fill", "stroke"].map(|k| c.attrs.get(k).cloned())).flatten(); + n += styled.filter(|v| serde_json::from_value::(v.clone()).is_ok_and(|p| is_pattern(&p, name))).count(); + let needle = format!("\"pattern\":\"{name}\""); + n + d.unknown.get(vectorcraft_brush::DOC_KEY).map_or(0, |v| v.to_string().matches(&needle).count()) +} + +/// Canceling a new pattern removes every swatch of it, a color group's included, and the fills and +/// strokes of it on art outside the editing layer become no paint. The object's other paints, and +/// a swatch that only shares the pattern's name, stay. +#[test] +fn cancel_new_pattern_forgets_group_swatch_and_outside_fills_and_strokes() { + let mut s = session(); + let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"ids": [other.0], "color": "#00ff00"})).unwrap(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap(); + let d = doc_mut(&mut s); + let solid = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + d.swatch_groups.push(vectorcraft_color::SwatchGroup { + name: "Mine".into(), + swatches: vec![ + vectorcraft_color::Swatch { name: "Grouped".into(), paint: pattern_paint("Tiles"), global: false, spot: false }, + vectorcraft_color::Swatch { name: "Tiles".into(), paint: solid.clone(), global: false, spot: false }, + ], + }); + assert!(pattern_uses(doc(&s), "Tiles") >= 3); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Tiles").is_none()); + assert_eq!(pattern_uses(d, "Tiles"), 0); + assert!(d.swatch("Grouped").is_none(), "the group's pattern swatch goes"); + assert_eq!(d.swatch("Tiles").map(|w| &w.paint), Some(&solid), "a color swatch named like the pattern stays"); + let n = node(&s, other); + assert!(n.appearance.fill_paint().is_none()); + assert!(matches!(n.appearance.stroke_paint(), Paint::Solid { .. }), "the stroke stays"); +} + +/// Save a Copy keeps the copy as its own pattern when the new pattern is canceled, and the copy's +/// tile art loses its paints of the canceled pattern. +#[test] +fn cancel_new_pattern_forgets_its_paints_in_a_saved_copy() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + let layer = doc(&s).pattern_edit.as_ref().unwrap().layer; + let tile = doc(&s).node(layer).unwrap().children().unwrap()[0].id; + s.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Tiles"})).unwrap(); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Tiles copy"); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + let copy = d.pattern("Tiles copy").expect("the copy stays"); + assert_eq!(copy.art.len(), 1); + assert!(copy.art[0].appearance.fill_paint().is_none()); + assert_eq!(pattern_uses(d, "Tiles"), 0); +} + +/// Canceling a new pattern also clears its paints in type, opacity mask art, symbol definitions, +/// graph designs, graphic and character styles and brush art, and in the fill, stroke and +/// appearance new art takes, so new art does not take a paint of a pattern that is gone. +#[test] +fn cancel_new_pattern_forgets_hidden_paints_and_new_art_defaults() { + let mut s = session(); + let masked = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let m = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, m.0]})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap()); + let band = rect(&mut s, 200.0, 200.0, 40.0, 10.0); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [band.0], "swatch": "Tiles"})).unwrap(); + s.execute("brush.new", &json!({"type": "art", "name": "Band", "ids": [band.0]})).unwrap(); + let p = pattern_paint("Tiles"); + let d = doc_mut(&mut s); + let art = d.node(band).unwrap().clone(); + d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false }); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Bar".into(), art: Arc::new(art.clone()) }); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle { + name: "Style".into(), + appearance: (*art.appearance).clone(), + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + unit_box: true, + }); + let attrs = json!({"fill": p, "stroke": p, "size": 12}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() }); + if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) { + text.runs[0].style.fill = p.clone(); + } + let mask = d.node_mut(masked).and_then(|n| n.extras_if_any()).and_then(|e| e.mask.as_deref_mut()).expect("the mask"); + Arc::make_mut(&mut mask.art).appearance = vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0).into(); + s.paint.fill = p.clone(); + s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(Paint::None, p.clone(), 1.0)); + assert!(pattern_uses(doc(&s), "Tiles") >= 10, "{}", pattern_uses(doc(&s), "Tiles")); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert_eq!(pattern_uses(doc(&s), "Tiles"), 0); + assert!(doc(&s).char_styles[0].attrs["size"] == json!(12), "the style's other attributes stay"); + assert!(s.paint.fill.is_none()); + assert!(s.paint.appearance.as_ref().unwrap().items.iter().all(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => !is_pattern(&f.paint, "Tiles"), + vectorcraft_doc::AppearanceItem::Stroke(k) => !is_pattern(&k.paint, "Tiles"), + })); + let fresh = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let n = node(&s, fresh); + assert!(!is_pattern(&n.appearance.fill_paint(), "Tiles") && !is_pattern(&n.appearance.stroke_paint(), "Tiles")); +} + +/// Cancel is one undo step: Undo gives the pattern, its paints and the fill new art takes back, and +/// Redo clears them again. +#[test] +fn cancel_new_pattern_is_one_undo_step() { + let mut s = session(); + let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap(); + s.paint.fill = pattern_paint("Tiles"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Tiles")); + assert!(doc(&s).pattern("Tiles").is_some() && doc(&s).pattern_edit.is_some()); + assert!(is_pattern(&node(&s, other).appearance.fill_paint(), "Tiles")); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none()); + assert_eq!(pattern_uses(doc(&s), "Tiles"), 0); +} + +/// Canceling the edit of a pattern that Pattern Options renamed while it was edited gives the +/// pattern its old name back, with its swatch and the paints of it, so nothing is left painted +/// with a name no pattern has. +#[test] +fn cancel_after_renaming_an_edited_pattern_restores_its_name_and_paints() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap(); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots")); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_none()); + assert!(d.swatch("Dots").is_some_and(|w| is_pattern(&w.paint, "Dots"))); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Dots")); + assert_eq!(pattern_uses(d, "Spots"), 0); + // When Save a Copy has taken the old name since, the pattern keeps its new name and paints. + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap(); + s.execute("object.pattern.saveCopy", &json!({"name": "Dots"})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_some()); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots")); +} + #[test] fn edit_then_cancel_restores_definition() { let mut s = session(); @@ -381,6 +561,132 @@ fn delete_pattern_unpaints_objects() { assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err()); } +/// Puts paints of pattern `name` where the layers do not reach: a symbol definition, another +/// pattern's tile, a graphic style, a character style, a type run, a color group's swatch and the +/// fill new art takes. +fn paint_outside_the_layers(s: &mut Session, name: &str) { + let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap()); + let p = pattern_paint(name); + let d = doc_mut(s); + let shape = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)); + let art = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false }); + d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Other", vec![Arc::new(art.clone())])); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle { + name: "Style".into(), + appearance: (*art.appearance).clone(), + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + unit_box: true, + }); + let attrs = json!({"fill": p}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() }); + if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) { + text.runs[0].style.fill = p.clone(); + } + d.swatch_groups.push(vectorcraft_color::SwatchGroup { + name: "Mine".into(), + swatches: vec![vectorcraft_color::Swatch { name: "Grouped".into(), paint: p.clone(), global: false, spot: false }], + }); + s.paint.fill = p; +} + +/// Delete Pattern clears the pattern's paints outside the layers too, and in the fill new art takes. +#[test] +fn delete_pattern_forgets_paints_outside_the_layers() { + let mut s = session(); + make_dots(&mut s); + paint_outside_the_layers(&mut s, "Dots"); + assert_eq!(pattern_uses(doc(&s), "Dots"), 7); + let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + assert_eq!(v["unpainted"], 5, "the symbol, the other tile, the graphic style, the character style and the type"); + assert_eq!(pattern_uses(doc(&s), "Dots"), 0); + assert!(doc(&s).swatch("Grouped").is_none() && doc(&s).pattern("Other").is_some()); + assert!(s.paint.fill.is_none()); +} + +/// A Pattern Options rename renames the pattern's paints outside the layers too, and in the fill +/// new art takes. +#[test] +fn rename_pattern_reaches_paints_outside_the_layers() { + let mut s = session(); + make_dots(&mut s); + paint_outside_the_layers(&mut s, "Dots"); + s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert_eq!(pattern_uses(doc(&s), "Dots"), 0); + assert_eq!(pattern_uses(doc(&s), "Spots"), 7); + assert!(is_pattern(&s.paint.fill, "Spots")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots"), "Undo gives the fill its old name back"); +} + +/// With New Art Has Basic Appearance off, Redo of Delete Pattern gives new art the paints the +/// command left, the selected object's appearance taken after the pattern's fill was cleared. +#[test] +fn redo_of_delete_pattern_keeps_the_inherited_new_art_paints() { + let mut s = session(); + let r = make_dots(&mut s); + s.prefs.new_art_basic = false; + sel(&mut s, &[r]); + s.paint.fill = pattern_paint("Dots"); + s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + let after = s.paint.clone(); + assert!(!is_pattern(&after.fill, "Dots")); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.paint, after); +} + +/// A Pattern Options rename tried as a preview changes the fill new art takes only while the +/// preview stands: another preview and Cancel start from the fill the interaction began with. +/// Keeping the preview keeps the rename, and Undo takes it back. +#[test] +fn rename_previews_restore_the_fill_new_art_takes() { + let mut s = session(); + make_dots(&mut s); + s.paint.fill = pattern_paint("Dots"); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Spots")); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Tiles"})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Tiles")); + s.cancel_interaction().unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some()); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + s.commit_interaction().unwrap(); + assert!(is_pattern(&s.paint.fill, "Spots") && doc(&s).pattern("Spots").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some()); +} + +/// With New Art Has Basic Appearance off, a canceled rename preview leaves new art with the +/// appearance of the selected object as the document has it again, so no paint of the previewed +/// name is left. +#[test] +fn canceled_rename_preview_leaves_no_inherited_paint_of_the_new_name() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.prefs.new_art_basic = false; + sel(&mut s, &[big]); + s.paint = vectorcraft_tools::PaintDefaults::default(); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + s.cancel_interaction().unwrap(); + assert!(doc(&s).pattern("Dots").is_some() && doc(&s).pattern("Spots").is_none()); + let items = s.paint.appearance.iter().flat_map(|a| a.items.iter()).map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(), + vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(), + }); + let paints: Vec = [s.paint.fill.clone(), s.paint.stroke.clone()].into_iter().chain(items).collect(); + assert!(paints.iter().all(|p| !is_pattern(p, "Spots")), "{paints:?}"); +} + #[test] fn transform_patterns_only_moves_placement() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index bf159f5f00..0280f5449b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4092,6 +4092,17 @@ the old name. A rename gives the new name to the pattern's swatches that had the old name keeps it. A blank name gives "New Pattern N" in `object.pattern.make` and "`` copy" in `object.pattern.saveCopy`, and `pattern.options` keeps the name. +`object.pattern.cancel {}` leaves pattern editing mode and discards the changes. For a pattern that `object.pattern.make` +made for the edit, it removes the pattern and every swatch of it, a color group's included. Every other paint of the +pattern becomes no paint: on art outside the editing layer, in opacity mask art, type runs, symbol definitions, graph +designs, other patterns' tiles, graphic and character styles and brush art, and in the fill, stroke and appearance new +art takes. A pattern that `object.pattern.saveCopy` saved during the edit stays, with those paints cleared in its tile. +For an existing pattern, Cancel puts the definition back as it was when editing started; when `pattern.options` renamed +it during the edit, the old name comes back with its swatches and paints, unless another pattern, swatch or color group +has taken that name. `pattern.delete {name}` clears the paints of the pattern in the same places and returns +`{unpainted}`, the number of document paints it cleared. A `pattern.options` rename renames the paints in the same +places. Undo and Redo restore the fill, stroke and appearance new art takes with the document. + The pattern commands (`object.pattern.make`, `object.pattern.edit`, `object.pattern.saveCopy`, `pattern.options`, `pattern.delete`) and the Repeat commands (`object.repeat.radial`, `object.repeat.grid`, `object.repeat.mirror`, `object.repeat.options`) reject a param of the wrong type, such as `"instances": "8"`, `"edit": "false"` or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..06ff166035 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -704,6 +704,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). +- **Cancel, Delete and rename leave no paint of a missing pattern:** the help page lists Save a Copy, Done and Cancel for leaving pattern editing (https://helpx.adobe.com/illustrator/using/create-edit-patterns.html). Canceling a pattern made for the edit removes it with every swatch of it, a color group's included. Canceling, `pattern.delete` and a `pattern.options` rename reach every paint of the pattern: art outside the editing layer, opacity mask art, type runs, symbol definitions, graph designs, other patterns' tiles (a copy Save a Copy made included), graphic and character styles, brush art, and the fill, stroke and appearance new art takes. Cancel and Delete make those paints no paint, a VectorCraft rule; a rename renames them. Cancel after a rename in Pattern Options gives the pattern its old name back unless another pattern or swatch has taken it. Undo and Redo restore new art's paints with the document. Tests in `tests/pattern.rs` cover each command. - **A clip group in Properties:** with one clip group selected, the panel has Edit Clipping Path and Edit Contents at its top, under the Clip Group heading, which select the clipping path or the clipped art for the selection tools to transform alone, as Object › Clipping Mask does (: with a clip group selected, the tutorial uses Edit Clipping Path at the top of the Properties panel to select the mask for editing, and uses Edit Contents to select the masked art). Edit Contents shows only when the group clips something. Not checked against the reference app: whether Properties keeps the buttons while the clipping path or the contents is selected; the Control bar has no clip-group buttons here. Evidence: `panels::properties::tests::one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top`. - **Contextual controls in Properties:** the [Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html) lists controls that appear for particular selections and tools; Properties has them, each running the existing command. Quick Actions has Blend Options for one selected blend, with the Blend tool or any other, as the [blending page](https://helpx.adobe.com/illustrator/using/blending-objects.html) describes (#4817) (the Blend Options dialog, OK one undo step) and Merge Live Paint when a Live Paint group is selected with more paths and nothing Live Paint refuses (`livePaint.merge`). Appearance has the stroke's Variable Width Profile list, the Control bar's, with the Width tool or when the stroke has a variable width (`stroke.set {profile}`), and beside fx the Delete icon: with one effect on the one selected object (its own or a fill's or stroke's) its name and an icon that removes it (`effect.remove`), with several an icon that opens the Appearance panel, as the overview describes. With Direct Selection and corners of one path selected, Corner and Corner Radius set the kind and radius of those corners (`object.setLiveShape`); a live rectangle's and polygon's own rows already do. Each edit is one undo step. The overview's Variable Font icon was already in Character. Not checked against the reference app: whether Merge needs two Live Paint groups (the overview speaks of several selected groups; `livePaint.merge` takes a group and paths), and where exactly each control sits in its section. Evidence: `panels::properties::tests::{blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool, merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more, the_width_profile_shows_for_a_variable_width_or_the_width_tool, direct_selected_corners_show_their_kind_and_radius, one_effect_has_a_delete_icon_in_appearance}`. - **Edit Artboards in Properties:** a Transform section comes first (#5219): the reference point the Transform panel and Control bar share, the active artboard's X and Y (of that point) and W and H with the Constrain Width and Height Proportions link, its preset and orientation, and Move and Scale Artwork with Artboard. The Artboards section follows, with the name field, Artboard Options and Fill. The active artboard's name is a field. Typing a new name and pressing Enter or clicking elsewhere renames that artboard in one undo step, as the Artboards panel's rename does (`artboard.setProps {index, name}`); pressing Escape or leaving the field empty keeps the old name. A click on the Artboard Options icon beside the field opens Artboard Options for the active artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). Pointer and key tests in `panels/properties.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html). From 86a997d9282fc16b1a969d8a0c4fd60dd8dfebaf Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:44:48 -0500 Subject: [PATCH 03/34] PDF import (#6971): parity and roadmap entries --- docs/roadmap.md | 5 +++-- docs/target-app-parity.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c78d258c9..ac99ebe26f 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-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -148,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2f536bdeb..cdf850e515 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-13 · **Change:** minor (Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. | | 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | @@ -509,7 +510,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812; distance and angle labels while drawing, #2983) | 2–4; status-bar X/Y uses the active ruler origin and General units | -| File formats | 6 | 85% | Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | +| File formats | 6 | 85% | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | From 71da62dab2343c9b9da8a36505a61de31f57903b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:44:08 -0800 Subject: [PATCH 04/34] File formats: PICT pictures open and place File > Open and File > Place read QuickDraw pictures, versions 1 and 2, from .pct, .pic and .pict files, with or without the file's 512 bytes of application data; a version 2 picture also opens by its content. The reader plays the opcodes of Apple's Opcodes in Pictures table on the metafile player: lines, rectangles, rounded rectangles, ovals, arcs, polygons and regions become paths, text becomes point type in the font the picture names, bitmaps, pixel maps and QuickTime JPEG images become images, and the clip region clips what follows it. Place puts the picture in one group clipped to its frame. What isn't read comes back as warnings, and a picture cut short keeps what came before it. PICT is not written. Images whose transform flattens them no longer leave their pixel data in the document. Formats whose signature starts the file are detected before PICT, which is known by bytes 522 or 10. Docs: docs/mcp.md, docs/file-format-parity.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio.rs | 6 +- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/dxf.rs | 8 +- crates/engine/src/cmd/fileio/formats.rs | 12 +- crates/engine/src/cmd/fileio/load.rs | 10 + crates/engine/src/cmd/fileio/metafile.rs | 7 + crates/engine/src/cmd/fileio/tests_dxf.rs | 11 +- crates/engine/src/cmd/fileio/tests_pict.rs | 104 ++ crates/engine/src/cmd/place.rs | 6 +- crates/engine/tests/import_fuzz.rs | 103 +- crates/metafile/Cargo.toml | 2 +- crates/metafile/src/dib.rs | 6 +- crates/metafile/src/import.rs | 28 +- crates/metafile/src/import/pict.rs | 1173 ++++++++++++++++++++ crates/metafile/src/lib.rs | 28 +- crates/metafile/src/tests.rs | 1 + crates/metafile/src/tests/pict.rs | 466 ++++++++ crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 4 +- docs/mcp.md | 36 +- docs/target-app-parity.md | 1 + 42 files changed, 1997 insertions(+), 41 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_pict.rs create mode 100644 crates/metafile/src/import/pict.rs create mode 100644 crates/metafile/src/tests/pict.rs diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index b7ff1c7734..333db1c68f 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -13,10 +13,10 @@ //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. -//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). +//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PageMaker). //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. -//! - `metafile`: EMF and WMF export, open and place. +//! - `metafile`: EMF and WMF export, open and place; PICT open and place. //! - `hpgl`: HP-GL export options and plots. //! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no //! built-in format reads). @@ -217,6 +217,8 @@ mod tests_dxfimport; mod tests_hpgl; #[cfg(test)] mod tests_metafile; +#[cfg(test)] +mod tests_pict; #[cfg(test)] mod tests_pdfoutput; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index fe7c9d8f3e..26272d873d 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "File Formats", [], None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", + "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PageMaker, with what to use instead)}", always, formats ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 0fcaa4bfc0..8486cd2226 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -1,6 +1,6 @@ //! DXF export for every DXF path (`document.export {format: dxf}`, `document.exportDxf`, Export As //! → DXF Options): the options parsed from the params, one drawing per artboard with Use -//! Artboards, and the formats that can't be written (DWG, PICT) with what to use instead. +//! Artboards, and the formats that can't be written (DWG, PageMaker) with what to use instead. use std::sync::Arc; @@ -106,12 +106,6 @@ pub const UNSUPPORTED: &[Unsupported] = &[ extensions: &["dwg"], hint: "DWG is not available (no openly licensed writer exists): export DXF instead, which CAD apps open", }, - Unsupported { - id: "pict", - label: "PICT", - extensions: &["pict", "pct", "pic"], - hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first", - }, Unsupported { id: "pmd", label: "PageMaker", diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 3492fd6051..75b202c8ab 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -520,6 +520,8 @@ pub const FORMATS: &[Format] = &[ reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true), // jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it. reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true), + // QuickDraw pictures, read by `vectorcraft_metafile::import_pict` into paths, type and images. + reader("pict", "PICT", &["pct", "pic", "pict"], "image/x-pict", false), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -564,6 +566,9 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "pct", + "pic", + "pict", "plt", "hpgl", "hgl", @@ -581,10 +586,10 @@ pub const OPEN_EXTS: &[&str] = &[ /// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL 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. -/// The packaging declares no type for HP-GL plot files either. +/// The packaging declares no type for HP-GL plot files or PICT pictures either. 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", "jxl", "plt", "hpgl", "hgl", "hpg", + "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", "pct", "pic", "pict", ]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares @@ -658,6 +663,9 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "pct", + "pic", + "pict", "plt", "hpgl", "hgl", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 6f0f8fd533..76843f610e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -132,6 +132,12 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } + // A version 2 PICT is known by its version opcodes after the picture's frame, at byte 522 of a + // file or byte 10 of a bare picture (version 1 by its file's name), so a format whose + // signature starts the file is checked first. + if vectorcraft_metafile::is_pict(bytes) { + return format("pict"); + } if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) { return format("svg"); } @@ -499,6 +505,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (doc, warnings) = super::metafile::import(bytes)?; (doc, warnings, false) } + "pict" => { + let (doc, warnings) = super::metafile::import_pict(bytes)?; + (doc, warnings, false) + } "hpgl" => { let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?; (i.document, i.warnings, false) diff --git a/crates/engine/src/cmd/fileio/metafile.rs b/crates/engine/src/cmd/fileio/metafile.rs index 6bc658a4a0..d987d82757 100644 --- a/crates/engine/src/cmd/fileio/metafile.rs +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -2,6 +2,7 @@ //! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per //! artboard with Use Artboards; raster effects as images, rendered at the document's raster //! effects resolution), `document.open` and File → Place (one artboard, the picture's frame). +//! QuickDraw pictures (PICT) open and place the same way; they are never written. use serde_json::Value; use vectorcraft_doc::Document; @@ -52,3 +53,9 @@ pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec)> { let i = vectorcraft_metafile::import(bytes).map_err(super::err)?; Ok((i.document, i.warnings)) } + +/// A QuickDraw picture's document and the import's warnings. +pub(super) fn import_pict(bytes: &[u8]) -> Result<(Document, Vec)> { + let p = vectorcraft_metafile::import_pict(bytes).map_err(super::err)?; + Ok((p.document, p.warnings)) +} diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 03273d9450..1a3368b56f 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -1,5 +1,5 @@ //! DXF export through the commands: options, artboards, selection, images, and the formats that -//! can't be written (DWG, PICT). +//! can't be written (DWG, PageMaker). use serde_json::{Value, json}; @@ -275,23 +275,24 @@ fn formats_list_dxf_and_what_can_not_be_written() { } assert!(r["writable"].as_array().unwrap().contains(&json!("dxf"))); let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect(); - assert_eq!(unsupported, ["dwg", "pict", "pmd"]); + assert_eq!(unsupported, ["dwg", "pmd"]); } #[test] -fn dwg_and_pict_say_what_to_use_instead() { +fn dwg_and_pagemaker_say_what_to_use_instead() { let mut s = two_boards(); for p in [json!({"format": "dwg"}), json!({"path": "/tmp/plan.DWG"})] { let e = s.execute("document.export", &p).unwrap_err().to_string(); assert!(e.contains("DWG is not available") && e.contains("DXF"), "{p}: {e}"); } + // PICT opens; it isn't written. let e = s.execute("document.export", &json!({"format": "pict"})).unwrap_err().to_string(); - assert!(e.contains("PICT is a retired format"), "{e}"); + assert!(e.contains("PICT files can be opened but not written"), "{e}"); let e = s .execute("document.open", &json!({"name": "old.pct", "dataBase64": vectorcraft_format::base64_encode(&[0u8; 600])})) .unwrap_err() .to_string(); - assert!(e.contains("old.pct") && e.contains("PICT"), "{e}"); + assert!(e.contains("not a PICT file"), "{e}"); let e = s .execute("document.open", &json!({"name": "site.dwg", "dataBase64": vectorcraft_format::base64_encode(b"AC1032\0\0")})) .unwrap_err() diff --git a/crates/engine/src/cmd/fileio/tests_pict.rs b/crates/engine/src/cmd/fileio/tests_pict.rs new file mode 100644 index 0000000000..513adffbef --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pict.rs @@ -0,0 +1,104 @@ +//! PICT pictures through the commands: `document.open` by extension or by content, File › Place as +//! one group clipped to the picture's frame, the dialogs' filters and the format list, and files +//! that aren't pictures, or a PICT export, refused with a message. + +use serde_json::json; +use vectorcraft_doc::NodeKind; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// A version 2 PICT file of a 200 × 100 pt frame: a red rectangle and a line. +fn picture() -> Vec { + let words = |v: &[i16]| v.iter().flat_map(|x| x.to_be_bytes()).collect::>(); + let mut b = vec![0; 512]; + b.extend(words(&[0, 0, 0, 100, 200])); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(words(&[-1, 0])); + b.extend([0; 20]); + b.extend(words(&[0x1A, -1, 0, 0, 0x31, 10, 20, 50, 80, 0x20, 70, 10, 70, 190, 0xFF])); + b +} + +#[test] +fn document_open_reads_a_picture() { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": "logo.pct", "dataBase64": b64(&picture())})).unwrap(); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("pict"), Some("logo.pct")), "{r}"); + assert_eq!(r["warnings"], json!([])); + let st = s.doc().unwrap(); + assert_eq!(st.doc.artboards[0].rect.size(), (200.0, 100.0).into()); + let mut paths = vec![]; + st.doc.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + paths.push(n.clone()); + } + }); + assert_eq!(paths.len(), 2); + let b = paths[0].geometric_bounds().unwrap(); + assert!((b.x0 - 20.0).abs() < 1e-9 && (b.width() - 60.0).abs() < 1e-9, "{b:?}"); + // Opened as artwork: Save asks for a name. + assert!(st.path.is_none()); + + // A picture opens by its content under a name no format reads, and in each of its names. + for name in ["logo.PICT", "logo.pic", "logo.bin", "logo"] { + let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(&picture())})).unwrap(); + assert_eq!(r["format"].as_str(), Some("pict"), "{name}: {r}"); + } + assert_eq!(detect("logo.dat", &picture()).map(|f| f.id), Some("pict")); + assert_eq!(detect("bare.dat", &picture()[512..]).map(|f| f.id), Some("pict")); +} + +#[test] +fn file_place_places_a_picture_as_one_clipped_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let p = json!({"name": "logo.pct", "dataBase64": b64(&picture()), "at": [150, 150]}); + let info = s.execute("file.place.info", &p).unwrap(); + assert_eq!(info["format"], "pict", "{info}"); + assert!((info["width"].as_f64().unwrap() - 200.0).abs() < 1e-9, "{info}"); + let r = s.execute("file.place", &p).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("pict"), Some(1)), "{r}"); + let doc = &s.doc().unwrap().doc; + let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_eq!(group.name.as_deref(), Some("logo.pct")); + let NodeKind::Group { clip: true, .. } = &group.kind else { panic!("a clipping group, got {:?}", group.kind) }; + let b = group.geometric_bounds().unwrap(); + assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn files_that_are_not_pictures_are_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let e = s.execute("document.open", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).expect_err("refused"); + assert!(e.to_string().contains("not a PICT file"), "{e}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), (100.0, 50.0).into()); + assert!(s.execute("file.place", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).is_err()); + // Other formats' files aren't taken for pictures by their content. + // A file that starts with a PNG signature and holds a picture's version opcodes at byte 522. + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(&picture()[8..]); + assert!(detect("x.png", &png).is_none_or(|f| f.id != "pict")); + let e = s.execute("document.export", &json!({"format": "pict"})).expect_err("refused").to_string(); + assert!(e.contains("PICT files can be opened but not written"), "{e}"); +} + +#[test] +fn the_dialogs_and_the_format_list_name_pict() { + let exts = ["pct", "pic", "pict"]; + for e in exts { + assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}"); + assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("pict"), "{e}"); + } + let filter = ("PICT", &exts[..]); + assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); + let r = Session::new().execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pict").unwrap().clone(); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)), "{f}"); + assert_eq!(f["mime"], "image/x-pict"); + assert!(writable_format(None, Some("x/logo.pct")).is_err()); +} diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 5d56a2b646..81ec7fb7c2 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -6,8 +6,8 @@ //! `link` ([`super::links`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options //! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`). -//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by -//! its art (`crop: "bounding"`). +//! - An EMF, WMF or PICT picture becomes one group, clipped to its frame (`crop: "crop"`) or +//! bounded by its art (`crop: "bounding"`). //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by //! its art: the document an EPS file of ours carries, else what its PostScript draws, else its //! preview ([`fileio::load`]). @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4688845de4..52be19a952 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3,7 +3,8 @@ //! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records -//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files +//! of every kind with random contents) opened, placed or pasted, nor PICT pictures (damaged files, +//! opcodes with random contents) opened or placed, nor EPS and PostScript files //! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of //! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents //! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the @@ -2938,6 +2939,106 @@ proptest! { } } +// ---------- PICT pictures: opened and placed ---------- + +fn be16(v: &[i16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A version 2 PICT file of a 200 × 100 pt frame holding `ops`, each padded to an even length. +fn pict_of(ops: &[(u16, Vec)]) -> Vec { + let mut b = vec![0; 512]; + b.extend(be16(&[0, 0, 0, 100, 200])); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(be16(&[-1, 0])); + b.extend([0; 20]); + for (op, data) in ops { + b.extend(op.to_be_bytes()); + b.extend(data); + if b.len() % 2 == 1 { + b.push(0); + } + } + b.extend([0x00, 0xFF]); + b +} + +/// A picture with shapes, a region, text, a pattern and a packed pixel map. +fn pict_sample() -> Vec { + let region = [be16(&[36, 0, 0, 20, 20]), be16(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat(); + let table = be16(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]); + let pixmap = [ + be16(&[0x8008u16 as i16, 0, 0, 2, 8]), + be16(&[0, 0, 0, 0, 72, 0, 72, 0, 0, 8, 1, 8, 0, 0, 0, 0, 0, 0]), + table, + be16(&[0, 0, 2, 8, 10, 20, 14, 36, 0]), + vec![2, 0xF9, 0, 9, 7, 1, 0, 1, 0, 1, 0, 1, 0], + ] + .concat(); + pict_of(&[ + (0x1A, be16(&[-1, 0, 0])), + (0x07, be16(&[2, 2])), + (0x31, be16(&[10, 20, 50, 80])), + (0x30, be16(&[10, 20, 50, 80])), + (0x0B, be16(&[8, 8])), + (0x41, be16(&[0, 100, 40, 160])), + (0x51, be16(&[50, 100, 90, 160])), + (0x61, be16(&[0, 0, 60, 60, 30, 120])), + (0x71, be16(&[22, 0, 0, 40, 40, 0, 0, 40, 40, 0, 40])), + (0x81, region.clone()), + (0x01, region), + (0x20, be16(&[70, 10, 70, 190])), + (0x21, be16(&[90, 100])), + (0x0A, vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]), + (0x34, be16(&[0, 0, 10, 10])), + (0x2C, [be16(&[9, 3]), vec![6], b"Geneva".to_vec()].concat()), + (0x0D, be16(&[12])), + (0x28, [be16(&[80, 10]), vec![3], b"abc".to_vec()].concat()), + (0x98, pixmap), + ]) +} + +/// The sample the mutations start from opens with art and an image. +#[test] +fn the_pict_sample_opens() { + let l = vectorcraft_engine::cmd::fileio::load("x.pct", &pict_sample()).unwrap(); + assert_eq!(l.format.id, "pict"); + let (mut paths, mut images, mut texts) = (0, 0, 0); + l.doc.walk(|n| match n.kind { + vectorcraft_doc::NodeKind::Path { .. } => paths += 1, + vectorcraft_doc::NodeKind::Image(_) => images += 1, + vectorcraft_doc::NodeKind::Text(_) => texts += 1, + _ => {} + }); + assert!(paths > 8 && images == 1 && texts == 1, "{paths} paths, {images} images, {texts} texts, warnings {:?}", l.warnings); +} + +/// PICT opcodes VectorCraft reads, and reserved ones it skips. +const PICT_OPS: [u16; 40] = [ + 0x01, 0x03, 0x04, 0x07, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x12, 0x13, 0x14, 0x1A, 0x1B, 0x20, 0x21, 0x22, 0x23, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x30, + 0x31, 0x38, 0x41, 0x51, 0x61, 0x71, 0x81, 0x90, 0x91, 0x98, 0x99, 0x9A, 0x9B, 0xA1, 0x8200, 0x0024, +]; + +proptest! { + #![proptest_config(config())] + + #[test] + fn mutated_pict_pictures_never_panic(cut in 0usize..4096, edits in prop::collection::vec((0usize..4096, any::()), 0..16)) { + let mut bytes = pict_sample(); + let n = bytes.len(); + for &(at, b) in &edits { + bytes[512 + at % (n - 512)] = b; + } + bytes.truncate(cut.max(512)); + survive_metafile("mutated PICT", "x.pct", &bytes)?; + } + + #[test] + fn random_pict_opcodes_never_panic(ops in prop::collection::vec((prop::sample::select(PICT_OPS.to_vec()), arb_body()), 0..32)) { + survive_metafile("PICT opcodes", "x.pct", &pict_of(&ops))?; + } +} + // ---------- HP-GL plot files: opened and placed ---------- /// A plot file with every kind of instruction the reader plays, printer escapes around it. diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index fe54a67f6e..01b5d6434a 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-metafile" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft Windows metafiles: hand-written EMF and WMF writers and readers" +description = "VectorCraft metafiles: hand-written EMF and WMF writers and readers, and a QuickDraw picture (PICT) reader" [lints] workspace = true diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index a0e95498c3..4aeb5b4381 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -11,8 +11,8 @@ const MAX_SIDE: u32 = 8192; /// Most pixels an image the writers store holds. const MAX_PIXELS: u64 = 1 << 24; /// Most pixels along a side, and in all, of a bitmap the reader decodes. -const MAX_READ_SIDE: u32 = 1 << 15; -const MAX_READ_PIXELS: u64 = 1 << 26; +pub(crate) const MAX_READ_SIDE: u32 = 1 << 15; +pub(crate) const MAX_READ_PIXELS: u64 = 1 << 26; /// Most pixels the bitmaps of one file may decode to together (four of the largest one), so a small /// file of many image records can't cost minutes of decoding (#3600). pub(crate) const MAX_FILE_PIXELS: u64 = 1 << 28; @@ -164,7 +164,7 @@ fn png_without_metadata(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { } /// Take a bitmap of `w` × `h` from what is `left` of the file's budget, before it is decoded. -fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { +pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { let n = u64::from(w) * u64::from(h); *left = left.checked_sub(n).ok_or(OVER_BUDGET)?; Ok(()) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 0a3d791ee2..da7816663e 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -1,6 +1,7 @@ -//! The metafile reader: the format parsers (`emf`, `wmf`) play their records on a [`Player`], a -//! small model of the device context they draw into (objects, transforms, the current position, -//! the path bracket, the clip), which turns what they draw into document objects. +//! The metafile reader: the format parsers (`emf`, `wmf`, and `pict` for QuickDraw pictures) play +//! their records on a [`Player`], a small model of the device context they draw into (objects, +//! transforms, the current position, the path bracket, the clip), which turns what they draw into +//! document objects. //! //! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the //! same pen join into one path. Clips become clipping groups around what they clip. Images keep @@ -8,6 +9,7 @@ //! doesn't know are counted and skipped, with one warning. mod emf; +pub(crate) mod pict; mod wmf; use std::sync::Arc; @@ -787,15 +789,25 @@ impl Player { if img.width == 0 || img.height == 0 { return; } + let (w, h) = (f64::from(img.width), f64::from(img.height)); + self.image_xf(img, Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h), opacity); + } + + /// An image placed by `xf` (its pixels, from the top-left, → logical), at `opacity`. An image + /// that `xf` flattens or sends past finite numbers is left out, and adds nothing to the + /// document's images. + pub(crate) fn image_xf(&mut self, img: Rgba, xf: Affine, opacity: f64) { + if img.width == 0 || img.height == 0 { + return; + } + let xf = self.xf() * xf; + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return; + } let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) }; let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); - let (w, h) = (f64::from(img.width), f64::from(img.height)); - let xf = self.xf() * Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h); - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - return; - } let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; diff --git a/crates/metafile/src/import/pict.rs b/crates/metafile/src/import/pict.rs new file mode 100644 index 0000000000..a9b4cae9da --- /dev/null +++ b/crates/metafile/src/import/pict.rs @@ -0,0 +1,1173 @@ +//! QuickDraw pictures (PICT, versions 1 and 2), played on the [`Player`] by the opcodes and data +//! lengths of Apple's *Imaging With QuickDraw*, appendix A. +//! +//! A PICT file holds 512 bytes of application data before the picture; a picture on its own (as +//! a `PICT` resource holds it) reads too. A unit is a point, and the artboard is the picture's +//! frame; an extended version 2 header's source rectangle is mapped onto the frame. +//! +//! - Lines, rectangles, rounded rectangles, ovals, arcs and wedges, polygons and regions become +//! paths. A framed shape is stroked inside its outline at the pen's size, and a line below and +//! to the right of its points, where the pen draws; painted, filled and erased shapes are filled +//! with the pen, fill or background pattern. A pattern of two colors is filled with their +//! average. +//! - Text becomes point type in the font the picture names, read as Mac Roman. +//! - Bitmaps and pixel maps (1 to 8 bits with their color table, 16 and 32 bits, packed or not) +//! become images, clipped to their mask region; QuickTime JPEG images become images. +//! - The clip region clips what follows it. +//! +//! What isn't read (inverted shapes, transfer modes other than a copy, other QuickTime formats) +//! comes back as one warning each. Apple's table gives every opcode's length, so opcodes that draw +//! nothing VectorCraft reads are skipped; a picture cut short keeps what came before. + +use std::collections::HashMap; +use std::io::Cursor; + +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect, Shape, Vec2, shapes}; + +use super::{BAD_IMAGE, BrushObj, FontObj, PS_GEOMETRIC, PenObj, Player, SRCCOPY, TOO_MANY_PIXELS, crop, round_rect}; +use crate::dib::{self, Rgba}; +use crate::{Imported, Kind}; + +/// What a file that holds no picture is refused with. +const NOT_PICT: &str = "not a PICT file"; +/// The application data before the picture in a PICT file. +const FILE_HEADER: usize = 512; +/// Most opcodes one picture plays (a small file can't keep the reader busy for minutes). +const MAX_OPS: usize = 1 << 22; +/// Most rectangles one region breaks into. +const MAX_RECTS: usize = 1 << 16; +/// Steps the regions of one picture may take to read (each inversion point and band costs some). +const MAX_REGION_WORK: u64 = 1 << 28; +/// Most bytes the unpacked rows of one image may hold. +const MAX_ROW_BYTES: u64 = 1 << 28; +/// Most bytes the unpacked rows of one picture's images and patterns may hold together. +const MAX_FILE_ROW_BYTES: u64 = 1 << 30; +/// Most bytes a row may hold past what its pixels need. +const ROW_SLACK: usize = 64; +/// Most pixels, and bytes of unpacked rows, a pixel pattern may hold. +const MAX_PATTERN_PIXELS: usize = 1 << 16; +const MAX_PATTERN_ROW_BYTES: usize = 1 << 20; +/// Curves made for ovals and arcs are this close to the true curve, points. +const TOLERANCE: f64 = 0.01; +/// GDI text alignment on the baseline, which QuickDraw's text location is. +const TA_BASELINE: u32 = 0x18; +/// Pen style flags: square and flat ends, mitered joins. +const SQUARE: u32 = 0x100; +const FLAT: u32 = 0x200; +const MITER: u32 = 0x2000; + +const CUT_SHORT: &str = "the picture is cut short or damaged: what came before was imported"; +const TOO_MANY_OPS: &str = "the picture holds more opcodes than one file may: the rest was left out"; +const INVERTED: &str = "inverted shapes were left out"; +const PATTERNS: &str = "patterns are filled with the average of their colors"; +const MODES: &str = "art drawn in transfer modes other than a copy is drawn as a copy"; +const FRAMED_REGIONS: &str = "framed regions are outlined rectangle by rectangle"; +const BIG_REGIONS: &str = "regions larger than one picture may hold were left out"; +const CODECS: &str = "QuickTime images in formats other than JPEG were left out"; +const NOTHING: &str = "the picture draws nothing VectorCraft reads"; + +/// Big-endian reads from a slice, never past its end. +struct Be<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Be<'a> { + fn new(data: &'a [u8], pos: usize) -> Self { + Self { data, pos } + } + fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { + let b = self.data.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(b) + } + fn array(&mut self) -> Option<[u8; N]> { + self.bytes(N)?.try_into().ok() + } + fn skip(&mut self, n: usize) -> Option<()> { + self.bytes(n).map(drop) + } + fn u8(&mut self) -> Option { + self.array().map(|[b]: [u8; 1]| b) + } + fn u16(&mut self) -> Option { + self.array().map(u16::from_be_bytes) + } + fn i16(&mut self) -> Option { + self.array().map(i16::from_be_bytes) + } + fn u32(&mut self) -> Option { + self.array().map(u32::from_be_bytes) + } + fn i32(&mut self) -> Option { + self.array().map(i32::from_be_bytes) + } + /// A QuickDraw point: vertical, then horizontal. + fn point(&mut self) -> Option { + let v = self.i16()?; + let h = self.i16()?; + Some(Point::new(h.into(), v.into())) + } + /// A size stored as a point (a pen or an oval: height, then width). + fn size(&mut self) -> Option { + self.point().map(Point::to_vec2) + } + /// A QuickDraw rectangle: top, left, bottom, right. + fn rect(&mut self) -> Option { + let (top, left, bottom, right) = (self.i16()?, self.i16()?, self.i16()?, self.i16()?); + Some(Rect::new(left.into(), top.into(), right.into(), bottom.into())) + } + /// An `RGBColor` (16 bits a component), 8 bits a component. + fn rgb(&mut self) -> Option<[u8; 3]> { + let [r, g, b] = [self.u16()?, self.u16()?, self.u16()?]; + Some([r, g, b].map(|c| (c >> 8) as u8)) + } +} + +/// Where a picture is, its frame and whether it is version 2. +struct Header { + frame: Rect, + v2: bool, + /// Where its first opcode starts. + ops: usize, +} + +/// The picture whose `picSize` is at `at`, if a version opcode follows its frame. +fn header(bytes: &[u8], at: usize) -> Option
{ + let mut r = Be::new(bytes, at.checked_add(2)?); + let frame = r.rect()?; + let (v2, ops) = match r.array::<2>()? { + [0x11, 0x01] => (false, r.pos), + [0x00, 0x11] if r.array::<4>()? == [0x02, 0xFF, 0x0C, 0x00] => (true, at + 10), + _ => return None, + }; + (frame.width() > 0.0 && frame.height() > 0.0).then_some(Header { frame, v2, ops }) +} + +/// The picture of a file (after its application data), else the picture `bytes` start with. +fn find(bytes: &[u8]) -> Option
{ + header(bytes, FILE_HEADER).or_else(|| header(bytes, 0)) +} + +/// Do `bytes` hold a version 2 picture, after a file's application data or on their own? The +/// version 1 signature is two bytes, too short to tell a file by. +pub(crate) fn is_pict(bytes: &[u8]) -> bool { + [FILE_HEADER, 0].into_iter().any(|at| header(bytes, at).is_some_and(|h| h.v2)) +} + +/// A pattern: eight rows of eight bits (set bits in the foreground color, the others in the +/// background color), or one color. +#[derive(Clone, Copy)] +enum Pat { + Bits([u8; 8]), + Rgb([u8; 3]), +} + +/// What a shape opcode does with its shape. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Verb { + Frame, + Paint, + Erase, + Invert, + Fill, +} + +/// The verb of a shape opcode (its low three bits; 5 to 7 are reserved). +fn verb(op: u16) -> Option { + match op & 7 { + 0 => Some(Verb::Frame), + 1 => Some(Verb::Paint), + 2 => Some(Verb::Erase), + 3 => Some(Verb::Invert), + 4 => Some(Verb::Fill), + _ => None, + } +} + +/// An old-style QuickDraw color (`blackColor` 33 … `blueColor` 409) as RGB. +fn old_color(c: u32) -> [u8; 3] { + match c { + 30 => [255; 3], + 69 => [255, 255, 0], + 137 => [255, 0, 255], + 205 => [255, 0, 0], + 273 => [0, 255, 255], + 341 => [0, 255, 0], + 409 => [0, 0, 255], + _ => [0; 3], + } +} + +/// `a` and `b` mixed: `t` of `a`. +fn mix(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { + let m = |x: u8, y: u8| (f64::from(x) * t + f64::from(y) * (1.0 - t)).round() as u8; + [m(a[0], b[0]), m(a[1], b[1]), m(a[2], b[2])] +} + +/// Byte `b` of Mac Roman. +fn mac_roman(b: u8) -> char { + const HIGH: [char; 128] = [ + 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', // + 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', // + '†', '°', '¢', '£', '§', '•', '¶', 'ß', '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø', // + '∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø', // + '¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', '»', '…', '\u{A0}', 'À', 'Ã', 'Õ', 'Œ', 'œ', // + '–', '—', '“', '”', '‘', '’', '÷', '◊', 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', 'fi', 'fl', // + '‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô', // + '\u{F8FF}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ', + ]; + match b { + 0x80.. => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER), + _ => char::from(b), + } +} + +/// The family name of a classic Macintosh font number. +fn font_family(id: u16) -> &'static str { + match id { + 0 => "Chicago", + 1 | 3 => "Geneva", + 2 => "New York", + 4 => "Monaco", + 20 => "Times", + 21 => "Helvetica", + 22 => "Courier", + 23 => "Symbol", + _ => "", + } +} + +/// The arc of the oval in `r` from `start` degrees clockwise from 12 o'clock, over `sweep` +/// degrees (clockwise when positive). Angles are measured on the rectangle, so 45° runs through +/// its top-right corner. A wedge closes through the center. +fn arc(r: Rect, start: i16, sweep: i16, wedge: bool) -> BezPath { + let c = r.center(); + let radii = Vec2::new(r.width() / 2.0, r.height() / 2.0); + let mut bp = BezPath::new(); + if sweep == 0 { + return bp; + } + let a0 = (f64::from(start) - 90.0).to_radians(); + let sweep = f64::from(sweep).clamp(-360.0, 360.0).to_radians(); + let arc = kurbo::Arc { center: c, radii, start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + if wedge { + bp.move_to(c); + bp.line_to(c + Vec2::new(radii.x * a0.cos(), radii.y * a0.sin())); + arc.append_iter(TOLERANCE).for_each(|el| bp.push(el)); + bp.close_path(); + } else { + kurbo::Shape::path_elements(&arc, TOLERANCE).for_each(|el| bp.push(el)); + } + bp +} + +/// The outlines of `rects`, as one path. +fn rects_path(rects: &[Rect]) -> BezPath { + let mut bp = BezPath::new(); + for r in rects { + bp.extend(shapes::rectangle(*r).to_bezpath()); + } + bp +} + +/// Take `n` steps from `work`; false when too few are left. +fn spend(work: &mut u64, n: usize) -> bool { + match work.checked_sub(n as u64) { + Some(left) => { + *work = left; + true + } + None => false, + } +} + +/// A region (its size, bounding box and inversion points), read at `r`, as rectangles: `None` +/// when the data runs past the end; the flag is false when the region is larger than `work` or +/// [`MAX_RECTS`] allows, and then no rectangles are given. +fn region(r: &mut Be, work: &mut u64) -> Option<(Vec, bool)> { + let size = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(size.checked_sub(2)?)?, 0); + let bbox = b.rect()?; + if size <= 10 { + let whole = bbox.width() > 0.0 && bbox.height() > 0.0; + return Some((if whole { vec![bbox] } else { vec![] }, true)); + } + Some(bands(&mut b, work).map_or((vec![], false), |rects| (rects, true))) +} + +/// The rectangles of a region's scanlines: each lists the x coordinates where being inside the +/// region changes from the scanline before. Bands of the same spans one after another are one +/// rectangle. +fn bands(b: &mut Be, work: &mut u64) -> Option> { + let mut out: Vec = vec![]; + let mut active: Vec = vec![]; + let mut last_band = 0; + let mut prev_y = 0.0; + while let Some(y) = b.i16().filter(|y| *y != 0x7FFF) { + let y = f64::from(y); + let spans: Vec<(f64, f64)> = active.as_chunks::<2>().0.iter().map(|[a, b]| (f64::from(*a), f64::from(*b))).collect(); + if !spans.is_empty() && y > prev_y { + if !spend(work, spans.len() + 1) { + return None; + } + let prev = out.get_mut(last_band..).unwrap_or_default(); + let same = prev.len() == spans.len() && prev.iter().zip(&spans).all(|(r, (x0, x1))| r.x0 == *x0 && r.x1 == *x1 && r.y1 == prev_y); + if same { + prev.iter_mut().for_each(|r| r.y1 = y); + } else { + last_band = out.len(); + out.extend(spans.iter().map(|&(x0, x1)| Rect::new(x0, prev_y, x1, y))); + if out.len() > MAX_RECTS { + return None; + } + } + } + prev_y = y; + while let Some(x) = b.i16().filter(|x| *x != 0x7FFF) { + if !spend(work, active.len() + 1) { + return None; + } + match active.binary_search(&x) { + Ok(i) => { + active.remove(i); + } + Err(i) => active.insert(i, x), + } + } + } + Some(out) +} + +/// Skip the data of opcode `op`, by the length Apple's table gives it. +fn skip(op: u16, r: &mut Be) -> Option<()> { + let n = match op { + 0x00 | 0x17..=0x19 | 0x1C | 0x1E | 0xB0..=0xCF | 0x8000..=0x80FF => 0, + 0x04 => 1, + 0x03 | 0x05 | 0x08 | 0x0D | 0x11 | 0x15 | 0x16 | 0xA0 => 2, + 0x06 | 0x07 | 0x0B | 0x0C | 0x0E | 0x0F => 4, + 0x1A | 0x1B | 0x1D | 0x1F => 6, + 0x02 | 0x09 | 0x0A | 0x10 => 8, + // Reserved opcodes with a 16-bit length, and the font name. + 0x24..=0x27 | 0x2C..=0x2F | 0x92..=0x97 | 0x9C..=0x9F | 0xA2..=0xAF => usize::from(r.u16()?), + // Reserved for Apple: two bytes for each unit of the high byte (the 24-byte header 0x0C00). + 0x0100..=0x7FFF => usize::from(op >> 8) * 2, + // 0xD0–0xFE and 0x8100–0xFFFF (QuickTime images among them): a 32-bit length. + _ => usize::try_from(r.u32()?).ok()?, + }; + r.skip(n) +} + +/// How a bitmap's or pixel map's pixels are stored. +struct PixMap { + row_bytes: usize, + bounds: Rect, + /// Bits a pixel. + size: u16, + pack: u16, + cmps: u16, + /// Indexed pixels' colors; a bitmap has none (its set bits are the foreground color). + palette: Option>, + /// Rows of 8 bytes or more may be packed with PackBits, as the packing says. + packed: bool, +} + +impl PixMap { + /// From `rowBytes` on: a pixel map when `rowBytes` has its high bit set or `pixmap` says so, + /// else a bitmap; a pixel map's color table follows unless `direct`. + fn read(r: &mut Be, pixmap: bool, direct: bool) -> Option { + let raw = r.u16()?; + let mut pm = PixMap { row_bytes: usize::from(raw & 0x3FFF), bounds: r.rect()?, size: 1, pack: 0, cmps: 1, palette: None, packed: true }; + if pixmap || raw & 0x8000 != 0 { + r.skip(2)?; + pm.pack = r.u16()?; + r.skip(14)?; + pm.size = r.u16()?; + pm.cmps = r.u16()?; + r.skip(14)?; + if !direct { + pm.palette = Some(color_table(r)?); + } + } + Some(pm) + } + + /// The width and height of its pixels. + fn dims(&self) -> Option<(usize, usize)> { + let (w, h) = (self.bounds.width(), self.bounds.height()); + (w > 0.0 && h > 0.0).then_some((w as usize, h as usize)) + } + + /// The packing: 0 is 4 for 32-bit pixels and 3 for 16-bit ones. + fn packing(&self) -> u16 { + match (self.size, self.pack) { + (32, 0) => 4, + (16, 0) => 3, + (_, p) => p, + } + } + + /// Rows are stored as they are (no byte count, no PackBits). + fn raw_rows(&self) -> bool { + !self.packed || self.row_bytes < 8 || self.packing() == 1 || (self.size == 32 && self.packing() == 2) + } + + /// Bytes of an unpacked row: a component plane each for packing 4, three bytes a pixel for + /// packing 2, else `rowBytes`. + fn row_len(&self, w: usize) -> usize { + match self.packing() { + 4 if self.size == 32 && self.row_bytes >= 8 => w * usize::from(self.cmps), + 2 if self.size == 32 => w * 3, + _ => self.row_bytes, + } + } + + /// Whether its pixels can be read: a depth QuickDraw has, and rows long enough for them and at + /// most [`ROW_SLACK`] bytes longer. + fn decodable(&self, w: usize) -> bool { + let need = match self.size { + 1 | 2 | 4 | 8 => (w * usize::from(self.size)).div_ceil(8), + 16 => 2 * w, + 32 if self.packing() == 4 && self.row_bytes >= 8 => { + if !matches!(self.cmps, 3 | 4) { + return false; + } + w * usize::from(self.cmps) + } + 32 if self.packing() == 2 => 3 * w, + 32 => 4 * w, + _ => return false, + }; + (need..=need + ROW_SLACK).contains(&self.row_len(w)) + } + + /// Read `h` rows of pixel data, unpacked into one buffer when `keep`, else only stepped over. + /// `None` when the data runs past the end; the flag is false when a row didn't unpack to its + /// length. + fn rows(&self, r: &mut Be, w: usize, h: usize, keep: bool) -> Option<(Vec, bool)> { + let len = self.row_len(w); + let unit = if self.packing() == 3 { 2 } else { 1 }; + let mut out = vec![]; + let mut ok = true; + let mut keep = keep; + for _ in 0..h { + if self.raw_rows() { + let row = r.bytes(len)?; + if keep { + out.extend_from_slice(row); + } + continue; + } + let n = if self.row_bytes > 250 { usize::from(r.u16()?) } else { usize::from(r.u8()?) }; + let packed = r.bytes(n)?; + // After a row that doesn't unpack, the rest are only stepped over. + if keep && !unpack(packed, len, unit, &mut out) { + ok = false; + keep = false; + out = vec![]; + } + } + Some((out, ok)) + } + + /// The color of pixel `x` of `row` (the foreground and background colors for a bitmap's bits). + fn pixel(&self, row: &[u8], x: usize, w: usize, fg: [u8; 3], bg: [u8; 3]) -> Option<[u8; 3]> { + let at = |i: usize| row.get(i).copied(); + match self.size { + 1 | 2 | 4 | 8 => { + let bits = usize::from(self.size); + let shift = 8 - bits - x * bits % 8; + let index = usize::from((at(x * bits / 8)? >> shift) & ((1u16 << bits) - 1) as u8); + match &self.palette { + Some(p) => p.get(index).copied(), + None => Some(if index == 0 { bg } else { fg }), + } + } + 16 => { + let v = u16::from_be_bytes([at(2 * x)?, at(2 * x + 1)?]); + let c = |s: u16| (((v >> s) & 31) * 255 / 31) as u8; + Some([c(10), c(5), c(0)]) + } + _ if self.packing() == 4 && self.row_bytes >= 8 => { + // Component planes, alpha first when there are four (QuickDraw doesn't draw it). + let first = usize::from(self.cmps) - 3; + Some([at(first * w + x)?, at((first + 1) * w + x)?, at((first + 2) * w + x)?]) + } + _ if self.packing() == 2 => Some([at(3 * x)?, at(3 * x + 1)?, at(3 * x + 2)?]), + _ => Some([at(4 * x + 1)?, at(4 * x + 2)?, at(4 * x + 3)?]), + } + } + + /// Unpacked rows as opaque pixels. + fn to_rgba(&self, data: &[u8], w: usize, h: usize, fg: [u8; 3], bg: [u8; 3]) -> Option { + let len = self.row_len(w); + let mut pixels = Vec::with_capacity(w * h * 4); + for y in 0..h { + let row = data.get(y * len..(y + 1) * len)?; + for x in 0..w { + pixels.extend(self.pixel(row, x, w, fg, bg)?); + pixels.push(255); + } + } + Some(Rgba { width: u32::try_from(w).ok()?, height: u32::try_from(h).ok()?, pixels }) + } +} + +/// A `ColorTable`: indexed pixels' colors (entry by entry for a device table, else by each +/// entry's value). +fn color_table(r: &mut Be) -> Option> { + r.skip(4)?; + let flags = r.u16()?; + let n = usize::from(r.u16()?) + 1; + let mut palette = vec![[0u8; 3]; 256]; + for i in 0..n { + let value = usize::from(r.u16()?); + let c = r.rgb()?; + if let Some(e) = palette.get_mut(if flags & 0x8000 != 0 { i } else { value }) { + *e = c; + } + } + Some(palette) +} + +/// Unpack PackBits `data` (runs of `unit`-byte elements) onto `out`, `len` bytes; false when the +/// data is damaged or short (the row is padded with zeros). +fn unpack(data: &[u8], len: usize, unit: usize, out: &mut Vec) -> bool { + let start = out.len(); + let mut i = 0; + let mut ok = true; + while out.len() - start < len { + let Some(&n) = data.get(i) else { + ok = false; + break; + }; + i += 1; + let n = n as i8; + if n >= 0 { + let k = (n as usize + 1) * unit; + let Some(lit) = data.get(i..i + k) else { + ok = false; + break; + }; + out.extend_from_slice(lit); + i += k; + } else if n != -128 { + let Some(v) = data.get(i..i + unit) else { + ok = false; + break; + }; + for _ in 0..(1 - isize::from(n)) { + out.extend_from_slice(v); + } + i += unit; + } + } + out.resize(start + len, 0); + ok +} + +/// The picture being played: QuickDraw's drawing state, and the player that makes the objects. +struct Pict { + p: Player, + frame: Rect, + /// Picture space → document. + base: Affine, + origin: Vec2, + fg: [u8; 3], + bg: [u8; 3], + pen: Vec2, + pen_mode: u16, + pen_pat: Pat, + fill_pat: Pat, + back_pat: Pat, + oval: Vec2, + pen_loc: Point, + font: u16, + face: u8, + text_size: i16, + text_loc: Point, + /// Font numbers the picture names. + names: HashMap, + /// The last rectangle of the rectangle, rounded rectangle, oval and arc opcodes. + last: [Rect; 4], + region_work: u64, + /// Bytes of unpacked rows the picture's images and patterns may still take. + row_bytes_left: u64, +} + +impl Pict { + fn new(frame: Rect) -> Self { + let base = Affine::translate(-frame.origin().to_vec2()); + let artboard = Rect::new(0.0, 0.0, frame.width(), frame.height()); + // A picture's coordinates map straight onto the document, as a WMF's do. + let p = Player::new(Kind::Wmf, base, 25.4 / 72.0, artboard); + Self { + p, + frame, + base, + origin: Vec2::ZERO, + fg: [0; 3], + bg: [255; 3], + pen: Vec2::new(1.0, 1.0), + pen_mode: 8, + pen_pat: Pat::Bits([0xFF; 8]), + fill_pat: Pat::Bits([0xFF; 8]), + back_pat: Pat::Bits([0; 8]), + oval: Vec2::ZERO, + pen_loc: Point::ZERO, + font: 0, + face: 0, + text_size: 0, + text_loc: Point::ZERO, + names: HashMap::new(), + last: [Rect::ZERO; 4], + region_work: MAX_REGION_WORK, + row_bytes_left: MAX_FILE_ROW_BYTES, + } + } + + /// Charge the `w` × `h` pixels of `pm`, and the bytes of its unpacked rows, to the picture's + /// budgets: false, and nothing charged, when either is spent. + fn charge(&mut self, pm: &PixMap, w: usize, h: usize) -> bool { + let bytes = (pm.row_len(w) as u64).saturating_mul(h as u64); + let Some(rows_left) = self.row_bytes_left.checked_sub(bytes) else { return false }; + let (Ok(wide), Ok(high)) = (u32::try_from(w), u32::try_from(h)) else { return false }; + if dib::charge(wide, high, &mut self.p.pixels_left).is_err() { + return false; + } + self.row_bytes_left = rows_left; + true + } + + /// Play opcode `op`, its data at `r`; `None` when the data runs past the end. + fn op(&mut self, op: u16, r: &mut Be) -> Option<()> { + if !matches!(op, 0x20..=0x23) { + self.p.flush(); + } + match op { + 0x01 => self.clip(r), + 0x0C => self.move_origin(r), + 0x20..=0x23 => self.line(op, r), + 0x28..=0x2B => self.text(op, r), + 0x2C => self.font_name(r), + 0x30..=0x6F => self.shape(op, r), + 0x70..=0x77 => self.poly(verb(op), r), + 0x80..=0x87 => self.region_shape(verb(op), r), + // The same polygon and region: Apple's table gives them no data and no drawing. + 0x78..=0x7F | 0x88..=0x8F => Some(()), + 0x90 | 0x91 | 0x98..=0x9B => self.bits(op, r), + 0xA1 => { + r.skip(2)?; + let n = usize::from(r.u16()?); + r.skip(n) + } + 0x0C00 => self.header(r), + 0x8200 => self.quicktime(r), + _ => self.state(op, r), + } + } + + /// The opcodes that set the drawing state; the others are skipped. + fn state(&mut self, op: u16, r: &mut Be) -> Option<()> { + match op { + 0x02 => self.back_pat = Pat::Bits(r.array()?), + 0x03 => self.font = r.u16()?, + 0x04 => self.face = r.u8()?, + 0x07 => self.pen = r.size()?, + 0x08 => self.pen_mode = r.u16()?, + 0x09 => self.pen_pat = Pat::Bits(r.array()?), + 0x0A => self.fill_pat = Pat::Bits(r.array()?), + 0x0B => self.oval = r.size()?, + 0x0D => self.text_size = r.i16()?, + 0x0E => self.fg = old_color(r.u32()?), + 0x0F => self.bg = old_color(r.u32()?), + 0x12 => self.back_pat = self.pix_pat(r)?, + 0x13 => self.pen_pat = self.pix_pat(r)?, + 0x14 => self.fill_pat = self.pix_pat(r)?, + 0x1A => self.fg = r.rgb()?, + 0x1B => self.bg = r.rgb()?, + _ => return skip(op, r), + } + Some(()) + } + + /// The version 2 header: an extended one's source rectangle maps onto the frame. + fn header(&mut self, r: &mut Be) -> Option<()> { + let mut b = Be::new(r.bytes(24)?, 0); + if b.i16() == Some(-2) + && let Some(src) = b.skip(10).and_then(|()| b.rect()) + && src.width() > 0.0 + && src.height() > 0.0 + { + let k = Vec2::new(self.frame.width() / src.width(), self.frame.height() / src.height()); + self.base = Affine::scale_non_uniform(k.x, k.y) * Affine::translate(-src.origin().to_vec2()); + self.p.dev_to_doc = self.base * Affine::translate(-self.origin); + } + Some(()) + } + + /// The origin moves by (dh, dv): a point drawn after it is that much nearer the top left. + fn move_origin(&mut self, r: &mut Be) -> Option<()> { + let (dh, dv) = (r.i16()?, r.i16()?); + self.origin += Vec2::new(dh.into(), dv.into()); + self.p.dev_to_doc = self.base * Affine::translate(-self.origin); + Some(()) + } + + /// A pixel pattern: its color (the average of a color pattern's pixels), else its bitmap. + fn pix_pat(&mut self, r: &mut Be) -> Option { + let kind = r.u16()?; + let bits: [u8; 8] = r.array()?; + match kind { + 1 => { + let pm = PixMap::read(r, true, false)?; + let (w, h) = pm.dims()?; + let small = w * h <= MAX_PATTERN_PIXELS && pm.row_len(w) * h <= MAX_PATTERN_ROW_BYTES; + let keep = small && pm.decodable(w) && self.charge(&pm, w, h); + let (data, _) = pm.rows(r, w, h, keep)?; + let avg = keep.then(|| pm.to_rgba(&data, w, h, self.fg, self.bg)).flatten().map(|img| self.average(&img)); + Some(avg.map_or(Pat::Bits(bits), Pat::Rgb)) + } + 2 => Some(Pat::Rgb(r.rgb()?)), + _ => Some(Pat::Bits(bits)), + } + } + + /// The average color of `img`, with a warning when its pixels differ. + fn average(&mut self, img: &Rgba) -> [u8; 3] { + let px = img.pixels.as_chunks::<4>().0; + if px.windows(2).any(|w| w[0] != w[1]) { + self.p.warn(PATTERNS); + } + let n = px.len().max(1) as u64; + let sum = |i: usize| (px.iter().map(|p| u64::from(p[i])).sum::() / n) as u8; + [sum(0), sum(1), sum(2)] + } + + /// The color a pattern fills with: its own, or its two colors mixed as its bits are. + fn pat_color(&mut self, pat: Pat) -> [u8; 3] { + match pat { + Pat::Rgb(c) => c, + Pat::Bits(b) => { + let ones: u32 = b.iter().map(|x| x.count_ones()).sum(); + if ones != 0 && ones != 64 { + self.p.warn(PATTERNS); + } + mix(self.fg, self.bg, f64::from(ones) / 64.0) + } + } + } + + /// The pen's color (a warning when its mode isn't a copy). + fn pen_color(&mut self) -> [u8; 3] { + if !matches!(self.pen_mode, 0 | 8) { + self.p.warn(MODES); + } + self.pat_color(self.pen_pat) + } + + /// The color painting, erasing or filling an area takes; `None` for the other verbs. + fn area_color(&mut self, verb: Verb) -> Option<[u8; 3]> { + match verb { + Verb::Paint => Some(self.pen_color()), + Verb::Erase => Some(self.pat_color(self.back_pat)), + Verb::Fill => Some(self.pat_color(self.fill_pat)), + Verb::Frame | Verb::Invert => None, + } + } + + fn has_pen(&self) -> bool { + self.pen.x > 0.0 && self.pen.y > 0.0 + } + + /// Fill `bp` (picture space) with `color`. + fn fill(&mut self, mut bp: BezPath, color: [u8; 3], rule: FillRule) { + bp.apply_affine(self.p.xf()); + self.p.dc.brush = BrushObj::Solid(color); + self.p.dc.fill_rule = rule; + self.p.draw(bp, true, false); + } + + /// The pen as a stroke of `color` with `ends`, as wide as the pen's average side. + fn set_pen(&mut self, color: [u8; 3], ends: u32) { + let width = (self.pen.x + self.pen.y) / 2.0; + self.p.dc.pen = PenObj { style: PS_GEOMETRIC | ends | MITER, width, color, entries: vec![] }; + } + + /// Stroke `bp` (picture space) with the pen in `color`. + fn stroke(&mut self, mut bp: BezPath, color: [u8; 3], ends: u32) { + self.set_pen(color, ends); + bp.apply_affine(self.p.xf()); + self.p.draw(bp, false, true); + } + + /// Draw a framed shape: its outline (from `outline` of a rectangle and an oval size) stroked + /// inside `r`, or filled when the pen is as large as the shape. + fn frame(&mut self, r: Rect, outline: impl Fn(Rect, Vec2) -> BezPath, ends: u32) { + if !self.has_pen() { + return; + } + let color = self.pen_color(); + let half = self.pen / 2.0; + let inner = Rect::new(r.x0 + half.x, r.y0 + half.y, r.x1 - half.x, r.y1 - half.y); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return self.fill(outline(r, self.oval), color, FillRule::NonZero); + } + self.stroke(outline(inner, self.oval - self.pen), color, ends); + } + + /// The rectangle, rounded rectangle, oval and arc opcodes. + fn shape(&mut self, op: u16, r: &mut Be) -> Option<()> { + let family = op >> 4; + let slot = usize::from(family - 3); + let rect = if op & 8 != 0 { self.last.get(slot).copied()? } else { r.rect()? }; + let angles = if family == 6 { Some((r.i16()?, r.i16()?)) } else { None }; + let Some(verb) = verb(op) else { return Some(()) }; + if let Some(l) = self.last.get_mut(slot) { + *l = rect; + } + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return Some(()); + } + let outline = move |r: Rect, oval: Vec2, wedge: bool| match family { + 3 => shapes::rectangle(r).to_bezpath(), + 4 => round_rect(r, oval.x, oval.y), + 5 => kurbo::Ellipse::from_rect(r).to_path(TOLERANCE), + _ => angles.map_or_else(BezPath::new, |(start, sweep)| arc(r, start, sweep, wedge)), + }; + match verb { + Verb::Frame => self.frame(rect, |r, oval| outline(r, oval, false), if family == 6 { FLAT } else { SQUARE }), + Verb::Invert => self.p.warn(INVERTED), + _ => { + if let Some(c) = self.area_color(verb) { + self.fill(outline(rect, self.oval, true), c, FillRule::NonZero); + } + } + } + Some(()) + } + + /// A polygon opcode: framed along its points, else filled. + fn poly(&mut self, verb: Option, r: &mut Be) -> Option<()> { + let size = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(size.checked_sub(2)?)?, 8); + let Some(verb) = verb else { return Some(()) }; + let mut pts = vec![]; + while let Some(p) = b.point() { + pts.push(p); + } + let Some((first, rest)) = pts.split_first() else { return Some(()) }; + let closed = pts.len() > 2 && pts.first() == pts.last(); + match verb { + Verb::Frame if self.has_pen() => { + let half = self.pen / 2.0; + let mut bp = BezPath::new(); + bp.move_to(*first + half); + rest.iter().for_each(|p| bp.line_to(*p + half)); + if closed { + bp.close_path(); + } + let color = self.pen_color(); + self.stroke(bp, color, SQUARE); + } + Verb::Frame => {} + Verb::Invert => self.p.warn(INVERTED), + _ => { + let mut bp = BezPath::new(); + bp.move_to(*first); + rest.iter().for_each(|p| bp.line_to(*p)); + bp.close_path(); + if let Some(c) = self.area_color(verb) { + self.fill(bp, c, FillRule::EvenOdd); + } + } + } + Some(()) + } + + /// A region's rectangles, with a warning when it was too large to read. + fn read_region(&mut self, r: &mut Be) -> Option> { + let (rects, whole) = region(r, &mut self.region_work)?; + if !whole { + self.p.warn(BIG_REGIONS); + } + Some(rects) + } + + /// A region opcode: each rectangle framed, else the region filled. + fn region_shape(&mut self, verb: Option, r: &mut Be) -> Option<()> { + let rects = self.read_region(r)?; + let Some(verb) = verb.filter(|_| !rects.is_empty()) else { return Some(()) }; + match verb { + Verb::Frame if self.has_pen() => { + if rects.len() > 1 { + self.p.warn(FRAMED_REGIONS); + } + let half = self.pen / 2.0; + let inner: Vec = rects.iter().map(|r| Rect::new(r.x0 + half.x, r.y0 + half.y, r.x1 - half.x, r.y1 - half.y)).collect(); + let color = self.pen_color(); + self.stroke(rects_path(&inner), color, SQUARE); + } + Verb::Frame => {} + Verb::Invert => self.p.warn(INVERTED), + _ => { + if let Some(c) = self.area_color(verb) { + self.fill(rects_path(&rects), c, FillRule::NonZero); + } + } + } + Some(()) + } + + /// The clip region: it replaces the clip. An empty region clips away everything after it, as + /// a clip of no area at the frame's corner; one too large to read leaves the clip as it was. + fn clip(&mut self, r: &mut Be) -> Option<()> { + let (mut rects, whole) = region(r, &mut self.region_work)?; + if !whole { + self.p.warn(BIG_REGIONS); + return Some(()); + } + if rects.is_empty() { + let corner = Point::new(self.frame.x0, self.frame.y0); + rects.push(Rect::from_points(corner, corner)); + } + let mut bp = rects_path(&rects); + bp.apply_affine(self.p.xf()); + self.p.push_clip(bp, FillRule::NonZero, true); + Some(()) + } + + /// The line opcodes: the pen drawn from point to point, hanging below and to the right of + /// the line; lines one after another join into one path. + fn line(&mut self, op: u16, r: &mut Be) -> Option<()> { + let delta = |r: &mut Be| Some(Vec2::new(f64::from(r.u8()? as i8), f64::from(r.u8()? as i8))); + let (from, to) = match op { + 0x20 => (r.point()?, r.point()?), + 0x21 => (self.pen_loc, r.point()?), + 0x22 => { + let a = r.point()?; + (a, a + delta(r)?) + } + _ => (self.pen_loc, self.pen_loc + delta(r)?), + }; + self.pen_loc = to; + if !self.has_pen() { + return Some(()); + } + let color = self.pen_color(); + if from == to { + // A dot: the pen's rectangle. + self.p.flush(); + self.fill(shapes::rectangle(Rect::from_points(from, from + self.pen)).to_bezpath(), color, FillRule::NonZero); + return Some(()); + } + let pen = PenObj { style: PS_GEOMETRIC | SQUARE | MITER, width: (self.pen.x + self.pen.y) / 2.0, color, entries: vec![] }; + if self.p.dc.pen != pen { + self.p.flush(); + self.p.dc.pen = pen; + } + let half = self.pen / 2.0; + let xf = self.p.xf(); + let (a, b) = (xf * (from + half), xf * (to + half)); + if self.p.pending.elements().last().and_then(|e| e.end_point()) != Some(a) { + self.p.pending.move_to(a); + } + self.p.pending.line_to(b); + Some(()) + } + + /// The text opcodes: a string at the text location, moved to or by the opcode's data. + fn text(&mut self, op: u16, r: &mut Be) -> Option<()> { + match op { + 0x28 => self.text_loc = r.point()?, + 0x29 => self.text_loc.x += f64::from(r.u8()?), + 0x2A => self.text_loc.y += f64::from(r.u8()?), + _ => { + self.text_loc.x += f64::from(r.u8()?); + self.text_loc.y += f64::from(r.u8()?); + } + } + let n = usize::from(r.u8()?); + let s: String = r.bytes(n)?.iter().map(|&b| mac_roman(b)).filter(|c| !c.is_control()).collect(); + let size = if self.text_size > 0 { self.text_size } else { 12 }; + let face = self.names.get(&self.font).cloned().unwrap_or_else(|| font_family(self.font).to_string()); + self.p.dc.font = FontObj { + height: -f64::from(size), + escapement: 0.0, + weight: if self.face & 1 != 0 { 700 } else { 400 }, + italic: self.face & 2 != 0, + face, + }; + self.p.dc.text_color = self.fg; + self.p.dc.text_align = TA_BASELINE; + self.p.text(self.text_loc, &s); + Some(()) + } + + /// A font's name for its number. + fn font_name(&mut self, r: &mut Be) -> Option<()> { + let n = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(n)?, 0); + if let Some(id) = b.u16() + && let Some(len) = b.u8() + && let Some(name) = b.bytes(usize::from(len)) + { + let name: String = name.iter().map(|&c| mac_roman(c)).collect(); + self.names.insert(id, name.trim().to_string()); + } + Some(()) + } + + /// A bitmap or pixel map copied into a rectangle (`Rgn` opcodes: clipped to a mask region). + fn bits(&mut self, op: u16, r: &mut Be) -> Option<()> { + let direct = matches!(op, 0x9A | 0x9B); + if direct { + r.skip(4)?; + } + let mut pm = PixMap::read(r, direct, direct)?; + // BitsRect and BitsRgn store their rows unpacked. + pm.packed = op >= 0x98; + let (src, dst, mode) = (r.rect()?, r.rect()?, r.u16()?); + let mask = if op & 1 != 0 { Some(self.read_region(r)?) } else { None }; + let (w, h) = pm.dims()?; + let (wide, high) = (w as u64, h as u64); + let fits = wide * high <= dib::MAX_READ_PIXELS && pm.row_len(w) as u64 * high <= MAX_ROW_BYTES; + let readable = pm.decodable(w); + let keep = fits && readable && self.charge(&pm, w, h); + let (data, ok) = pm.rows(r, w, h, keep)?; + if !keep { + self.p.warn(if readable { TOO_MANY_PIXELS } else { BAD_IMAGE }); + return Some(()); + } + let Some(mut img) = pm.to_rgba(&data, w, h, self.fg, self.bg).filter(|_| ok) else { + self.p.warn(BAD_IMAGE); + return Some(()); + }; + self.transfer(&mut img, mode, pm.size == 1 && pm.palette.is_none()); + if dst.width() <= 0.0 || dst.height() <= 0.0 || mask.as_ref().is_some_and(Vec::is_empty) { + return Some(()); + } + let saved = self.p.dc.clips.clone(); + if let Some(rects) = &mask { + let mut bp = rects_path(rects); + bp.apply_affine(self.p.xf()); + self.p.push_clip(bp, FillRule::NonZero, false); + } + self.p.image(Ok(img), src - pm.bounds.origin().to_vec2(), dst, SRCCOPY, 1.0); + self.p.dc.clips = saved; + Some(()) + } + + /// Transfer mode `mode` on copied pixels: OR of a bitmap and the transparent mode leave the + /// background color out; the others are copied, with a warning. + fn transfer(&mut self, img: &mut Rgba, mode: u16, bitmap: bool) { + // 64 dithers, which doesn't change what is copied. + let clear = match mode & !64 { + 0 => false, + 1 if bitmap => true, + 36 => true, + _ => { + self.p.warn(MODES); + false + } + }; + if clear { + let bg = self.bg; + img.pixels.as_chunks_mut::<4>().0.iter_mut().filter(|p| [p[0], p[1], p[2]] == bg).for_each(|p| p[3] = 0); + } + } + + /// A compressed QuickTime image: a JPEG one becomes an image placed by its matrix. + fn quicktime(&mut self, r: &mut Be) -> Option<()> { + let n = usize::try_from(r.u32()?).ok()?; + let data = r.bytes(n)?; + match qt_image(data) { + Some((ctype, xf, src, image)) if &ctype == b"jpeg" => match self.jpeg(image) { + Ok(img) => { + let img = crop(img, src); + self.p.image_xf(img, xf * Affine::translate(src.origin().to_vec2()), 1.0); + } + Err(w) => self.p.warn(w), + }, + Some(_) => self.p.warn(CODECS), + None => self.p.warn(BAD_IMAGE), + } + Some(()) + } + + /// A JPEG stream's pixels, taken from the file's pixel budget. + fn jpeg(&mut self, data: &[u8]) -> Result { + let reader = || image::ImageReader::with_format(Cursor::new(data), image::ImageFormat::Jpeg); + let (w, h) = reader().into_dimensions().map_err(|_| BAD_IMAGE)?; + if w > dib::MAX_READ_SIDE || h > dib::MAX_READ_SIDE || u64::from(w) * u64::from(h) > dib::MAX_READ_PIXELS { + return Err(TOO_MANY_PIXELS); + } + dib::charge(w, h, &mut self.p.pixels_left).map_err(|_| TOO_MANY_PIXELS)?; + let img = reader().decode().map_err(|_| BAD_IMAGE)?.to_rgba8(); + Ok(Rgba { width: img.width(), height: img.height(), pixels: img.into_raw() }) + } +} + +/// A QuickTime image's compressor type, matrix (image → picture space), source rectangle and +/// compressed data, from its header and image description. +fn qt_image(d: &[u8]) -> Option<([u8; 4], Affine, Rect, &[u8])> { + let mut b = Be::new(d, 2); + let mut m = [0.0; 9]; + for v in &mut m { + *v = f64::from(b.i32()?) / 65536.0; + } + let matte = usize::try_from(b.u32()?).ok()?; + b.skip(10)?; + let src = b.rect()?; + b.skip(4)?; + let mask = usize::try_from(b.u32()?).ok()?; + b.skip(matte)?; + b.skip(mask)?; + let start = b.pos; + let id_size = usize::try_from(b.u32()?).ok()?; + let ctype = b.array::<4>()?; + b.skip(36)?; + let size = usize::try_from(b.u32()?).ok()?; + let at = start.checked_add(id_size)?; + let data = d.get(at..at.checked_add(size)?).or_else(|| d.get(at..))?; + // The rows of a QuickTime matrix are [a b u] [c d v] [tx ty w]; u, v and w (perspective) are + // left out. + let xf = Affine::new([m[0], m[1], m[3], m[4], m[6], m[7]]); + Some((ctype, xf, src, data)) +} + +/// Read a picture. +pub(crate) fn play(bytes: &[u8]) -> Result { + let h = find(bytes).ok_or(NOT_PICT)?; + let mut q = Pict::new(h.frame); + let mut r = Be::new(bytes, h.ops); + // Why the picture stopped before its end opcode, if it did. + let mut stopped = Some(TOO_MANY_OPS); + for _ in 0..MAX_OPS { + // Version 2 opcodes start on even bytes. + if h.v2 && r.pos % 2 == 1 && r.skip(1).is_none() { + stopped = Some(CUT_SHORT); + break; + } + let Some(op) = (if h.v2 { r.u16() } else { r.u8().map(u16::from) }) else { + stopped = Some(CUT_SHORT); + break; + }; + if op == 0x00FF { + stopped = None; + break; + } + if q.op(op, &mut r).is_none() { + stopped = Some(CUT_SHORT); + break; + } + } + if let Some(why) = stopped { + q.p.warn(why); + } + q.p.flush(); + if q.p.out.is_empty() { + q.p.warn(NOTHING); + } + Ok(q.p.finish()) +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index a517d516a6..0e699236d7 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -15,13 +15,14 @@ //! //! [`import`] reads either format back into a document of one artboard (the picture's frame) and //! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are -//! skipped, with one warning. +//! skipped, with one warning. [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2) +//! into the same kind of document. //! //! - `bytes`: little-endian writing and bounds-checked reading. //! - `dib`: device-independent bitmaps, both ways. //! - `scene`: the document walk both writers share. //! - `emf`, `wmf`: the writers. -//! - `import`: the reader. +//! - `import`: the readers. mod bytes; mod dib; @@ -107,5 +108,28 @@ pub fn import(bytes: &[u8]) -> Result { Ok(i) } +/// A QuickDraw picture read into a document. +#[derive(Clone, Debug)] +pub struct Picture { + pub document: Document, + /// What was approximated or skipped. + pub warnings: Vec, +} + +/// Do `bytes` hold a version 2 QuickDraw picture, after a PICT file's 512 bytes of application +/// data or on their own? A version 1 picture is known by its file's name. +pub fn is_pict(bytes: &[u8]) -> bool { + import::pict::is_pict(bytes) +} + +/// Read a QuickDraw picture (version 1 or 2; a PICT file, or a picture on its own) into a document +/// whose one artboard is the picture's frame. +pub fn import_pict(bytes: &[u8]) -> Result { + let i = import::pict::play(bytes)?; + let mut p = Picture { document: i.document, warnings: i.warnings }; + p.document.limit_depth_noting(&mut p.warnings); + Ok(p) +} + #[cfg(test)] mod tests; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 23bcfdc672..602db3d55f 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -12,6 +12,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; use crate::*; mod import; +mod pict; /// Add `n` (with a fresh id) on top of `d`'s first layer. pub(crate) fn add(d: &mut Document, mut n: Node) { diff --git a/crates/metafile/src/tests/pict.rs b/crates/metafile/src/tests/pict.rs new file mode 100644 index 0000000000..055e88c95b --- /dev/null +++ b/crates/metafile/src/tests/pict.rs @@ -0,0 +1,466 @@ +//! QuickDraw pictures read from hand-built opcodes: versions 1 and 2, with and without the file's +//! application data; shapes, lines, regions, patterns, text, bitmaps, pixel maps and QuickTime +//! JPEG images; and pictures that are cut short, damaged or never end. + +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use crate::import_pict; + +/// Big-endian words. +fn be(v: &[i16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A QuickDraw rectangle: top, left, bottom, right. +fn rect(top: i16, left: i16, bottom: i16, right: i16) -> Vec { + be(&[top, left, bottom, right]) +} + +/// A version 2 PICT file: 512 bytes of application data, then a picture of `frame` (top, left, +/// bottom, right) holding `ops`, each padded to an even length, and the end opcode. +fn v2(frame: [i16; 4], ops: &[(u16, Vec)]) -> Vec { + let mut b = vec![0xA5; 512]; + b.extend([0, 0]); + b.extend(be(&frame)); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(be(&[-1, 0])); + b.extend([0; 20]); + for (op, data) in ops { + b.extend(op.to_be_bytes()); + b.extend(data); + if b.len() % 2 == 1 { + b.push(0); + } + } + b.extend([0x00, 0xFF]); + b +} + +/// An RGB foreground color. +fn fg(r: u16, g: u16, b: u16) -> (u16, Vec) { + (0x1A, [r, g, b].iter().flat_map(|c| c.to_be_bytes()).collect()) +} + +/// Every object of the imported document, depth first. +fn all(d: &Document) -> Vec { + let mut v = vec![]; + for l in &d.layers { + for c in l.children().into_iter().flatten() { + c.walk(&mut |n| v.push(n.clone())); + } + } + v +} + +fn paths(d: &Document) -> Vec { + all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect() +} + +fn near(a: Rect, b: Rect, tol: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol) +} + +fn fill_rgb(n: &Node) -> Option<[u8; 3]> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => f.paint.color().map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }), + _ => None, + }) +} + +fn stroke(n: &Node) -> Option<([u8; 3], f64)> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => s.paint.color().map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + ([r, g, b], s.width) + }), + _ => None, + }) +} + +/// The images of `d` with their pixels. +fn images(d: &Document) -> Vec<(Node, image::RgbaImage)> { + all(d) + .into_iter() + .filter_map(|n| match &n.kind { + NodeKind::Image(im) => { + let png = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + Some((n.clone(), png)) + } + _ => None, + }) + .collect() +} + +#[test] +fn a_version_2_picture_opens_as_shapes_in_its_frame() { + let bytes = v2( + [10, 20, 110, 220], + &[ + fg(0xFFFF, 0, 0), + // paintRect + (0x31, rect(20, 30, 60, 80)), + (0x07, be(&[4, 4])), + fg(0, 0, 0xFFFF), + // frameRect: stroked inside the rectangle. + (0x30, rect(20, 120, 80, 200)), + // A line, and one from its end: one path. + (0x20, be(&[90, 40, 90, 140])), + (0x21, be(&[100, 140])), + ], + ); + assert!(crate::is_pict(&bytes)); + let p = import_pict(&bytes).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let a = p.document.artboards[0].rect; + assert!(near(a, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-9), "{a:?}"); + let v = paths(&p.document); + assert_eq!(v.len(), 3, "{v:?}"); + assert_eq!(fill_rgb(&v[0]), Some([255, 0, 0])); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 50.0), 1e-6)); + assert!(v[0].appearance.stroke().is_none()); + // The pen's outer edge is the rectangle. + assert_eq!(stroke(&v[1]), Some(([0, 0, 255], 4.0))); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(102.0, 12.0, 178.0, 68.0), 1e-6)); + assert!(fill_rgb(&v[1]).is_none()); + // The pen hangs below and to the right of the line. + assert_eq!(stroke(&v[2]), Some(([0, 0, 255], 4.0))); + assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(22.0, 82.0, 122.0, 92.0), 1e-6)); +} + +#[test] +fn a_bare_picture_and_a_version_1_picture_open() { + // A version 2 picture without the file's application data. + let file = v2([0, 0, 50, 50], &[(0x31, rect(10, 10, 20, 20))]); + let bare = &file[512..]; + assert!(crate::is_pict(bare)); + assert_eq!(paths(&import_pict(bare).unwrap().document).len(), 1); + // Version 1: byte opcodes, known by name only. + let mut v1 = vec![0, 0]; + v1.extend(rect(0, 0, 40, 60)); + v1.extend([0x11, 0x01, 0x31]); + v1.extend(rect(5, 5, 15, 25)); + v1.extend([0xA0, 0x00, 0x05, 0x50]); + v1.extend(rect(20, 20, 30, 30)); + v1.push(0xFF); + assert!(!crate::is_pict(&v1)); + let p = import_pict(&v1).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + assert_eq!(p.document.artboards[0].rect.size(), (60.0, 40.0).into()); + let v = paths(&p.document); + assert_eq!(v.len(), 2); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 25.0, 15.0), 1e-6)); + // The default pen: one point, black. + assert_eq!(stroke(&v[1]), Some(([0, 0, 0], 1.0))); + // Inside a file's application data too. + let mut file = vec![0; 512]; + file.extend(&v1); + assert_eq!(paths(&import_pict(&file).unwrap().document).len(), 2); +} + +#[test] +fn files_that_are_not_pictures_are_refused() { + assert_eq!(import_pict(&[0; 600]).unwrap_err(), "not a PICT file"); + assert_eq!(import_pict(b"").unwrap_err(), "not a PICT file"); + // An empty frame. + let mut b = vec![0, 0]; + b.extend(rect(10, 10, 10, 50)); + b.extend([0x11, 0x01, 0xFF]); + assert!(import_pict(&b).is_err()); +} + +#[test] +fn rounded_rectangles_ovals_arcs_and_same_shapes() { + let bytes = v2( + [0, 0, 100, 100], + &[ + (0x0B, be(&[20, 20])), + (0x41, rect(0, 0, 50, 50)), + (0x51, rect(50, 0, 100, 50)), + // A wedge from 12 to 3 o'clock. + (0x61, [rect(0, 50, 100, 150), be(&[0, 90])].concat()), + fg(0, 0xFFFF, 0), + // paintSameRect after paintRect: the same rectangle. + (0x31, rect(60, 60, 70, 80)), + (0x39, vec![]), + ], + ); + let p = import_pict(&bytes).unwrap(); + let v = paths(&p.document); + assert_eq!(v.len(), 5, "{v:?}"); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 50.0), 1e-6)); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 50.0, 50.0, 100.0), 1e-3)); + assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(100.0, 0.0, 150.0, 50.0), 1e-3), "{:?}", v[2].geometric_bounds()); + assert_eq!(v[3].geometric_bounds(), v[4].geometric_bounds()); + assert_eq!(fill_rgb(&v[4]), Some([0, 255, 0])); +} + +#[test] +fn polygons_and_regions_fill_and_clip() { + // An L: the top band 20 wide, the bottom band 10. + let region = [be(&[36]), rect(0, 0, 20, 20), be(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat(); + let poly = [be(&[22]), rect(0, 0, 40, 40), be(&[0, 0, 40, 40, 0, 40])].concat(); + let bytes = v2([0, 0, 100, 100], &[(0x81, region.clone()), (0x71, poly), (0x01, region), (0x31, rect(0, 0, 100, 100))]); + let p = import_pict(&bytes).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let v = paths(&p.document); + assert_eq!(v.len(), 3); + let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") }; + assert_eq!(path.subpaths.len(), 2, "two bands"); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9)); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 40.0, 40.0), 1e-9)); + // The rectangle after the clip region is clipped to it. + let layer = p.document.layers[0].children().unwrap(); + let NodeKind::Group { children, clip: true } = &layer[2].kind else { panic!("a clipping group: {:?}", layer[2].kind) }; + assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9)); +} + +#[test] +fn patterns_fill_with_their_average_color() { + let gray = vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]; + let bytes = v2( + [0, 0, 10, 10], + &[(0x0A, gray), (0x34, rect(0, 0, 10, 10)), (0x12, [be(&[2]), vec![0; 8], be(&[0, -1, 0])].concat()), (0x32, rect(0, 0, 5, 5))], + ); + let p = import_pict(&bytes).unwrap(); + let v = paths(&p.document); + assert_eq!(fill_rgb(&v[0]), Some([128, 128, 128])); + // eraseRect with an RGB background pattern. + assert_eq!(fill_rgb(&v[1]), Some([0, 255, 0])); + assert!(p.warnings.iter().any(|w| w.contains("average of their colors")), "{:?}", p.warnings); +} + +#[test] +fn text_comes_in_as_point_type_in_the_named_font() { + let name = [be(&[9, 300]), vec![6], b"Geneva".to_vec()].concat(); + let bytes = v2( + [0, 0, 100, 200], + &[(0x2C, name), (0x03, be(&[300])), (0x0D, be(&[18])), (0x04, vec![1]), (0x28, [be(&[50, 10]), vec![4], b"Caf\x8E".to_vec()].concat())], + ); + let p = import_pict(&bytes).unwrap(); + let texts: Vec<_> = all(&p.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).collect(); + let NodeKind::Text(t) = &texts[0].kind else { panic!("text") }; + assert_eq!(t.plain_text(), "Café"); + let st = &t.runs[0].style; + assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Geneva", "Bold", 18.0)); + // The text location is the baseline's start. + assert_eq!(t.xf.translation(), Point::new(10.0, 50.0).to_vec2()); +} + +/// A pixel map's fields after `rowBytes` and its bounds: version, packing, resolution, depth and +/// components. +fn pixmap_fields(pack: i16, size: i16, cmps: i16) -> Vec { + be(&[0, pack, 0, 0, 72, 0, 72, 0, 0, size, cmps, 8, 0, 0, 0, 0, 0, 0]) +} + +#[test] +fn a_packed_indexed_pixel_map_becomes_an_image() { + let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]); + // An 8 × 2 map: a run of red, then blue and red by turns. + let rows = [vec![2, 0xF9, 0], vec![9, 7, 1, 0, 1, 0, 1, 0, 1, 0]].concat(); + let data = + [be(&[0x8008u16 as i16]), rect(0, 0, 2, 8), pixmap_fields(0, 8, 1), table, rect(0, 0, 2, 8), rect(10, 20, 14, 36), be(&[0]), rows].concat(); + let p = import_pict(&v2([0, 0, 50, 50], &[(0x98, data)])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + let (n, px) = &imgs[0]; + assert_eq!(px.dimensions(), (8, 2)); + assert_eq!(px.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(px.get_pixel(0, 1).0, [0, 0, 255, 255]); + assert_eq!(px.get_pixel(1, 1).0, [255, 0, 0, 255]); + // Drawn into its destination, twice its size. + assert!(near(n.geometric_bounds().unwrap(), Rect::new(20.0, 10.0, 36.0, 14.0), 1e-9)); +} + +#[test] +fn direct_pixel_maps_and_masked_bitmaps() { + // 32-bit, packed by component planes: red, then green. + let row = vec![7, 5, 255, 0, 0, 255, 0, 0]; + let direct = + [be(&[0, 0xFF, 0x8008u16 as i16]), rect(0, 0, 1, 2), pixmap_fields(4, 32, 3), rect(0, 0, 1, 2), rect(0, 0, 1, 2), be(&[0]), row].concat(); + // A one-bit bitmap ORed in (its clear bits left out), masked by a region. + let mask = [be(&[10]), rect(0, 0, 1, 1)].concat(); + let bitmap = [be(&[2]), rect(0, 0, 1, 2), rect(0, 0, 1, 2), rect(5, 0, 6, 2), be(&[1]), mask, vec![0x80, 0]].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x9A, direct), (0x91, bitmap)])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 2); + assert_eq!(imgs[0].1.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(imgs[0].1.get_pixel(1, 0).0, [0, 255, 0, 255]); + assert_eq!(imgs[1].1.get_pixel(0, 0).0, [0, 0, 0, 255]); + assert_eq!(imgs[1].1.get_pixel(1, 0).0[3], 0, "the clear bit is left out"); + let layer = p.document.layers[0].children().unwrap(); + assert!(matches!(layer[1].kind, NodeKind::Group { clip: true, .. }), "the mask region clips the bitmap"); +} + +/// A pixel map's rows may be only a little longer than its pixels need: a map one pixel wide +/// with rows of 16,383 bytes is left out, and a pixel pattern like it fills with its bitmap. +#[test] +fn rows_far_longer_than_their_pixels_are_refused() { + let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, -1, 0, 0]); + // A row of 16,383 ones: 127 runs of 128 and one of 127, after its byte count. + let mut row = be(&[256]); + (0..127).for_each(|_| row.extend([0x81, 1])); + row.extend([0x82, 1]); + let rows = row.repeat(4); + let map = [be(&[0xBFFFu16 as i16]), rect(0, 0, 4, 1), pixmap_fields(0, 8, 1), table.clone()].concat(); + let data = [map, rect(0, 0, 4, 1), rect(0, 0, 4, 1), be(&[0]), rows].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x98, data)])).unwrap(); + assert!(images(&p.document).is_empty()); + assert!(p.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", p.warnings); + + let pattern = [be(&[1]), vec![0xFF; 8], be(&[0xBFFFu16 as i16]), rect(0, 0, 1, 1), pixmap_fields(0, 8, 1), table, row].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x12, pattern), (0x32, rect(0, 0, 5, 5))])).unwrap(); + assert_eq!(fill_rgb(&paths(&p.document)[0]), Some([0, 0, 0]), "the pattern's bitmap in the foreground color"); +} + +/// BitsRect rows are stored unpacked, also when a row is 8 bytes or longer. +#[test] +fn bitmap_rows_of_eight_bytes_or_more_are_read_unpacked() { + let row = vec![0x80, 0, 0, 0, 0, 0, 0, 0]; + let bitmap = [be(&[8]), rect(0, 0, 1, 16), rect(0, 0, 1, 16), rect(0, 0, 1, 16), be(&[0]), row].concat(); + let p = import_pict(&v2([0, 0, 10, 20], &[(0x90, bitmap), (0x31, rect(2, 2, 4, 4))])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0].1.dimensions(), (16, 1)); + assert_eq!(imgs[0].1.get_pixel(0, 0).0, [0, 0, 0, 255]); + assert_eq!(imgs[0].1.get_pixel(1, 0).0, [255, 255, 255, 255]); + assert_eq!(paths(&p.document).len(), 1, "the rectangle after it"); +} + +/// An empty clip region clips away what follows it. +#[test] +fn an_empty_clip_region_hides_what_follows() { + let empty = [be(&[10]), rect(0, 0, 0, 0)].concat(); + let p = import_pict(&v2([0, 0, 50, 50], &[(0x01, empty), (0x31, rect(0, 0, 10, 10))])).unwrap(); + let layer = p.document.layers[0].children().unwrap(); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + let b = children[0].geometric_bounds().unwrap(); + assert_eq!((b.width(), b.height()), (0.0, 0.0), "{b:?}"); +} + +/// A QuickTime image of `ctype` with `matrix` (a, b, c, d, tx, ty in points) and source rectangle +/// `w` × `h`, holding `data`. +fn quicktime(ctype: &[u8; 4], matrix: [f64; 6], w: i16, h: i16, data: &[u8]) -> Vec { + let fixed = |v: f64| ((v * 65536.0) as i32).to_be_bytes(); + let [a, b, c, d, tx, ty] = matrix; + let mut q = be(&[0]); + for v in [a, b, 0.0, c, d, 0.0, tx, ty] { + q.extend(fixed(v)); + } + q.extend((1i32 << 30).to_be_bytes()); + q.extend([0; 4]); + q.extend([0; 8]); + q.extend(be(&[0])); + q.extend(rect(0, 0, h, w)); + q.extend([0; 4]); + q.extend([0; 4]); + let mut desc = vec![]; + desc.extend(86u32.to_be_bytes()); + desc.extend(ctype); + desc.extend([0; 24]); + desc.extend(be(&[w, h])); + desc.extend([0; 8]); + desc.extend((data.len() as u32).to_be_bytes()); + desc.resize(86, 0); + q.extend(desc); + q.extend(data); + [(q.len() as u32).to_be_bytes().to_vec(), q].concat() +} + +fn red_jpeg() -> Vec { + let img = image::RgbImage::from_pixel(4, 2, image::Rgb([255, 0, 0])); + let mut out = std::io::Cursor::new(vec![]); + img.write_to(&mut out, image::ImageFormat::Jpeg).unwrap(); + out.into_inner() +} + +#[test] +fn quicktime_jpeg_images_are_placed_by_their_matrix() { + let jpeg = red_jpeg(); + let ops = [ + (0x8200, quicktime(b"jpeg", [2.0, 0.0, 0.0, 2.0, 10.0, 5.0], 4, 2, &jpeg)), + (0x8200, quicktime(b"rle ", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 4, 2, &[1, 2, 3])), + ]; + let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap(); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + let (n, px) = &imgs[0]; + assert_eq!(px.dimensions(), (4, 2)); + let [r, g, b, _] = px.get_pixel(1, 1).0; + assert!(r > 240 && g < 16 && b < 16, "{r} {g} {b}"); + assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 5.0, 18.0, 9.0), 1e-9)); + assert!(p.warnings.iter().any(|w| w.contains("other than JPEG")), "{:?}", p.warnings); +} + +/// An image whose matrix flattens it is left out, and adds nothing to the document's images. +#[test] +fn a_flattened_image_adds_no_image_data() { + let p = import_pict(&v2([0, 0, 50, 50], &[(0x8200, quicktime(b"jpeg", [0.0; 6], 4, 2, &red_jpeg()))])).unwrap(); + assert!(images(&p.document).is_empty()); + assert!(p.document.images.is_empty(), "{:?}", p.document.images.keys().collect::>()); +} + +#[test] +fn pictures_cut_short_keep_what_came_before() { + let full = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0x31, rect(20, 20, 30, 30))]); + // Cut inside the second rectangle. + let cut = &full[..full.len() - 6]; + let p = import_pict(cut).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings); + // A long comment longer than the file. + let p = import_pict(&v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0xA1, be(&[100, 5000]))])).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings); + // Nothing drawn. + let p = import_pict(&v2([0, 0, 50, 50], &[(0xA0, be(&[100]))])).unwrap(); + assert!(p.warnings.iter().any(|w| w.contains("draws nothing")), "{:?}", p.warnings); +} + +#[test] +fn reserved_opcodes_are_skipped_by_their_lengths() { + let ops = [ + // A 16-bit length, a fixed length for Apple's reserved words, and a 32-bit length. + (0x0024, be(&[4, 1, 2])), + (0x0200, be(&[1, 2])), + (0x00D0, [8u32.to_be_bytes().to_vec(), vec![9; 8]].concat()), + (0x8201, [2u32.to_be_bytes().to_vec(), vec![9; 2]].concat()), + (0x31, rect(0, 0, 10, 10)), + ]; + let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap(); + assert_eq!(paths(&p.document).len(), 1, "{:?}", p.warnings); +} + +/// A picture of nothing but no-ops stops at the opcode limit. +#[test] +fn a_picture_that_never_ends_stops_at_the_opcode_limit() { + let mut b = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10))]); + b.truncate(b.len() - 2); + b.resize(b.len() + 2 * ((1 << 22) + 1), 0); + let p = import_pict(&b).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("more opcodes")), "{:?}", p.warnings); +} + +/// Regions share one budget of inversion points per picture: past it, a region is left out. +#[test] +fn regions_past_the_picture_budget_are_left_out() { + // One scanline of 16,000 inversion points: 8,000 rectangles. + let mut words = vec![0]; + words.extend(0..16_000i16); + words.extend([0x7FFF, 1, 0x7FFF, 0x7FFF]); + let body = [rect(0, 0, 1, 16_000), be(&words)].concat(); + let region = [be(&[(body.len() + 2) as i16]), body].concat(); + let p = import_pict(&v2([0, 0, 300, 300], &[(0x81, region.clone()), (0x81, region.clone()), (0x81, region)])).unwrap(); + assert!(p.warnings.iter().any(|w| w.contains("regions larger")), "{:?}", p.warnings); + let v = paths(&p.document); + assert_eq!(v.len(), 2); + let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") }; + assert_eq!(path.subpaths.len(), 8_000); +} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 33df72c253..13b24da639 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5812,6 +5812,7 @@ digits Malayalam Malaiàlam @msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`) @msg not an EMF or WMF file no és un fitxer EMF o WMF @msg not an HP-GL file no és un fitxer HP-GL +@msg not a PICT file no és un fitxer PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ff48f1ecfc..472c387521 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5012,6 +5012,7 @@ imageTrace Outline Obrys @msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0} @msg not an EMF or WMF file nejde o soubor EMF ani WMF @msg not an HP-GL file nejde o soubor HP-GL +@msg not a PICT file nejde o soubor PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musí být pole 1 až 255 barev #rrggbb @msg not found nenalezeno @msg not in pattern editing mode není aktivní režim úprav vzorku diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d3b12f63a..5080e87138 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4629,6 +4629,7 @@ theme Appearance Erscheinungsbild @msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0} @msg not an EMF or WMF file keine EMF- oder WMF-Datei @msg not an HP-GL file keine HP-GL-Datei +@msg not a PICT file keine PICT-Datei @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` muss ein Array von 1 bis 255 Farben der Form #rrggbb sein @msg not found nicht gefunden @msg not in pattern editing mode nicht im Musterbearbeitungsmodus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8467b8e831..5550f9e15f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4657,6 +4657,7 @@ digits Malayalam Μαλαγιάλαμ @msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0} @msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF @msg not an HP-GL file δεν είναι αρχείο HP-GL +@msg not a PICT file δεν είναι αρχείο PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors το `pens` πρέπει να είναι πίνακας 1 έως 255 χρωμάτων #rrggbb @msg not found δεν βρέθηκε @msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 194ba2c602..d750ab8d0d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4704,6 +4704,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0} @msg not an EMF or WMF file no es un archivo EMF ni WMF @msg not an HP-GL file no es un archivo HP-GL +@msg not a PICT file no es un archivo PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` debe ser una lista de 1 a 255 colores #rrggbb @msg not found no se encontró @msg not in pattern editing mode no está en el modo de edición de motivos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e36c5e891e..b366a462b1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4918,6 +4918,7 @@ imageTrace Outline Ääriviiva @msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0} @msg not an EMF or WMF file ei EMF- tai WMF-tiedosto @msg not an HP-GL file ei HP-GL-tiedosto +@msg not a PICT file ei PICT-tiedosto @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` on oltava 1–255 #rrggbb-värin taulukko @msg not found ei löytynyt @msg not in pattern editing mode ei kuvion muokkaustilassa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 067845f493..9b2bb5569a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4693,6 +4693,7 @@ axis Both Les deux @msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0} @msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF @msg not an HP-GL file ce n’est pas un fichier HP-GL +@msg not a PICT file ce n’est pas un fichier PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` doit être une liste de 1 à 255 couleurs #rrggbb @msg not found introuvable @msg not in pattern editing mode pas en mode d’édition de motif diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 165e04ae7d..f06be2e82a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7369,6 +7369,7 @@ antiAlias Sharp शार्प @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है +@msg not a PICT file PICT फ़ाइल नहीं है @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2ccf9d57bc..c26bf6420b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5389,6 +5389,7 @@ imageTrace Outline Körvonal @msg not a blend nem összemosás @msg not an EMF or WMF file nem EMF- vagy WMF-fájl @msg not an HP-GL file nem HP-GL-fájl +@msg not a PICT file nem PICT-fájl @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`: 1–255 #rrggbb szín tömbje kell @msg not found nem található @msg not in pattern editing mode nem mintázatszerkesztő módban vagy diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be83d78b43..0c97004be1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4755,6 +4755,7 @@ axis Both Entrambi @msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0} @msg not an EMF or WMF file non è un file EMF o WMF @msg not an HP-GL file non è un file HP-GL +@msg not a PICT file non è un file PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve essere un elenco da 1 a 255 colori #rrggbb @msg not found non trovato @msg not in pattern editing mode non sei in modalità modifica motivo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b27c6d2698..98a8a7c8ac 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4744,6 +4744,7 @@ swatch Bright Violet ビビッドバイオレット @msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません @msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません @msg not an HP-GL file HP-GL ファイルではありません +@msg not a PICT file PICT ファイルではありません @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` は 1〜255 個の #rrggbb 形式のカラーの配列にしてください @msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません @msg the EMF header is damaged EMF ヘッダーが破損しています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4ba40baab0..7cdb2dfb86 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4382,6 +4382,7 @@ font-technology Variable ცვლადი @msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0} @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი @msg not an HP-GL file ეს არ არის HP-GL ფაილი +@msg not a PICT file ეს არ არის PICT ფაილი @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` უნდა იყოს 1-დან 255-მდე #rrggbb ფერის მასივი @msg not found ვერ მოიძებნა @msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c46ce6b4fa..c886f8c035 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4671,6 +4671,7 @@ theme Appearance 외관 @msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다 @msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다 @msg not an HP-GL file HP-GL 파일이 아닙니다 +@msg not a PICT file PICT 파일이 아닙니다 @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`는 #rrggbb 색상 1~255개의 배열이어야 합니다 @msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다 @msg the EMF header is damaged EMF 헤더가 손상되었습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a232ac4e8d..793eb8e15b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4837,6 +4837,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0} @msg not an EMF or WMF file to nie jest plik EMF ani WMF @msg not an HP-GL file to nie jest plik HP-GL +@msg not a PICT file to nie jest plik PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musi być tablicą od 1 do 255 kolorów #rrggbb @msg not found nie znaleziono @msg not in pattern editing mode nie jest w trybie edycji wzorka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 68a225625d..2fd53cf23e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4161,6 +4161,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0} @msg not an EMF or WMF file não é um arquivo EMF ou WMF @msg not an HP-GL file não é um arquivo HP-GL +@msg not a PICT file não é um arquivo PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve ser uma matriz de 1 a 255 cores #rrggbb @msg not found não encontrado @msg not in pattern editing mode não está no modo de edição de padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a54d1eed2c..6ca7f542eb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4741,6 +4741,7 @@ axis Both Оба @msg not a valid plug-in module: {0} не является допустимым модулем: {0} @msg not an EMF or WMF file не является файлом EMF или WMF @msg not an HP-GL file не является файлом HP-GL +@msg not a PICT file не является файлом PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` должен быть массивом из 1–255 цветов #rrggbb @msg not found не найдено @msg not in pattern editing mode не в режиме редактирования узора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d0648b5946..e5d23af1e8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4605,6 +4605,7 @@ axis Both Båda @msg not a valid plug-in module: {0} inte ett giltigt plugin: {0} @msg not an EMF or WMF file inte en EMF- eller WMF-fil @msg not an HP-GL file inte en HP-GL-fil +@msg not a PICT file inte en PICT-fil @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` måste vara en array med 1 till 255 färger i formatet #rrggbb @msg not found hittades inte @msg not in pattern editing mode inte i läget för mönsterredigering diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 915a244b43..3b94838c6b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4460,6 +4460,7 @@ axis Both Her İkisi @msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} @msg not an EMF or WMF file EMF veya WMF dosyası değil @msg not an HP-GL file HP-GL dosyası değil +@msg not a PICT file PICT dosyası değil @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır @msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor @msg not found bulunamadı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8b5445a50..43996c53e1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4739,6 +4739,7 @@ axis Both Обидві @msg not a valid plug-in module: {0} не є допустимим модулем: {0} @msg not an EMF or WMF file не є файлом EMF або WMF @msg not an HP-GL file не є файлом HP-GL +@msg not a PICT file не є файлом PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` має бути масивом із 1–255 кольорів #rrggbb @msg not found не знайдено @msg not in pattern editing mode не в режимі редагування візерунка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51681bfed5..16147c95d8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5052,6 +5052,7 @@ theme Appearance Giao diện @msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được. @msg not an EMF or WMF file không phải tệp EMF hoặc WMF @msg not an HP-GL file không phải tệp HP-GL +@msg not a PICT file không phải tệp PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` phải là mảng từ 1 đến 255 màu #rrggbb @msg not found không tìm thấy @msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d1e4de6bc6..2246fc7b7f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4442,6 +4442,7 @@ imageTrace Outline 轮廓 @msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 @msg not an EMF or WMF file 不是 EMF 或 WMF 文件 @msg not an HP-GL file 不是 HP-GL 文件 +@msg not a PICT file 不是 PICT 文件 @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必须是由 1 到 255 个 #rrggbb 格式颜色组成的数组 @msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 @msg the EMF header is damaged EMF 文件头已损坏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e49ed109a..e1315f27e9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2212,6 +2212,7 @@ file-filter Templates 範本 @msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖 @msg not an EMF or WMF file 不是 EMF 或 WMF 檔案 @msg not an HP-GL file 不是 HP-GL 檔案 +@msg not a PICT file 不是 PICT 檔案 @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必須是由 1 到 255 個 #rrggbb 格式顏色組成的陣列 @msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取 @msg the EMF header is damaged EMF 檔頭已損毀 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 465f1701a8..e0964f7200 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ Ordinary File > Save offers Save As when its own location refuses writes with a | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| -| Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | +| Formats opened or placed | ~30 | 31 | missing: FreeHand, CorelDRAW, DWG, 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 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -284,7 +284,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | 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 | +| PICT | open, place (legacy) | yes (versions 1 and 2: shapes, lines, regions, text, bitmaps, pixel maps and QuickTime JPEG images; placed as one group clipped to the picture's frame) | no | `.pct`, `.pic` and `.pict`; a version 2 picture also opens by its content; a two-color pattern fills with its average color; inverted shapes, transfer modes other than a copy and QuickTime images in other formats come back as warnings; a picture cut short keeps what came before it; tests in `crates/metafile/src/tests/pict.rs`, `crates/engine/src/cmd/fileio/tests_pict.rs` and `crates/engine/tests/import_fuzz.rs` | | Pixar PXR | open, place (legacy) | yes (any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded, in tiles; R alone is gray; alpha matted to black or unassociated) | no | recognized by its magic number; 12-bit samples are clamped to 0.0 to 1.0; null tiles are transparent with A and black without; the offsets aren't read, and the image takes the default resolution; a file cut short, with header values or packets the format doesn't define, or past the pixel allowance is refused with a message; tested on synthetic files only; tests in `crates/engine/src/cmd/fileio/tests_pxrread.rs` and `crates/engine/tests/import_fuzz.rs` | | FreeHand `.fh*` | open (legacy) | no | no | | | CorelDRAW `.cdr` | open | no | no | | diff --git a/docs/mcp.md b/docs/mcp.md index 2c8afc7aea..926b457c61 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -629,7 +629,7 @@ sampler. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PICT (`.pct`/`.pic`/`.pict`, and a version 2 picture of any extension), HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `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; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `_.eps` beside it, listed in the reply's `files`). 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. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). 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. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `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`, `hpgl` (a `.plt` plot file for plotters and cutters), `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, WMF and HP-GL 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. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -6617,8 +6617,8 @@ no linked file, and `warnings` carries "an image couldn't be decoded and is miss images. DWG can't be written (no openly licensed writer exists): `document.formats` lists it under `unsupported` with that -hint, and exporting to it answers with the hint to export DXF. PICT files are not supported either and say so when -opened. +hint, and exporting to it answers with the hint to export DXF. PICT files open and place (see PICT import) but can't +be written. ```json {"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} @@ -7020,6 +7020,36 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}} ``` +## PICT import + +`document.open` and `file.place` read QuickDraw pictures, versions 1 and 2 (`.pct`, `.pic`, and `.pict`, which is +VectorCraft's own name for them), into one artboard, the picture's frame in points, and one layer. A file holds 512 bytes +of application data before the picture; a picture without them reads too. A version 2 picture also opens by its content +under any name. Opcodes and their data lengths follow Apple's +[Opcodes in Pictures](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-461.html) and +[Version and Header Opcodes](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-459.html): + +- Lines, rectangles, rounded rectangles, ovals, arcs and wedges, polygons and regions become paths, in the order the + picture draws them. A framed shape is stroked inside its outline at the pen's size; a line is stroked below and to + the right of its points. Painted, filled and erased shapes are filled with the pen, fill or background pattern, and a + two-color pattern with the average of its colors. +- Text becomes point type in the font the picture names, at its size and style, read as Mac Roman. +- Bitmaps and pixel maps (1 to 8 bits with their color table, 16 and 32 bits, packed or not) become images, clipped to + their mask region. In the OR transfer mode, a bitmap's clear bits are left transparent. A QuickTime JPEG image + (opcode `0x8200`) becomes an image placed by its matrix. +- The clip region clips what follows it. + +`file.place` places the picture as one group clipped to its frame. What isn't read comes back in `warnings`, once each: +inverted shapes, transfer modes other than a copy (drawn as a copy), QuickTime images in formats other than JPEG, and +regions or images past the reader's limits. A picture cut short keeps what came before it with a warning, and one that +draws nothing VectorCraft reads opens empty with a warning. A file that holds no picture is refused with +"not a PICT file". PICT can't be written: `document.export` to it is refused with "PICT files can be opened but not +written". + +```json +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/logo.pct","at":[300,200]}}} +``` + ## HP-GL import `document.open` and `file.place` read HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`) into one layer, with the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce5c5fda5..6ef5362460 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1583,6 +1583,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - PICT pictures (`.pct`, `.pic`, and `.pict` as VectorCraft's own name) open and place, as [the help page](https://helpx.adobe.com/archive/illustrator/illustrator-cs5-5-troubleshooting.pdf) lists Macintosh PICT under both opening and placing: versions 1 and 2, with or without the file's 512 bytes of application data, read by the opcodes of Apple's [Opcodes in Pictures](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-461.html). Lines, rectangles, rounded rectangles, ovals, arcs, polygons and regions become paths in their pen and pattern colors, text becomes point type in the font the picture names, bitmaps, pixel maps and QuickTime JPEG images become images, and the clip region clips what follows it; File › Place places the picture as one group clipped to its frame. Inverted shapes, other transfer modes and other QuickTime formats come back as warnings, and a picture cut short keeps what came before it (`crates/metafile/src/tests/pict.rs`, `crates/engine/src/cmd/fileio/tests_pict.rs`). - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.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). From c84ece827cfd40942c9b7ab21dc9c7b1c342d6ec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:00:10 -0800 Subject: [PATCH 05/34] i18n: parameter schema errors translate what the value must be A parameter schema's error ("`zoom` must be a number > 0, not -1") was translated around its requirement, which stayed in English in every language: only "a number", "a string" and "true or false" had rows. - Every complete catalog gets a row for each requirement a schema writes: a number with each kind of range and with a unit, an integer and its range, a list of names, an object id, a list of object ids, a color, a point, an object and a list. The bounds, unit and names stay as the English gives them. - An enum error no longer matches the `{key}` must be one of {_1} row, which took `"a", "b", not "c"` as its list and left ", not ..." in English: a `{path}` must be one of {_1}, not {got} row translates it, in the complete catalogs and in Bulgarian, Bosnian, Danish, Indonesian and Dutch. - A part of a message that starts with `{` or `[` is translated only when it is an exact message, so "[x, y] (two numbers)" translates and JSON values, shown cut short or whole, stay as they are. Docs: docs/mcp.md (Parameter schemas), docs/target-app-parity.md. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/i18n.rs | 16 ++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 29 ++++++ crates/ui-egui/src/i18n/catalog.rs | 6 ++ crates/ui-egui/src/i18n/cs.tsv | 29 ++++++ crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 29 ++++++ crates/ui-egui/src/i18n/el.tsv | 29 ++++++ crates/ui-egui/src/i18n/es.tsv | 29 ++++++ crates/ui-egui/src/i18n/fi.tsv | 29 ++++++ crates/ui-egui/src/i18n/fr.tsv | 29 ++++++ crates/ui-egui/src/i18n/hi.tsv | 29 ++++++ crates/ui-egui/src/i18n/hu.tsv | 29 ++++++ crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 29 ++++++ crates/ui-egui/src/i18n/ja.tsv | 29 ++++++ crates/ui-egui/src/i18n/ka.tsv | 29 ++++++ crates/ui-egui/src/i18n/ko.tsv | 29 ++++++ crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 29 ++++++ crates/ui-egui/src/i18n/pt-br.tsv | 29 ++++++ crates/ui-egui/src/i18n/ru.tsv | 29 ++++++ crates/ui-egui/src/i18n/sv.tsv | 29 ++++++ crates/ui-egui/src/i18n/tests.rs | 154 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 29 ++++++ crates/ui-egui/src/i18n/uk.tsv | 29 ++++++ crates/ui-egui/src/i18n/vi.tsv | 29 ++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 29 ++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 29 ++++++ docs/mcp.md | 5 + docs/target-app-parity.md | 1 + 34 files changed, 823 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b42037f540..7e85c13b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6854,6 +6854,7 @@ dependencies = [ "serde", "serde_json", "toml", + "vectorcraft-apischema", "vectorcraft-brush", "vectorcraft-cad", "vectorcraft-color", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 8e30081d7f..2ea7ac8079 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -68,6 +68,7 @@ rustix = { workspace = true, features = ["std", "process"] } [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +vectorcraft-apischema = { workspace = true } vectorcraft-testkit = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ee09ee14e..e6f3cde533 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -659,7 +659,8 @@ const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and /// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as -/// `{"objects": [...]}`), stay as they are. +/// `{"objects": [...]}`), stay as they are. A part that starts with `{` or `[` is translated only +/// when it is a whole message with an exact entry, such as `[x, y] (two numbers)`. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } @@ -669,9 +670,16 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { let count = caps.iter().find(|(k, _)| *k == "n").and_then(|(_, v)| v.parse::().ok()); let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1))); let Some(template) = forms.get(form) else { return s.to_string() }; - let nested = |v: &str| depth > 1 && !v.starts_with(['{', '[']); - let values: Vec<(&str, String)> = - caps.into_iter().map(|(k, v)| (k, if nested(v) { message_at(lang, v, depth - 1) } else { v.to_string() })).collect(); + let nested = |v: &str| { + if depth <= 1 { + v.to_string() + } else if v.starts_with(['{', '[']) { + lang.catalog().exact_message(v).unwrap_or(v).to_string() + } else { + message_at(lang, v, depth - 1) + } + }; + let values: Vec<(&str, String)> = caps.into_iter().map(|(k, v)| (k, nested(v))).collect(); let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); fill_once(template, &args) } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ca25bdf078..562c881809 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4183,6 +4183,7 @@ axis Both И двете @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` трябва да бъде цяло число между {min} и {max} (получено е {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` трябва да бъде между {min} и {max} (получено е {x}) @msg `{key}` must be one of {_1} `{key}` трябва да бъде едно от {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` трябва да бъде едно от {_1}, а не {got} @msg `{key}` must be true or false `{key}` трябва да бъде true или false @msg `{k}` must be [x, y] `{k}` трябва да бъде [x, y] @msg `{name}` has no options `{name}` няма опции diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 69fcb871b9..c8a06e4d91 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4553,6 +4553,7 @@ imageTrace Outline Obris @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` mora biti cijeli broj između {min} i {max} (dobiveno {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` mora biti između {min} i {max} (dobiveno {x}) @msg `{key}` must be one of {_1} `{key}` mora biti jedno od {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` mora biti jedno od {_1}, a ne {got} @msg `{key}` must be true or false `{key}` mora biti true ili false @msg `top` and `below` can't both be true `top` i `below` ne mogu oba biti true @msg `select` must be true or false `select` mora biti true ili false diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 33df72c253..0dda77c19d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6401,6 +6401,35 @@ digits Malayalam Malaiàlam @msg the path has no anchor points el traçat no té punts d’ancoratge @msg too many anchor points (limit {MAX_ANCHORS}) massa punts d’ancoratge (límit {MAX_ANCHORS}) @msg true or false true o false +@msg a number >= {_1} un nombre >= {_1} +@msg a number > {_1} un nombre > {_1} +@msg a number <= {_1} un nombre <= {_1} +@msg a number < {_1} un nombre < {_1} +@msg a number {_1} to {_2} un nombre de {_1} a {_2} +@msg a number >= {_1} and < {_2} un nombre >= {_1} i < {_2} +@msg a number > {_1} and <= {_2} un nombre > {_1} i <= {_2} +@msg a number > {_1} and < {_2} un nombre > {_1} i < {_2} +@msg a number ({_1}) un nombre ({_1}) +@msg a number >= {_1} ({_2}) un nombre >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un nombre > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un nombre <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un nombre < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un nombre de {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un nombre >= {_1} i < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un nombre > {_1} i <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un nombre > {_1} i < {_2} ({_3}) +@msg an integer un enter +@msg an integer >= {_1} un enter >= {_1} +@msg an integer <= {_1} un enter <= {_1} +@msg an integer {_1} to {_2} un enter de {_1} a {_2} +@msg one of {_1} un de {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` ha de ser un de {_1}, no {got} +@msg an object id (a non-negative integer) un identificador d’objecte (un enter no negatiu) +@msg a list of object ids una llista d’identificadors d’objecte +@msg a color (a string, [r, g, b] or an object) un color (una cadena, [r, g, b] o un objecte) +@msg [x, y] (two numbers) [x, y] (dos nombres) +@msg an object un objecte +@msg a list una llista @msg unknown key `{k}`; the keys here are {_1} clau desconeguda `{k}`; les claus aquí són {_1} @msg {_1} is listed twice {_1} apareix dues vegades @msg {_1} is not the id of an input object {_1} no és l’id d’un objecte d’entrada diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 138299643d..1f450a20be 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -230,6 +230,12 @@ impl Catalog { self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps))) } + /// The translation of a status or error message that has an exact `@msg` entry; templates are + /// not tried. + pub fn exact_message(&self, s: &str) -> Option<&str> { + self.messages.get(s).map(String::as_str) + } + /// The plural form `index` of the message whose English forms are `one|other`. pub fn plural(&self, one: &str, other: &str, index: usize) -> Option<&str> { let forms = self.plurals.get(&format!("{one}|{other}"))?; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ff48f1ecfc..40e12bf6f4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6323,6 +6323,35 @@ axis Y Axis osa Y @msg the path has no anchor points cesta nemá žádné kotevní body @msg too many anchor points (limit {MAX_ANCHORS}) příliš mnoho kotevních bodů (limit {MAX_ANCHORS}) @msg true or false true nebo false +@msg a number >= {_1} číslo >= {_1} +@msg a number > {_1} číslo > {_1} +@msg a number <= {_1} číslo <= {_1} +@msg a number < {_1} číslo < {_1} +@msg a number {_1} to {_2} číslo od {_1} do {_2} +@msg a number >= {_1} and < {_2} číslo >= {_1} a < {_2} +@msg a number > {_1} and <= {_2} číslo > {_1} a <= {_2} +@msg a number > {_1} and < {_2} číslo > {_1} a < {_2} +@msg a number ({_1}) číslo ({_1}) +@msg a number >= {_1} ({_2}) číslo >= {_1} ({_2}) +@msg a number > {_1} ({_2}) číslo > {_1} ({_2}) +@msg a number <= {_1} ({_2}) číslo <= {_1} ({_2}) +@msg a number < {_1} ({_2}) číslo < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) číslo od {_1} do {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) číslo >= {_1} a < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) číslo > {_1} a <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) číslo > {_1} a < {_2} ({_3}) +@msg an integer celé číslo +@msg an integer >= {_1} celé číslo >= {_1} +@msg an integer <= {_1} celé číslo <= {_1} +@msg an integer {_1} to {_2} celé číslo od {_1} do {_2} +@msg one of {_1} jedna z hodnot {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` musí být jedna z hodnot {_1}, nikoli {got} +@msg an object id (a non-negative integer) ID objektu (nezáporné celé číslo) +@msg a list of object ids seznam ID objektů +@msg a color (a string, [r, g, b] or an object) barva (řetězec, [r, g, b] nebo objekt) +@msg [x, y] (two numbers) [x, y] (dvě čísla) +@msg an object objekt +@msg a list seznam @msg unknown key `{k}`; the keys here are {_1} neznámý klíč `{k}`; zde platné klíče jsou {_1} @msg {_1} is listed twice {_1} je uveden dvakrát @msg {_1} is not the id of an input object {_1} není ID vstupního objektu diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 38300ca372..6b99525b1c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4299,6 +4299,7 @@ imageTrace Outline Kontur @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` skal være et heltal mellem {min} og {max} (fik {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` skal være mellem {min} og {max} (fik {x}) @msg `{key}` must be one of {_1} `{key}` skal være en af {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` skal være en af {_1}, ikke {got} @msg `{key}` must be true or false `{key}` skal være true eller false @msg `{k}` must be [x, y] `{k}` skal være [x, y] @msg `{name}` has no options `{name}` har ingen indstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d3b12f63a..0e732c8be0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5914,6 +5914,35 @@ axis Y Axis Y-Achse @msg the path has no anchor points der Pfad hat keine Ankerpunkte @msg too many anchor points (limit {MAX_ANCHORS}) zu viele Ankerpunkte (Grenze {MAX_ANCHORS}) @msg true or false true oder false +@msg a number >= {_1} eine Zahl >= {_1} +@msg a number > {_1} eine Zahl > {_1} +@msg a number <= {_1} eine Zahl <= {_1} +@msg a number < {_1} eine Zahl < {_1} +@msg a number {_1} to {_2} eine Zahl von {_1} bis {_2} +@msg a number >= {_1} and < {_2} eine Zahl >= {_1} und < {_2} +@msg a number > {_1} and <= {_2} eine Zahl > {_1} und <= {_2} +@msg a number > {_1} and < {_2} eine Zahl > {_1} und < {_2} +@msg a number ({_1}) eine Zahl ({_1}) +@msg a number >= {_1} ({_2}) eine Zahl >= {_1} ({_2}) +@msg a number > {_1} ({_2}) eine Zahl > {_1} ({_2}) +@msg a number <= {_1} ({_2}) eine Zahl <= {_1} ({_2}) +@msg a number < {_1} ({_2}) eine Zahl < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) eine Zahl von {_1} bis {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) eine Zahl >= {_1} und < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) eine Zahl > {_1} und <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) eine Zahl > {_1} und < {_2} ({_3}) +@msg an integer eine ganze Zahl +@msg an integer >= {_1} eine ganze Zahl >= {_1} +@msg an integer <= {_1} eine ganze Zahl <= {_1} +@msg an integer {_1} to {_2} eine ganze Zahl von {_1} bis {_2} +@msg one of {_1} einer der Werte {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` muss einer der Werte {_1} sein, nicht {got} +@msg an object id (a non-negative integer) eine Objekt-ID (eine nicht negative ganze Zahl) +@msg a list of object ids eine Liste von Objekt-IDs +@msg a color (a string, [r, g, b] or an object) eine Farbe (eine Zeichenkette, [r, g, b] oder ein Objekt) +@msg [x, y] (two numbers) [x, y] (zwei Zahlen) +@msg an object ein Objekt +@msg a list eine Liste @msg unknown key `{k}`; the keys here are {_1} unbekannter Schlüssel `{k}`; die Schlüssel hier sind {_1} @msg {_1} is listed twice {_1} ist doppelt aufgeführt @msg {_1} is not the id of an input object {_1} ist nicht die ID eines Eingabeobjekts diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8467b8e831..59d243f120 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6274,6 +6274,35 @@ axis Y Axis Άξονας Y @msg the path has no anchor points η διαδρομή δεν έχει σημεία αγκύρωσης @msg too many anchor points (limit {MAX_ANCHORS}) πάρα πολλά σημεία αγκύρωσης (όριο {MAX_ANCHORS}) @msg true or false true ή false +@msg a number >= {_1} αριθμός >= {_1} +@msg a number > {_1} αριθμός > {_1} +@msg a number <= {_1} αριθμός <= {_1} +@msg a number < {_1} αριθμός < {_1} +@msg a number {_1} to {_2} αριθμός από {_1} έως {_2} +@msg a number >= {_1} and < {_2} αριθμός >= {_1} και < {_2} +@msg a number > {_1} and <= {_2} αριθμός > {_1} και <= {_2} +@msg a number > {_1} and < {_2} αριθμός > {_1} και < {_2} +@msg a number ({_1}) αριθμός ({_1}) +@msg a number >= {_1} ({_2}) αριθμός >= {_1} ({_2}) +@msg a number > {_1} ({_2}) αριθμός > {_1} ({_2}) +@msg a number <= {_1} ({_2}) αριθμός <= {_1} ({_2}) +@msg a number < {_1} ({_2}) αριθμός < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) αριθμός από {_1} έως {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) αριθμός >= {_1} και < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) αριθμός > {_1} και <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) αριθμός > {_1} και < {_2} ({_3}) +@msg an integer ακέραιος +@msg an integer >= {_1} ακέραιος >= {_1} +@msg an integer <= {_1} ακέραιος <= {_1} +@msg an integer {_1} to {_2} ακέραιος από {_1} έως {_2} +@msg one of {_1} μία από τις τιμές {_1} +@msg `{path}` must be one of {_1}, not {got} Το `{path}` πρέπει να είναι μία από τις τιμές {_1}, όχι {got} +@msg an object id (a non-negative integer) αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) +@msg a list of object ids λίστα αναγνωριστικών αντικειμένων +@msg a color (a string, [r, g, b] or an object) χρώμα (συμβολοσειρά, [r, g, b] ή αντικείμενο) +@msg [x, y] (two numbers) [x, y] (δύο αριθμοί) +@msg an object αντικείμενο +@msg a list λίστα @msg unknown key `{k}`; the keys here are {_1} άγνωστο κλειδί `{k}`· τα κλειδιά εδώ είναι {_1} @msg {_1} is listed twice το {_1} αναφέρεται δύο φορές @msg {_1} is not the id of an input object το {_1} δεν είναι αναγνωριστικό αντικειμένου εισόδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 194ba2c602..c4180a77ec 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6265,6 +6265,35 @@ axis Y Axis Eje Y @msg the path has no anchor points el trazado no tiene puntos de ancla @msg too many anchor points (limit {MAX_ANCHORS}) demasiados puntos de ancla (límite {MAX_ANCHORS}) @msg true or false true o false +@msg a number >= {_1} un número >= {_1} +@msg a number > {_1} un número > {_1} +@msg a number <= {_1} un número <= {_1} +@msg a number < {_1} un número < {_1} +@msg a number {_1} to {_2} un número de {_1} a {_2} +@msg a number >= {_1} and < {_2} un número >= {_1} y < {_2} +@msg a number > {_1} and <= {_2} un número > {_1} y <= {_2} +@msg a number > {_1} and < {_2} un número > {_1} y < {_2} +@msg a number ({_1}) un número ({_1}) +@msg a number >= {_1} ({_2}) un número >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un número > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un número <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un número < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un número de {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un número >= {_1} y < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un número > {_1} y <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un número > {_1} y < {_2} ({_3}) +@msg an integer un entero +@msg an integer >= {_1} un entero >= {_1} +@msg an integer <= {_1} un entero <= {_1} +@msg an integer {_1} to {_2} un entero de {_1} a {_2} +@msg one of {_1} uno de {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` debe ser uno de {_1}, no {got} +@msg an object id (a non-negative integer) un id. de objeto (un entero no negativo) +@msg a list of object ids una lista de id. de objeto +@msg a color (a string, [r, g, b] or an object) un color (una cadena, [r, g, b] o un objeto) +@msg [x, y] (two numbers) [x, y] (dos números) +@msg an object un objeto +@msg a list una lista @msg unknown key `{k}`; the keys here are {_1} clave desconocida `{k}`; las claves aquí son {_1} @msg {_1} is listed twice {_1} aparece dos veces @msg {_1} is not the id of an input object {_1} no es el id de un objeto de entrada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e36c5e891e..365003fc51 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6353,6 +6353,35 @@ axis Y Axis Y-akseli @msg the path has no anchor points polulla ei ole ankkuripisteitä @msg too many anchor points (limit {MAX_ANCHORS}) liian monta ankkuripistettä (raja {MAX_ANCHORS}) @msg true or false true tai false +@msg a number >= {_1} luku >= {_1} +@msg a number > {_1} luku > {_1} +@msg a number <= {_1} luku <= {_1} +@msg a number < {_1} luku < {_1} +@msg a number {_1} to {_2} luku, joka on vähintään {_1} ja enintään {_2} +@msg a number >= {_1} and < {_2} luku >= {_1} ja < {_2} +@msg a number > {_1} and <= {_2} luku > {_1} ja <= {_2} +@msg a number > {_1} and < {_2} luku > {_1} ja < {_2} +@msg a number ({_1}) luku ({_1}) +@msg a number >= {_1} ({_2}) luku >= {_1} ({_2}) +@msg a number > {_1} ({_2}) luku > {_1} ({_2}) +@msg a number <= {_1} ({_2}) luku <= {_1} ({_2}) +@msg a number < {_1} ({_2}) luku < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) luku, joka on vähintään {_1} ja enintään {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) luku >= {_1} ja < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) luku > {_1} ja <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) luku > {_1} ja < {_2} ({_3}) +@msg an integer kokonaisluku +@msg an integer >= {_1} kokonaisluku >= {_1} +@msg an integer <= {_1} kokonaisluku <= {_1} +@msg an integer {_1} to {_2} kokonaisluku, joka on vähintään {_1} ja enintään {_2} +@msg one of {_1} jokin arvoista {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` on oltava jokin arvoista {_1}, ei {got} +@msg an object id (a non-negative integer) objektitunnus (ei-negatiivinen kokonaisluku) +@msg a list of object ids objektitunnusten luettelo +@msg a color (a string, [r, g, b] or an object) väri (merkkijono, [r, g, b] tai objekti) +@msg [x, y] (two numbers) [x, y] (kaksi lukua) +@msg an object objekti +@msg a list luettelo @msg unknown key `{k}`; the keys here are {_1} tuntematon avain `{k}`; tässä käytettävät avaimet ovat {_1} @msg {_1} is listed twice {_1} on luettelossa kahdesti @msg {_1} is not the id of an input object {_1} ei ole syöteobjektin tunnus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 067845f493..8a3ee3e111 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6318,6 +6318,35 @@ axis Y Axis Axe Y @msg the path has no anchor points le tracé n’a aucun point d’ancrage @msg too many anchor points (limit {MAX_ANCHORS}) trop de points d’ancrage (limite {MAX_ANCHORS}) @msg true or false true ou false +@msg a number >= {_1} un nombre >= {_1} +@msg a number > {_1} un nombre > {_1} +@msg a number <= {_1} un nombre <= {_1} +@msg a number < {_1} un nombre < {_1} +@msg a number {_1} to {_2} un nombre de {_1} à {_2} +@msg a number >= {_1} and < {_2} un nombre >= {_1} et < {_2} +@msg a number > {_1} and <= {_2} un nombre > {_1} et <= {_2} +@msg a number > {_1} and < {_2} un nombre > {_1} et < {_2} +@msg a number ({_1}) un nombre ({_1}) +@msg a number >= {_1} ({_2}) un nombre >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un nombre > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un nombre <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un nombre < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un nombre de {_1} à {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un nombre >= {_1} et < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un nombre > {_1} et <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un nombre > {_1} et < {_2} ({_3}) +@msg an integer un entier +@msg an integer >= {_1} un entier >= {_1} +@msg an integer <= {_1} un entier <= {_1} +@msg an integer {_1} to {_2} un entier de {_1} à {_2} +@msg one of {_1} l’une des valeurs {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` doit être l’une des valeurs {_1}, pas {got} +@msg an object id (a non-negative integer) un identifiant d’objet (un entier positif ou nul) +@msg a list of object ids une liste d’identifiants d’objet +@msg a color (a string, [r, g, b] or an object) une couleur (une chaîne, [r, g, b] ou un objet) +@msg [x, y] (two numbers) [x, y] (deux nombres) +@msg an object un objet +@msg a list une liste @msg unknown key `{k}`; the keys here are {_1} clé inconnue `{k}` ; les clés ici sont {_1} @msg {_1} is listed twice {_1} figure deux fois @msg {_1} is not the id of an input object {_1} n’est pas l’identifiant d’un objet d’entrée diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 165e04ae7d..cae603977e 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5530,6 +5530,35 @@ antiAlias Sharp शार्प @msg the path has no anchor points पाथ में कोई एंकर पॉइंट नहीं है @msg too many anchor points (limit {MAX_ANCHORS}) बहुत अधिक एंकर पॉइंट (सीमा {MAX_ANCHORS}) @msg true or false true या false +@msg a number >= {_1} एक संख्या >= {_1} +@msg a number > {_1} एक संख्या > {_1} +@msg a number <= {_1} एक संख्या <= {_1} +@msg a number < {_1} एक संख्या < {_1} +@msg a number {_1} to {_2} {_1} से {_2} तक की एक संख्या +@msg a number >= {_1} and < {_2} एक संख्या >= {_1} और < {_2} +@msg a number > {_1} and <= {_2} एक संख्या > {_1} और <= {_2} +@msg a number > {_1} and < {_2} एक संख्या > {_1} और < {_2} +@msg a number ({_1}) एक संख्या ({_1}) +@msg a number >= {_1} ({_2}) एक संख्या >= {_1} ({_2}) +@msg a number > {_1} ({_2}) एक संख्या > {_1} ({_2}) +@msg a number <= {_1} ({_2}) एक संख्या <= {_1} ({_2}) +@msg a number < {_1} ({_2}) एक संख्या < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} से {_2} तक की एक संख्या ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) एक संख्या >= {_1} और < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) एक संख्या > {_1} और <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) एक संख्या > {_1} और < {_2} ({_3}) +@msg an integer एक पूर्णांक +@msg an integer >= {_1} एक पूर्णांक >= {_1} +@msg an integer <= {_1} एक पूर्णांक <= {_1} +@msg an integer {_1} to {_2} {_1} से {_2} तक का एक पूर्णांक +@msg one of {_1} {_1} में से एक +@msg `{path}` must be one of {_1}, not {got} `{path}` {_1} में से एक होना चाहिए, {got} नहीं +@msg an object id (a non-negative integer) एक ऑब्जेक्ट id (एक गैर-नकारात्मक पूर्णांक) +@msg a list of object ids ऑब्जेक्ट ids की एक सूची +@msg a color (a string, [r, g, b] or an object) एक रंग (एक स्ट्रिंग, [r, g, b] या एक ऑब्जेक्ट) +@msg [x, y] (two numbers) [x, y] (दो संख्याएँ) +@msg an object एक ऑब्जेक्ट +@msg a list एक सूची @msg unknown key `{k}`; the keys here are {_1} अज्ञात कुंजी `{k}`; यहाँ की कुंजियाँ {_1} हैं @msg {_1} is listed twice {_1} दो बार सूचीबद्ध है @msg {_1} is not the id of an input object {_1} किसी इनपुट ऑब्जेक्ट का id नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2ccf9d57bc..0155edf5b2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6306,6 +6306,35 @@ axis Y Axis Y tengely @msg the path has no anchor points az útvonalnak nincsenek horgonypontjai @msg too many anchor points (limit {MAX_ANCHORS}) túl sok horgonypont (korlát: {MAX_ANCHORS}) @msg true or false true vagy false +@msg a number >= {_1} szám >= {_1} +@msg a number > {_1} szám > {_1} +@msg a number <= {_1} szám <= {_1} +@msg a number < {_1} szám < {_1} +@msg a number {_1} to {_2} {_1} és {_2} közötti szám +@msg a number >= {_1} and < {_2} szám >= {_1} és < {_2} +@msg a number > {_1} and <= {_2} szám > {_1} és <= {_2} +@msg a number > {_1} and < {_2} szám > {_1} és < {_2} +@msg a number ({_1}) szám ({_1}) +@msg a number >= {_1} ({_2}) szám >= {_1} ({_2}) +@msg a number > {_1} ({_2}) szám > {_1} ({_2}) +@msg a number <= {_1} ({_2}) szám <= {_1} ({_2}) +@msg a number < {_1} ({_2}) szám < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} és {_2} közötti szám ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) szám >= {_1} és < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) szám > {_1} és <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) szám > {_1} és < {_2} ({_3}) +@msg an integer egész szám +@msg an integer >= {_1} egész szám >= {_1} +@msg an integer <= {_1} egész szám <= {_1} +@msg an integer {_1} to {_2} {_1} és {_2} közötti egész szám +@msg one of {_1} {_1} közül az egyik +@msg `{path}` must be one of {_1}, not {got} `{path}`: {_1} közül az egyik kell, nem ez: {got} +@msg an object id (a non-negative integer) objektumazonosító (nemnegatív egész szám) +@msg a list of object ids objektumazonosítók listája +@msg a color (a string, [r, g, b] or an object) szín (karakterlánc, [r, g, b] vagy objektum) +@msg [x, y] (two numbers) [x, y] (két szám) +@msg an object objektum +@msg a list lista @msg unknown key `{k}`; the keys here are {_1} ismeretlen kulcs: `{k}`; az itt használható kulcsok: {_1} @msg {_1} is listed twice {_1} kétszer szerepel @msg {_1} is not the id of an input object {_1} nem egy bemeneti objektum azonosítója diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 16303f38f5..9ef5051464 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4299,6 +4299,7 @@ imageTrace Outline Kontur @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` harus berupa bilangan bulat antara {min} dan {max} (diperoleh {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` harus di antara {min} dan {max} (diperoleh {x}) @msg `{key}` must be one of {_1} `{key}` harus salah satu dari {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` harus salah satu dari {_1}, bukan {got} @msg `{key}` must be true or false `{key}` harus true atau false @msg `{k}` must be [x, y] `{k}` harus [x, y] @msg `{name}` has no options `{name}` tidak memiliki opsi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be83d78b43..34d901ec90 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5948,6 +5948,35 @@ axis Y Axis Asse Y @msg the path has no anchor points il tracciato non ha punti di ancoraggio @msg too many anchor points (limit {MAX_ANCHORS}) troppi punti di ancoraggio (limite {MAX_ANCHORS}) @msg true or false true o false +@msg a number >= {_1} un numero >= {_1} +@msg a number > {_1} un numero > {_1} +@msg a number <= {_1} un numero <= {_1} +@msg a number < {_1} un numero < {_1} +@msg a number {_1} to {_2} un numero da {_1} a {_2} +@msg a number >= {_1} and < {_2} un numero >= {_1} e < {_2} +@msg a number > {_1} and <= {_2} un numero > {_1} e <= {_2} +@msg a number > {_1} and < {_2} un numero > {_1} e < {_2} +@msg a number ({_1}) un numero ({_1}) +@msg a number >= {_1} ({_2}) un numero >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un numero > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un numero <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un numero < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un numero da {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un numero >= {_1} e < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un numero > {_1} e <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un numero > {_1} e < {_2} ({_3}) +@msg an integer un intero +@msg an integer >= {_1} un intero >= {_1} +@msg an integer <= {_1} un intero <= {_1} +@msg an integer {_1} to {_2} un intero da {_1} a {_2} +@msg one of {_1} uno tra {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` deve essere uno tra {_1}, non {got} +@msg an object id (a non-negative integer) un ID di oggetto (un intero non negativo) +@msg a list of object ids un elenco di ID di oggetti +@msg a color (a string, [r, g, b] or an object) un colore (una stringa, [r, g, b] o un oggetto) +@msg [x, y] (two numbers) [x, y] (due numeri) +@msg an object un oggetto +@msg a list un elenco @msg unknown key `{k}`; the keys here are {_1} chiave sconosciuta `{k}`; le chiavi qui sono {_1} @msg {_1} is listed twice {_1} è elencato due volte @msg {_1} is not the id of an input object {_1} non è l’id di un oggetto di input diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b27c6d2698..027b262681 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6013,6 +6013,35 @@ axis Y Axis Y 軸 @msg the path has no anchor points パスにアンカーポイントがありません @msg too many anchor points (limit {MAX_ANCHORS}) アンカーポイントが多すぎます (上限 {MAX_ANCHORS}) @msg true or false true または false +@msg a number >= {_1} {_1} 以上の数値 +@msg a number > {_1} {_1} より大きい数値 +@msg a number <= {_1} {_1} 以下の数値 +@msg a number < {_1} {_1} 未満の数値 +@msg a number {_1} to {_2} {_1} から {_2} までの数値 +@msg a number >= {_1} and < {_2} {_1} 以上 {_2} 未満の数値 +@msg a number > {_1} and <= {_2} {_1} より大きく {_2} 以下の数値 +@msg a number > {_1} and < {_2} {_1} より大きく {_2} 未満の数値 +@msg a number ({_1}) 数値 ({_1}) +@msg a number >= {_1} ({_2}) {_1} 以上の数値 ({_2}) +@msg a number > {_1} ({_2}) {_1} より大きい数値 ({_2}) +@msg a number <= {_1} ({_2}) {_1} 以下の数値 ({_2}) +@msg a number < {_1} ({_2}) {_1} 未満の数値 ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} から {_2} までの数値 ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) {_1} 以上 {_2} 未満の数値 ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) {_1} より大きく {_2} 以下の数値 ({_3}) +@msg a number > {_1} and < {_2} ({_3}) {_1} より大きく {_2} 未満の数値 ({_3}) +@msg an integer 整数 +@msg an integer >= {_1} {_1} 以上の整数 +@msg an integer <= {_1} {_1} 以下の整数 +@msg an integer {_1} to {_2} {_1} から {_2} までの整数 +@msg one of {_1} {_1} のいずれか +@msg `{path}` must be one of {_1}, not {got} `{path}` は {_1} のいずれかにしてください ({got} は使えません) +@msg an object id (a non-negative integer) オブジェクト ID (0 以上の整数) +@msg a list of object ids オブジェクト ID のリスト +@msg a color (a string, [r, g, b] or an object) カラー (文字列、[r, g, b] またはオブジェクト) +@msg [x, y] (two numbers) [x, y] (2 つの数値) +@msg an object オブジェクト +@msg a list リスト @msg unknown key `{k}`; the keys here are {_1} `{k}` は不明なキーです。ここで使えるキー: {_1} @msg {_1} is listed twice {_1} が 2 回指定されています @msg {_1} is not the id of an input object {_1} は入力オブジェクトの ID ではありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4ba40baab0..e49e6cd4b8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6274,6 +6274,35 @@ axis Y Axis Y ღერძი @msg the path has no anchor points ტრაექტორიას საყრდენი წერტილები არ აქვს @msg too many anchor points (limit {MAX_ANCHORS}) ძალიან ბევრი საყრდენი წერტილი (ლიმიტი {MAX_ANCHORS}) @msg true or false true ან false +@msg a number >= {_1} რიცხვი >= {_1} +@msg a number > {_1} რიცხვი > {_1} +@msg a number <= {_1} რიცხვი <= {_1} +@msg a number < {_1} რიცხვი < {_1} +@msg a number {_1} to {_2} რიცხვი {_1}-დან {_2}-მდე +@msg a number >= {_1} and < {_2} რიცხვი >= {_1} და < {_2} +@msg a number > {_1} and <= {_2} რიცხვი > {_1} და <= {_2} +@msg a number > {_1} and < {_2} რიცხვი > {_1} და < {_2} +@msg a number ({_1}) რიცხვი ({_1}) +@msg a number >= {_1} ({_2}) რიცხვი >= {_1} ({_2}) +@msg a number > {_1} ({_2}) რიცხვი > {_1} ({_2}) +@msg a number <= {_1} ({_2}) რიცხვი <= {_1} ({_2}) +@msg a number < {_1} ({_2}) რიცხვი < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) რიცხვი {_1}-დან {_2}-მდე ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) რიცხვი >= {_1} და < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) რიცხვი > {_1} და <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) რიცხვი > {_1} და < {_2} ({_3}) +@msg an integer მთელი რიცხვი +@msg an integer >= {_1} მთელი რიცხვი >= {_1} +@msg an integer <= {_1} მთელი რიცხვი <= {_1} +@msg an integer {_1} to {_2} მთელი რიცხვი {_1}-დან {_2}-მდე +@msg one of {_1} ერთ-ერთი მნიშვნელობა {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` უნდა იყოს ერთ-ერთი მნიშვნელობა {_1} და არა {got} +@msg an object id (a non-negative integer) ობიექტის იდენტიფიკატორი (არაუარყოფითი მთელი რიცხვი) +@msg a list of object ids ობიექტების იდენტიფიკატორების სია +@msg a color (a string, [r, g, b] or an object) ფერი (სტრიქონი, [r, g, b] ან ობიექტი) +@msg [x, y] (two numbers) [x, y] (ორი რიცხვი) +@msg an object ობიექტი +@msg a list სია @msg unknown key `{k}`; the keys here are {_1} უცნობი გასაღები `{k}`; აქ გასაღებებია: {_1} @msg {_1} is listed twice {_1} ორჯერაა მითითებული @msg {_1} is not the id of an input object {_1} შემავალი ობიექტის ID არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c46ce6b4fa..206d70f302 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5909,6 +5909,35 @@ axis Y Axis Y축 @msg the path has no anchor points 패스에 앵커 포인트가 없습니다 @msg too many anchor points (limit {MAX_ANCHORS}) 앵커 포인트가 너무 많습니다(한도 {MAX_ANCHORS}) @msg true or false true 또는 false +@msg a number >= {_1} {_1} 이상인 숫자 +@msg a number > {_1} {_1} 초과인 숫자 +@msg a number <= {_1} {_1} 이하인 숫자 +@msg a number < {_1} {_1} 미만인 숫자 +@msg a number {_1} to {_2} {_1} 이상 {_2} 이하인 숫자 +@msg a number >= {_1} and < {_2} {_1} 이상 {_2} 미만인 숫자 +@msg a number > {_1} and <= {_2} {_1} 초과 {_2} 이하인 숫자 +@msg a number > {_1} and < {_2} {_1} 초과 {_2} 미만인 숫자 +@msg a number ({_1}) 숫자({_1}) +@msg a number >= {_1} ({_2}) {_1} 이상인 숫자({_2}) +@msg a number > {_1} ({_2}) {_1} 초과인 숫자({_2}) +@msg a number <= {_1} ({_2}) {_1} 이하인 숫자({_2}) +@msg a number < {_1} ({_2}) {_1} 미만인 숫자({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} 이상 {_2} 이하인 숫자({_3}) +@msg a number >= {_1} and < {_2} ({_3}) {_1} 이상 {_2} 미만인 숫자({_3}) +@msg a number > {_1} and <= {_2} ({_3}) {_1} 초과 {_2} 이하인 숫자({_3}) +@msg a number > {_1} and < {_2} ({_3}) {_1} 초과 {_2} 미만인 숫자({_3}) +@msg an integer 정수 +@msg an integer >= {_1} {_1} 이상인 정수 +@msg an integer <= {_1} {_1} 이하인 정수 +@msg an integer {_1} to {_2} {_1} 이상 {_2} 이하인 정수 +@msg one of {_1} {_1} 중 하나 +@msg `{path}` must be one of {_1}, not {got} `{path}`은(는) {_1} 중 하나여야 합니다({got} 사용 불가) +@msg an object id (a non-negative integer) 개체 ID(음수가 아닌 정수) +@msg a list of object ids 개체 ID 목록 +@msg a color (a string, [r, g, b] or an object) 색상(문자열, [r, g, b] 또는 개체) +@msg [x, y] (two numbers) [x, y](숫자 두 개) +@msg an object 개체 +@msg a list 목록 @msg unknown key `{k}`; the keys here are {_1} 알 수 없는 키 `{k}`: 여기서 사용할 수 있는 키: {_1} @msg {_1} is listed twice {_1}이(가) 두 번 나열되었습니다 @msg {_1} is not the id of an input object {_1}은(는) 입력 개체의 ID가 아닙니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a19e190ed6..87961019c8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4313,6 +4313,7 @@ imageTrace Outline Contour @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` moet een geheel getal tussen {min} en {max} zijn (kreeg {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` moet tussen {min} en {max} liggen (kreeg {x}) @msg `{key}` must be one of {_1} `{key}` moet een van {_1} zijn +@msg `{path}` must be one of {_1}, not {got} `{path}` moet een van {_1} zijn, niet {got} @msg `{key}` must be true or false `{key}` moet true of false zijn @msg `{k}` must be [x, y] `{k}` moet [x, y] zijn @msg `{name}` has no options `{name}` heeft geen opties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a232ac4e8d..3121bfbb61 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6177,6 +6177,35 @@ axis Y Axis Oś Y @msg the path has no anchor points ścieżka nie ma punktów kontrolnych @msg too many anchor points (limit {MAX_ANCHORS}) za dużo punktów kontrolnych (limit {MAX_ANCHORS}) @msg true or false true lub false +@msg a number >= {_1} liczbą >= {_1} +@msg a number > {_1} liczbą > {_1} +@msg a number <= {_1} liczbą <= {_1} +@msg a number < {_1} liczbą < {_1} +@msg a number {_1} to {_2} liczbą od {_1} do {_2} +@msg a number >= {_1} and < {_2} liczbą >= {_1} i < {_2} +@msg a number > {_1} and <= {_2} liczbą > {_1} i <= {_2} +@msg a number > {_1} and < {_2} liczbą > {_1} i < {_2} +@msg a number ({_1}) liczbą ({_1}) +@msg a number >= {_1} ({_2}) liczbą >= {_1} ({_2}) +@msg a number > {_1} ({_2}) liczbą > {_1} ({_2}) +@msg a number <= {_1} ({_2}) liczbą <= {_1} ({_2}) +@msg a number < {_1} ({_2}) liczbą < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) liczbą od {_1} do {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) liczbą >= {_1} i < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) liczbą > {_1} i <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) liczbą > {_1} i < {_2} ({_3}) +@msg an integer liczbą całkowitą +@msg an integer >= {_1} liczbą całkowitą >= {_1} +@msg an integer <= {_1} liczbą całkowitą <= {_1} +@msg an integer {_1} to {_2} liczbą całkowitą od {_1} do {_2} +@msg one of {_1} jedną z wartości {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` musi być jedną z wartości {_1}, a nie {got} +@msg an object id (a non-negative integer) identyfikatorem obiektu (nieujemną liczbą całkowitą) +@msg a list of object ids listą identyfikatorów obiektów +@msg a color (a string, [r, g, b] or an object) kolorem (ciągiem znaków, [r, g, b] lub obiektem) +@msg [x, y] (two numbers) [x, y] (dwiema liczbami) +@msg an object obiektem +@msg a list listą @msg unknown key `{k}`; the keys here are {_1} nieznany klucz `{k}`; klucze w tym miejscu to {_1} @msg {_1} is listed twice {_1} występuje dwa razy @msg {_1} is not the id of an input object {_1} nie jest identyfikatorem obiektu wejściowego diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 68a225625d..d6f98a96e4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6238,6 +6238,35 @@ axis Y Axis Eixo Y @msg the path has no anchor points o caminho não tem pontos de ancoragem @msg too many anchor points (limit {MAX_ANCHORS}) pontos de ancoragem demais (limite {MAX_ANCHORS}) @msg true or false true ou false +@msg a number >= {_1} um número >= {_1} +@msg a number > {_1} um número > {_1} +@msg a number <= {_1} um número <= {_1} +@msg a number < {_1} um número < {_1} +@msg a number {_1} to {_2} um número de {_1} a {_2} +@msg a number >= {_1} and < {_2} um número >= {_1} e < {_2} +@msg a number > {_1} and <= {_2} um número > {_1} e <= {_2} +@msg a number > {_1} and < {_2} um número > {_1} e < {_2} +@msg a number ({_1}) um número ({_1}) +@msg a number >= {_1} ({_2}) um número >= {_1} ({_2}) +@msg a number > {_1} ({_2}) um número > {_1} ({_2}) +@msg a number <= {_1} ({_2}) um número <= {_1} ({_2}) +@msg a number < {_1} ({_2}) um número < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) um número de {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) um número >= {_1} e < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) um número > {_1} e <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) um número > {_1} e < {_2} ({_3}) +@msg an integer um inteiro +@msg an integer >= {_1} um inteiro >= {_1} +@msg an integer <= {_1} um inteiro <= {_1} +@msg an integer {_1} to {_2} um inteiro de {_1} a {_2} +@msg one of {_1} um de {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` deve ser um de {_1}, não {got} +@msg an object id (a non-negative integer) um ID de objeto (um inteiro não negativo) +@msg a list of object ids uma lista de IDs de objeto +@msg a color (a string, [r, g, b] or an object) uma cor (uma string, [r, g, b] ou um objeto) +@msg [x, y] (two numbers) [x, y] (dois números) +@msg an object um objeto +@msg a list uma lista @msg unknown key `{k}`; the keys here are {_1} chave desconhecida `{k}`; as chaves aqui são {_1} @msg {_1} is listed twice {_1} aparece duas vezes @msg {_1} is not the id of an input object {_1} não é o id de um objeto de entrada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a54d1eed2c..13512a6070 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5934,6 +5934,35 @@ axis Y Axis Ось Y @msg the path has no anchor points у контура нет опорных точек @msg too many anchor points (limit {MAX_ANCHORS}) слишком много опорных точек (предел {MAX_ANCHORS}) @msg true or false true или false +@msg a number >= {_1} число >= {_1} +@msg a number > {_1} число > {_1} +@msg a number <= {_1} число <= {_1} +@msg a number < {_1} число < {_1} +@msg a number {_1} to {_2} число от {_1} до {_2} +@msg a number >= {_1} and < {_2} число >= {_1} и < {_2} +@msg a number > {_1} and <= {_2} число > {_1} и <= {_2} +@msg a number > {_1} and < {_2} число > {_1} и < {_2} +@msg a number ({_1}) число ({_1}) +@msg a number >= {_1} ({_2}) число >= {_1} ({_2}) +@msg a number > {_1} ({_2}) число > {_1} ({_2}) +@msg a number <= {_1} ({_2}) число <= {_1} ({_2}) +@msg a number < {_1} ({_2}) число < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) число от {_1} до {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) число >= {_1} и < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) число > {_1} и <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) число > {_1} и < {_2} ({_3}) +@msg an integer целое число +@msg an integer >= {_1} целое число >= {_1} +@msg an integer <= {_1} целое число <= {_1} +@msg an integer {_1} to {_2} целое число от {_1} до {_2} +@msg one of {_1} одно из значений {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` должно быть одним из значений {_1}, а не {got} +@msg an object id (a non-negative integer) идентификатор объекта (неотрицательное целое число) +@msg a list of object ids список идентификаторов объектов +@msg a color (a string, [r, g, b] or an object) цвет (строка, [r, g, b] или объект) +@msg [x, y] (two numbers) [x, y] (два числа) +@msg an object объект +@msg a list список @msg unknown key `{k}`; the keys here are {_1} неизвестный ключ `{k}`; здесь допустимы ключи {_1} @msg {_1} is listed twice {_1} указан дважды @msg {_1} is not the id of an input object {_1} не является идентификатором входного объекта diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d0648b5946..5f82d23f68 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6309,6 +6309,35 @@ axis Y Axis Y-axel @msg the path has no anchor points banan har inga ankarpunkter @msg too many anchor points (limit {MAX_ANCHORS}) för många ankarpunkter (gräns {MAX_ANCHORS}) @msg true or false true eller false +@msg a number >= {_1} ett tal >= {_1} +@msg a number > {_1} ett tal > {_1} +@msg a number <= {_1} ett tal <= {_1} +@msg a number < {_1} ett tal < {_1} +@msg a number {_1} to {_2} ett tal från {_1} till {_2} +@msg a number >= {_1} and < {_2} ett tal >= {_1} och < {_2} +@msg a number > {_1} and <= {_2} ett tal > {_1} och <= {_2} +@msg a number > {_1} and < {_2} ett tal > {_1} och < {_2} +@msg a number ({_1}) ett tal ({_1}) +@msg a number >= {_1} ({_2}) ett tal >= {_1} ({_2}) +@msg a number > {_1} ({_2}) ett tal > {_1} ({_2}) +@msg a number <= {_1} ({_2}) ett tal <= {_1} ({_2}) +@msg a number < {_1} ({_2}) ett tal < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) ett tal från {_1} till {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) ett tal >= {_1} och < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) ett tal > {_1} och <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) ett tal > {_1} och < {_2} ({_3}) +@msg an integer ett heltal +@msg an integer >= {_1} ett heltal >= {_1} +@msg an integer <= {_1} ett heltal <= {_1} +@msg an integer {_1} to {_2} ett heltal från {_1} till {_2} +@msg one of {_1} ett av {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` måste vara ett av {_1}, inte {got} +@msg an object id (a non-negative integer) ett objekt-id (ett icke-negativt heltal) +@msg a list of object ids en lista med objekt-id:n +@msg a color (a string, [r, g, b] or an object) en färg (en sträng, [r, g, b] eller ett objekt) +@msg [x, y] (two numbers) [x, y] (två tal) +@msg an object ett objekt +@msg a list en lista @msg unknown key `{k}`; the keys here are {_1} okänd nyckel `{k}`; nycklarna här är {_1} @msg {_1} is listed twice {_1} finns två gånger @msg {_1} is not the id of an input object {_1} är inte id:t för ett indataobjekt diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 887eb4253b..70dfe0ab90 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2704,6 +2704,14 @@ fn parameter_schema_errors_are_translated() { s.execute("file.new", &json!({})).unwrap(); let errors = [ (s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string(), "`zoom`", "must be", "-1"), + (s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string(), "`zoom`", "a number > 0", "-1"), + (s.execute("view.saved.new", &json!({"center": 5, "zoom": 1})).unwrap_err().to_string(), "`center`", "two numbers", "5"), + ( + s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 1, "rotation": "x"})).unwrap_err().to_string(), + "`rotation`", + "a number (deg)", + "\"x\"", + ), (s.execute("view.saved.new", &json!({"center": [0, 0]})).unwrap_err().to_string(), "`zoom`", "missing", ""), (s.execute("view.saved.list", &json!(5)).unwrap_err().to_string(), "5", "must be", ""), ]; @@ -2725,6 +2733,152 @@ fn parameter_schema_errors_are_translated() { } } +/// The phrases a parameter schema's error uses for what a value must be, as `@msg` sources: one +/// for each type, and for a number each range and unit form. +const SCHEMA_PHRASES: &[&str] = &[ + "a number", + "a number >= {_1}", + "a number > {_1}", + "a number <= {_1}", + "a number < {_1}", + "a number {_1} to {_2}", + "a number >= {_1} and < {_2}", + "a number > {_1} and <= {_2}", + "a number > {_1} and < {_2}", + "a number ({_1})", + "a number >= {_1} ({_2})", + "a number > {_1} ({_2})", + "a number <= {_1} ({_2})", + "a number < {_1} ({_2})", + "a number {_1} to {_2} ({_3})", + "a number >= {_1} and < {_2} ({_3})", + "a number > {_1} and <= {_2} ({_3})", + "a number > {_1} and < {_2} ({_3})", + "an integer", + "an integer >= {_1}", + "an integer <= {_1}", + "an integer {_1} to {_2}", + "true or false", + "a string", + "one of {_1}", + "an object id (a non-negative integer)", + "a list of object ids", + "a color (a string, [r, g, b] or an object)", + "[x, y] (two numbers)", + "an object", + "a list", +]; + +/// What a value of each parameter type must be is translated in every language that translates +/// every message, for every range and unit of a number: the matched row's placeholders take the +/// bounds, the unit and the names exactly as the English gives them, and the whole error keeps +/// the parameter's path and the refused value. +#[test] +fn parameter_schema_expected_phrases_are_translated() { + use serde_json::{Value, json}; + use vectorcraft_apischema::{Bound, Param, ParamSchema, Problem, Type}; + let number = |min, max, unit| Type::Number { min, max, unit }; + let (incl, excl) = (|x| Some(Bound::Inclusive(x)), |x| Some(Bound::Exclusive(x))); + // (type, a value it refuses, the placeholder values of the phrase's row) + let cases: Vec<(Type, Value, Vec<&str>)> = vec![ + (Type::NUMBER, json!("x"), vec![]), + (number(incl(-2.5), None, None), json!(-3), vec!["-2.5"]), + (number(excl(0.0), None, None), json!(0), vec!["0"]), + (number(None, incl(1.5), None), json!(2), vec!["1.5"]), + (number(None, excl(100.0), None), json!(100), vec!["100"]), + (number(incl(-1.0), incl(1.0), None), json!(2), vec!["-1", "1"]), + (number(incl(0.0), excl(360.0), None), json!(360), vec!["0", "360"]), + (number(excl(0.0), incl(1.0), None), json!(0), vec!["0", "1"]), + (number(excl(-0.5), excl(0.5), None), json!(1), vec!["-0.5", "0.5"]), + (number(None, None, Some("deg")), json!("x"), vec!["deg"]), + (number(incl(0.25), None, Some("pt")), json!(0), vec!["0.25", "pt"]), + (number(excl(0.0), None, Some("pt")), json!(0), vec!["0", "pt"]), + (number(None, incl(-1.0), Some("mm")), json!(0), vec!["-1", "mm"]), + (number(None, excl(10.0), Some("px")), json!(10), vec!["10", "px"]), + (number(incl(0.0), incl(100.0), Some("%")), json!(101), vec!["0", "100", "%"]), + (number(incl(-180.0), excl(180.0), Some("deg")), json!(180), vec!["-180", "180", "deg"]), + (number(excl(0.0), incl(1000.0), Some("pt")), json!(0), vec!["0", "1000", "pt"]), + (number(excl(-1.0), excl(1.0), Some("in")), json!(1), vec!["-1", "1", "in"]), + (Type::Integer { min: None, max: None }, json!(1.5), vec![]), + (Type::Integer { min: Some(1), max: None }, json!(0), vec!["1"]), + (Type::Integer { min: None, max: Some(-1) }, json!(0), vec!["-1"]), + (Type::Integer { min: Some(1), max: Some(25) }, json!(26), vec!["1", "25"]), + (Type::Boolean, json!(1), vec![]), + (Type::String, json!(1), vec![]), + (Type::Enum(&["left", "center", "right"]), json!("top"), vec![r#""left", "center", "right""#]), + (Type::Enum(&["only"]), json!("other"), vec![r#""only""#]), + (Type::Id, json!(-1), vec![]), + (Type::Ids, json!(5), vec![]), + (Type::Color, json!(5), vec![]), + (Type::Point, json!(5), vec![]), + (Type::Object(&[]), json!(5), vec![]), + (Type::Array(&Type::NUMBER), json!(5), vec![]), + ]; + let one = |name: &'static str, ty: Type| ParamSchema::new(Box::leak(Box::new([Param::new(name, ty)])), ""); + let inner: &'static [Param] = Box::leak(Box::new([Param::new("zoom", number(excl(0.0), None, None))])); + let nested = one("edits", Type::Array(Box::leak(Box::new(Type::Object(inner))))); + let mut errors: Vec<_> = cases.iter().map(|(ty, bad, caps)| (one("x", *ty).validate(&json!({ "x": bad })), caps.clone())).collect(); + errors.push((nested.validate(&json!({"edits": [{"zoom": 1}, {"zoom": -1}]})), vec!["0"])); + let mut seen = std::collections::BTreeSet::new(); + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + for phrase in SCHEMA_PHRASES { + assert!(entries.iter().any(|(ctx, src, _)| ctx == "@msg" && src == phrase), "{code}: no row for {phrase:?}"); + } + for (result, caps) in &errors { + let Err(e) = result else { panic!("accepted: {result:?}") }; + let Problem::WrongType { expected, got } = &e.problem else { panic!("{e}") }; + seen.insert(expected.clone()); + let (_, matched) = lang.catalog().message(expected).unwrap_or_else(|| panic!("{code}: no row matches {expected:?}")); + let values: Vec<&str> = matched.iter().map(|(_, v)| *v).collect(); + assert_eq!(&values, caps, "{code}: {expected:?}"); + let phrase = message(lang, expected); + assert_ne!(&phrase, expected, "{code}"); + let mut from = 0; + for value in caps { + let at = phrase[from..].find(value).unwrap_or_else(|| panic!("{code}: {value:?} in {expected:?} -> {phrase:?}")); + from += at + value.len(); + } + let shown = message(lang, &e.to_string()); + let path = format!("`{}`", e.path); + let kept = caps.iter().all(|value| shown.contains(value)); + assert!(kept && shown.contains(&path) && shown.contains(got.as_str()), "{code}: {shown}"); + assert!(!holds_word(&shown, expected) && !shown.contains("must be") && !shown.contains(", not "), "{code}: {shown}"); + } + } + // A catalog's `{key}` must be one of {_1}` row would take `"a", "b", not "c"` as its list. + let enum_error = one("x", Type::Enum(&["a", "b"])).validate(&json!({"x": "c"})).map_or_else(|e| e.to_string(), |_| String::new()); + assert_eq!(enum_error, r#"`x` must be one of "a", "b", not "c""#); + for lang in Lang::all().filter(|l| l.code() != Lang::EN.code() && !l.0.source.is_empty()) { + let shown = message(lang, &enum_error); + assert!(!shown.contains(", not ") && shown.contains(r#""a", "b""#) && shown.contains(r#""c""#), "{}: {shown}", lang.code()); + } + for phrase in + ["a number > -0.5 and < 0.5", "a number 0 to 100 (%)", "an integer 1 to 25", r#"one of "left", "center", "right""#, "[x, y] (two numbers)"] + { + assert!(seen.contains(phrase), "{phrase:?} not in {seen:?}"); + } +} + +/// A JSON object or array inside a translated message stays as it is, and an exact message that +/// starts with a bracket is still translated. +#[test] +fn bracketed_message_parts_translate_only_as_exact_messages() { + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let point = message(lang, "[x, y] (two numbers)"); + assert_ne!(point, "[x, y] (two numbers)", "{code}"); + assert!(point.contains("[x, y]"), "{code}: {point}"); + for got in ["[1]", "[1, 2, 3]", r#"{"objects": [1]}"#, r#"[{"a": 1}, "b: c", …"#] { + let shown = message(lang, &format!("`center` must be [x, y] (two numbers), not {got}")); + assert!(shown.contains(&point) && shown.contains(got), "{code}: {shown}"); + let shown = message(lang, &format!("`p` must be an object, not {got}")); + assert!(shown.contains(got) && !shown.contains("an object"), "{code}: {shown}"); + } + } +} + /// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog, /// alone and in the status bar's note. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 915a244b43..c86ac108e2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6279,6 +6279,35 @@ axis Y Axis Y ekseni @msg the path has no anchor points yolun bağlantı noktası yok @msg too many anchor points (limit {MAX_ANCHORS}) çok fazla bağlantı noktası (sınır {MAX_ANCHORS}) @msg true or false true veya false +@msg a number >= {_1} bir sayı >= {_1} +@msg a number > {_1} bir sayı > {_1} +@msg a number <= {_1} bir sayı <= {_1} +@msg a number < {_1} bir sayı < {_1} +@msg a number {_1} to {_2} {_1} ile {_2} arasında bir sayı +@msg a number >= {_1} and < {_2} bir sayı >= {_1} ve < {_2} +@msg a number > {_1} and <= {_2} bir sayı > {_1} ve <= {_2} +@msg a number > {_1} and < {_2} bir sayı > {_1} ve < {_2} +@msg a number ({_1}) bir sayı ({_1}) +@msg a number >= {_1} ({_2}) bir sayı >= {_1} ({_2}) +@msg a number > {_1} ({_2}) bir sayı > {_1} ({_2}) +@msg a number <= {_1} ({_2}) bir sayı <= {_1} ({_2}) +@msg a number < {_1} ({_2}) bir sayı < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} ile {_2} arasında bir sayı ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) bir sayı >= {_1} ve < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) bir sayı > {_1} ve <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) bir sayı > {_1} ve < {_2} ({_3}) +@msg an integer bir tam sayı +@msg an integer >= {_1} bir tam sayı >= {_1} +@msg an integer <= {_1} bir tam sayı <= {_1} +@msg an integer {_1} to {_2} {_1} ile {_2} arasında bir tam sayı +@msg one of {_1} {_1} değerlerinden biri +@msg `{path}` must be one of {_1}, not {got} `{path}` {_1} değerlerinden biri olmalıdır, {got} değil +@msg an object id (a non-negative integer) bir nesne kimliği (negatif olmayan bir tam sayı) +@msg a list of object ids bir nesne kimlikleri listesi +@msg a color (a string, [r, g, b] or an object) bir renk (bir dize, [r, g, b] veya bir nesne) +@msg [x, y] (two numbers) [x, y] (iki sayı) +@msg an object bir nesne +@msg a list bir liste @msg unknown key `{k}`; the keys here are {_1} bilinmeyen anahtar `{k}`; buradaki anahtarlar: {_1} @msg {_1} is listed twice {_1} iki kez listelenmiş @msg {_1} is not the id of an input object {_1} bir girdi nesnesinin kimliği değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8b5445a50..4601c6ca19 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5928,6 +5928,35 @@ axis Y Axis Вісь Y @msg the path has no anchor points контур не має опорних точок @msg too many anchor points (limit {MAX_ANCHORS}) забагато опорних точок (межа {MAX_ANCHORS}) @msg true or false true або false +@msg a number >= {_1} число >= {_1} +@msg a number > {_1} число > {_1} +@msg a number <= {_1} число <= {_1} +@msg a number < {_1} число < {_1} +@msg a number {_1} to {_2} число від {_1} до {_2} +@msg a number >= {_1} and < {_2} число >= {_1} і < {_2} +@msg a number > {_1} and <= {_2} число > {_1} і <= {_2} +@msg a number > {_1} and < {_2} число > {_1} і < {_2} +@msg a number ({_1}) число ({_1}) +@msg a number >= {_1} ({_2}) число >= {_1} ({_2}) +@msg a number > {_1} ({_2}) число > {_1} ({_2}) +@msg a number <= {_1} ({_2}) число <= {_1} ({_2}) +@msg a number < {_1} ({_2}) число < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) число від {_1} до {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) число >= {_1} і < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) число > {_1} і <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) число > {_1} і < {_2} ({_3}) +@msg an integer ціле число +@msg an integer >= {_1} ціле число >= {_1} +@msg an integer <= {_1} ціле число <= {_1} +@msg an integer {_1} to {_2} ціле число від {_1} до {_2} +@msg one of {_1} одне зі значень {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` має бути одним зі значень {_1}, а не {got} +@msg an object id (a non-negative integer) ідентифікатор об’єкта (невід’ємне ціле число) +@msg a list of object ids список ідентифікаторів об’єктів +@msg a color (a string, [r, g, b] or an object) колір (рядок, [r, g, b] або об’єкт) +@msg [x, y] (two numbers) [x, y] (два числа) +@msg an object об’єкт +@msg a list список @msg unknown key `{k}`; the keys here are {_1} невідомий ключ `{k}`; тут допустимі ключі {_1} @msg {_1} is listed twice {_1} вказано двічі @msg {_1} is not the id of an input object {_1} не є ідентифікатором вхідного об’єкта diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51681bfed5..6c42f99c33 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5929,6 +5929,35 @@ axis Y Axis Trục Y @msg the path has no anchor points đường dẫn không có điểm neo @msg too many anchor points (limit {MAX_ANCHORS}) quá nhiều điểm neo (giới hạn {MAX_ANCHORS}) @msg true or false true hoặc false +@msg a number >= {_1} một số >= {_1} +@msg a number > {_1} một số > {_1} +@msg a number <= {_1} một số <= {_1} +@msg a number < {_1} một số < {_1} +@msg a number {_1} to {_2} một số từ {_1} đến {_2} +@msg a number >= {_1} and < {_2} một số >= {_1} và < {_2} +@msg a number > {_1} and <= {_2} một số > {_1} và <= {_2} +@msg a number > {_1} and < {_2} một số > {_1} và < {_2} +@msg a number ({_1}) một số ({_1}) +@msg a number >= {_1} ({_2}) một số >= {_1} ({_2}) +@msg a number > {_1} ({_2}) một số > {_1} ({_2}) +@msg a number <= {_1} ({_2}) một số <= {_1} ({_2}) +@msg a number < {_1} ({_2}) một số < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) một số từ {_1} đến {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) một số >= {_1} và < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) một số > {_1} và <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) một số > {_1} và < {_2} ({_3}) +@msg an integer một số nguyên +@msg an integer >= {_1} một số nguyên >= {_1} +@msg an integer <= {_1} một số nguyên <= {_1} +@msg an integer {_1} to {_2} một số nguyên từ {_1} đến {_2} +@msg one of {_1} một trong các giá trị {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` phải là một trong các giá trị {_1}, không phải {got} +@msg an object id (a non-negative integer) một ID đối tượng (một số nguyên không âm) +@msg a list of object ids một danh sách ID đối tượng +@msg a color (a string, [r, g, b] or an object) một màu (một chuỗi, [r, g, b] hoặc một đối tượng) +@msg [x, y] (two numbers) [x, y] (hai số) +@msg an object một đối tượng +@msg a list một danh sách @msg unknown key `{k}`; the keys here are {_1} khóa không xác định `{k}`; các khóa ở đây là {_1} @msg {_1} is listed twice {_1} được liệt kê hai lần @msg {_1} is not the id of an input object {_1} không phải là id của một đối tượng đầu vào diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d1e4de6bc6..f5415f5d21 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6161,6 +6161,35 @@ axis Y Axis Y 轴 @msg the path has no anchor points 路径没有锚点 @msg too many anchor points (limit {MAX_ANCHORS}) 锚点过多(上限 {MAX_ANCHORS}) @msg true or false true 或 false +@msg a number >= {_1} 大于或等于 {_1} 的数字 +@msg a number > {_1} 大于 {_1} 的数字 +@msg a number <= {_1} 小于或等于 {_1} 的数字 +@msg a number < {_1} 小于 {_1} 的数字 +@msg a number {_1} to {_2} {_1} 到 {_2} 之间的数字 +@msg a number >= {_1} and < {_2} 大于或等于 {_1} 且小于 {_2} 的数字 +@msg a number > {_1} and <= {_2} 大于 {_1} 且小于或等于 {_2} 的数字 +@msg a number > {_1} and < {_2} 大于 {_1} 且小于 {_2} 的数字 +@msg a number ({_1}) 数字({_1}) +@msg a number >= {_1} ({_2}) 大于或等于 {_1} 的数字({_2}) +@msg a number > {_1} ({_2}) 大于 {_1} 的数字({_2}) +@msg a number <= {_1} ({_2}) 小于或等于 {_1} 的数字({_2}) +@msg a number < {_1} ({_2}) 小于 {_1} 的数字({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} 到 {_2} 之间的数字({_3}) +@msg a number >= {_1} and < {_2} ({_3}) 大于或等于 {_1} 且小于 {_2} 的数字({_3}) +@msg a number > {_1} and <= {_2} ({_3}) 大于 {_1} 且小于或等于 {_2} 的数字({_3}) +@msg a number > {_1} and < {_2} ({_3}) 大于 {_1} 且小于 {_2} 的数字({_3}) +@msg an integer 整数 +@msg an integer >= {_1} 大于或等于 {_1} 的整数 +@msg an integer <= {_1} 小于或等于 {_1} 的整数 +@msg an integer {_1} to {_2} {_1} 到 {_2} 之间的整数 +@msg one of {_1} {_1} 之一 +@msg `{path}` must be one of {_1}, not {got} `{path}` 必须是 {_1} 之一,而不是 {got} +@msg an object id (a non-negative integer) 对象 ID(非负整数) +@msg a list of object ids 对象 ID 列表 +@msg a color (a string, [r, g, b] or an object) 颜色(字符串、[r, g, b] 或对象) +@msg [x, y] (two numbers) [x, y](两个数字) +@msg an object 对象 +@msg a list 列表 @msg unknown key `{k}`; the keys here are {_1} 未知键 `{k}`;此处可用的键:{_1} @msg {_1} is listed twice {_1} 列出了两次 @msg {_1} is not the id of an input object {_1} 不是输入对象的 ID diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e49ed109a..e83c81ef30 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5966,6 +5966,35 @@ axis Y Axis Y 軸 @msg the path has no anchor points 路徑沒有錨點 @msg too many anchor points (limit {MAX_ANCHORS}) 錨點過多(上限 {MAX_ANCHORS}) @msg true or false true 或 false +@msg a number >= {_1} 大於或等於 {_1} 的數字 +@msg a number > {_1} 大於 {_1} 的數字 +@msg a number <= {_1} 小於或等於 {_1} 的數字 +@msg a number < {_1} 小於 {_1} 的數字 +@msg a number {_1} to {_2} 介於 {_1} 到 {_2} 之間的數字 +@msg a number >= {_1} and < {_2} 大於或等於 {_1} 且小於 {_2} 的數字 +@msg a number > {_1} and <= {_2} 大於 {_1} 且小於或等於 {_2} 的數字 +@msg a number > {_1} and < {_2} 大於 {_1} 且小於 {_2} 的數字 +@msg a number ({_1}) 數字({_1}) +@msg a number >= {_1} ({_2}) 大於或等於 {_1} 的數字({_2}) +@msg a number > {_1} ({_2}) 大於 {_1} 的數字({_2}) +@msg a number <= {_1} ({_2}) 小於或等於 {_1} 的數字({_2}) +@msg a number < {_1} ({_2}) 小於 {_1} 的數字({_2}) +@msg a number {_1} to {_2} ({_3}) 介於 {_1} 到 {_2} 之間的數字({_3}) +@msg a number >= {_1} and < {_2} ({_3}) 大於或等於 {_1} 且小於 {_2} 的數字({_3}) +@msg a number > {_1} and <= {_2} ({_3}) 大於 {_1} 且小於或等於 {_2} 的數字({_3}) +@msg a number > {_1} and < {_2} ({_3}) 大於 {_1} 且小於 {_2} 的數字({_3}) +@msg an integer 整數 +@msg an integer >= {_1} 大於或等於 {_1} 的整數 +@msg an integer <= {_1} 小於或等於 {_1} 的整數 +@msg an integer {_1} to {_2} 介於 {_1} 到 {_2} 之間的整數 +@msg one of {_1} {_1} 其中之一 +@msg `{path}` must be one of {_1}, not {got} `{path}` 必須是 {_1} 其中之一,而不是 {got} +@msg an object id (a non-negative integer) 物件 ID(非負整數) +@msg a list of object ids 物件 ID 清單 +@msg a color (a string, [r, g, b] or an object) 顏色(字串、[r, g, b] 或物件) +@msg [x, y] (two numbers) [x, y](兩個數字) +@msg an object 物件 +@msg a list 清單 @msg unknown key `{k}`; the keys here are {_1} 未知的鍵 `{k}`;此處可用的鍵:{_1} @msg {_1} is listed twice {_1} 列出了兩次 @msg {_1} is not the id of an input object {_1} 不是輸入物件的 ID diff --git a/docs/mcp.md b/docs/mcp.md index 2c8afc7aea..5596fd0bf5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1799,6 +1799,11 @@ parameter error, ``invalid parameters for ``: `` is nested too deeply` value. A schema that is wrong in itself, such as one that nests past 16 levels or puts a list of ids or a point inside 16 lists, is an internal error in the command, and the command does not run. +The command API returns these errors in English. The desktop app's status bar shows them in the interface language, +the requirement after "must be" included (a number and its range and unit, an integer, a list of names, an object id, +a color, a point, an object or a list); the parameter's path, the refused value, the bounds, the unit and the names stay +as the English gives them. + An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce5c5fda5..d62ca2f683 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -860,6 +860,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). + - A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`. Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way. - Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors. From f5bfe0c4258607888720955c9ded84318ab52a44 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:27:09 -0800 Subject: [PATCH 06/34] Effects: effect.apply refuses a wrongly typed parameter that has no default effect.apply, appearance.addEffect and effect.setParams checked an effect parameter's type only when the effect has a dialog default for the key. Keys that only the params text names were stored as given: an Outline Stroke with `width: "10 pt"` drew at the stroke's own weight, and a Gaussian Blur with `linear: 1` blurred in linear light. - The earlier 3D `perspective`, `lightAzimuth` and `lightElevation`, Scribble's `variation` and Outline Stroke's `width` must be numbers, and Gaussian Blur's `linear` must be true or false. A test walks every effect so a new key without a default needs a listed type or a check of its own. - The `style` of Crop Marks must be `roman` or `japanese`, and `null` is refused. Any other value drew as Roman. - An installed plug-in effect's parameters are checked against its manifest when the command runs, as plugin.run already checks the effect records a filter returns. - effect.setParams with `warpStyle` checks the parameters against the new Warp style's effect, as it already did for a 3D effect's new `threeDType`. It checked them against the old style. - effect.setParams refuses a `visible` that is not true or false, such as `1` or `"false"`, when called and when a step plays, and nothing changes. Before, the value was ignored and the rest of the call applied. `null` still leaves the visibility as it is. A step of command.batch is refused the same way. A replayed journal or action still plays a wrongly typed effect parameter, as it plays a key the effect no longer takes. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/effectcmd.rs | 22 +++++- crates/engine/src/cmd/effectcmd/tests.rs | 44 +++++++++++ crates/engine/src/cmd/params.rs | 83 +++++++++++++++----- crates/engine/src/tests/params.rs | 99 ++++++++++++++++++++++++ crates/engine/src/tests/plugins.rs | 8 ++ docs/mcp.md | 25 +++++- docs/target-app-parity.md | 1 + 7 files changed, 256 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 72a72e6411..3201adfbe6 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -16,7 +16,7 @@ use vectorcraft_render::effects; use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at}; use super::expand::has_seeded; use super::rasterfx; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; /// The name of the `svg.filter` effect drawing `xml`: its `` id. An effect names the /// document's filter of that name, so `xml` that differs from the document's filter of the same @@ -76,7 +76,7 @@ fn effect_specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", has_doc, set_params ), @@ -649,15 +649,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { return vec![]; }; let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] }; - // A 3D effect whose type `threeDType` changes takes the new effect's parameters. + // A 3D effect whose type `threeDType` changes, and a Warp whose style `warpStyle` changes, take + // the new effect's parameters. let kind = three_d_type(p, C).ok().flatten(); + let warp = p.get("warpStyle").and_then(Value::as_str).filter(|style| effects::WarpStyle::from_id(style).is_some()); let effect = |&(id, at, index): &(NodeId, Option, usize)| { let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id; - Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string)) + Some(edited_effect_id(id, kind, warp)) }; targets.iter().filter_map(effect).collect() } +/// The id effect `id` has once `effect.setParams` sets its 3D type to `kind` or its Warp style to +/// `warp` (each when given and when `id` is an effect of that family). +fn edited_effect_id(id: &str, kind: Option<&str>, warp: Option<&str>) -> String { + let is_warp = id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_some(); + match (kind, warp) { + (Some(to), _) if effects::is_three_d(id) => to.to_string(), + (_, Some(style)) if is_warp => format!("warp.{style}"), + _ => id.to_string(), + } +} + /// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given. fn three_d_type(p: &Value, cmd: &str) -> Result> { let key = "threeDType"; @@ -718,6 +731,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { Ok(format!("warp.{style}")) }) .transpose()?; + check_switches(p, &["visible"], C)?; let visible = p.get("visible").and_then(Value::as_bool); let item = item_target(s, p, C)?; let edited = set_params_effects(s, p); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 5584073563..8b5f90eb4a 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -626,6 +626,50 @@ fn warp_options_non_warp_target_rejection_is_atomic() { assert!(!state.is_dirty()); } +/// `effect.setParams` refuses a `visible` that is not true or false, when called and when a step +/// plays, and the effect, the document and the undo history stay as they were. `null` leaves the +/// visibility as it is. +#[test] +fn set_params_refuses_a_visible_that_is_not_true_or_false() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": null})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for visible in [json!(1), json!(0), json!("false"), json!([])] { + let p = json!({"index": 0, "item": null, "visible": visible, "params": {"size": 9}}); + for replay in [false, true] { + let r = if replay { s.replay("effect.setParams", &p) } else { s.execute("effect.setParams", &p) }; + let err = r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("`visible` must be true or false"), "{visible} (replay {replay}): {err}"); + let state = s.doc().unwrap(); + assert_eq!(state.doc, before, "{visible} (replay {replay})"); + assert_eq!(state.history.undo.len(), undo, "{visible} (replay {replay})"); + assert!(!state.is_dirty()); + } + } + s.execute("effect.setParams", &json!({"index": 0, "item": null, "visible": null, "params": {"size": 9}})).unwrap(); + let n = node(&s, id); + assert!(n.appearance.effects[0].visible); + assert_eq!(n.appearance.effects[0].params["size"], 9); +} + +/// The effect `effect.setParams` checks the parameters against is the one it leaves: a Warp's new +/// style and a 3D effect's new type, and the effect as it is otherwise. +#[test] +fn set_params_checks_the_effect_it_leaves() { + use super::edited_effect_id; + assert_eq!(edited_effect_id("warp.arc", None, Some("flag")), "warp.flag"); + assert_eq!(edited_effect_id("warp.arc", None, None), "warp.arc"); + assert_eq!(edited_effect_id("distort.roughen", None, Some("flag")), "distort.roughen"); + assert_eq!(edited_effect_id("threeD.extrude", Some("threeD.revolve"), None), "threeD.revolve"); + assert_eq!(edited_effect_id("warp.arc", Some("threeD.revolve"), None), "warp.arc"); + let (mut s, _) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); + let p = json!({"index": 0, "item": null, "warpStyle": "flag", "params": {"bend": 25}}); + assert_eq!(super::set_params_effects(&s, &p), ["warp.flag"]); +} + #[test] fn list_remove_and_set_params() { let (mut s, id) = session_with_rect(); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 60fa90c32c..c38854ae76 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -15,8 +15,8 @@ //! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a //! misspelled `widht` no longer runs the command with its default. So are the `params` of an //! effect (#1592): `effect.apply` and -//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its -//! default, with a parameter error ([`check_effect_params`]). +//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than the +//! effect reads, with a parameter error ([`check_effect_params`]). //! //! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema //! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out @@ -263,15 +263,15 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError } /// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the -/// effect takes (those of its dialog defaults and of its params description), each of its -/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves -/// `channel` the effect has ([`check_effect_channel`]). Numbers out of range pass: the effect -/// clamps them when it draws. An effect plug-in resolves its own against its manifest, and an -/// unknown effect is the command's to report. +/// effect takes (those of its dialog defaults and of its params description), each of the type +/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`]) +/// and a Levels or Curves `channel` the effect has ([`check_effect_channel`]). Numbers out of range +/// pass: the effect clamps them when it draws. An installed effect plug-in's are checked against its +/// manifest, and an unknown effect is the command's to report. pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> { let Value::Object(given) = params else { return Ok(()) }; if effect.starts_with(vectorcraft_plugins::effect::PREFIX) { - return Ok(()); + return check_effect_types(id, effect, params); } let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) }; let defaults = info.defaults.as_object(); @@ -282,18 +282,10 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> keys.push(key); } } - for (key, v) in given { - if !keys.contains(&key.as_str()) { - return Err(unknown(id, key, &keys)); - } - match defaults.and_then(|d| d.get(key)) { - Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))), - Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))), - // (A colour is also taken as `[r, g, b]` or as a colour object.) - Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))), - _ => {} - } + if let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) { + return Err(unknown(id, key, &keys)); } + check_effect_types(id, effect, params)?; // An SVG filter's XML is one drawable element. if effect == vectorcraft_render::effects::SVG_FILTER && let Some(xml) = given.get("xml") @@ -329,6 +321,59 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> check_effect_channel(id, effect, params) } +/// The type of the values in `params` of effect `effect`, as command `id` was given them: a key +/// the effect has a dialog default for takes a value of its default's type, a key only the +/// effect's params description names takes the type the effect reads ([`undefaulted_type`]), and +/// an installed effect plug-in's take the types its manifest gives them. The `style` of Crop Marks +/// is `roman` or `japanese`. A number given as text (`"10 pt"`), `true` for a number, `1` for a +/// switch and `null` for either are refused. +fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> { + let Value::Object(given) = params else { return Ok(()) }; + if effect.starts_with(vectorcraft_plugins::effect::PREFIX) { + return match vectorcraft_plugins::effect::installed(effect) { + Some(plugin) => plugin.manifest().resolve_params(params).map(drop).map_err(|why| bad(id, why.to_string())), + None => Ok(()), + }; + } + let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) }; + for (key, v) in given { + let want = info.defaults.get(key).cloned().or_else(|| undefaulted_type(effect, key)); + match want { + Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))), + Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))), + // (A colour is also taken as `[r, g, b]` or as a colour object.) + Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))), + _ => {} + } + } + // Crop Marks draw one of two styles. + if effect == vectorcraft_render::effects::CROP_MARKS + && let Some(style) = given.get("style") + && style.as_str().and_then(vectorcraft_doc::marks::MarkStyle::parse).is_none() + { + return Err(bad(id, "`style` must be roman or japanese")); + } + Ok(()) +} + +/// A value of the type effect `effect` reads for `key`, a parameter its params description names +/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`, +/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number +/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG +/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their +/// own; they aren't listed. +pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option { + use vectorcraft_render::effects; + let number = || Some(Value::from(0.0)); + match key { + "perspective" | "lightAzimuth" | "lightElevation" if effects::is_three_d(effect) => number(), + "variation" if effect == "stylize.scribble" => number(), + "width" if effect == "path.outlineStroke" => number(), + "linear" if effect == "blur.gaussian" => Some(Value::Bool(false)), + _ => None, + } +} + /// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`, /// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers /// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 3cbdb5df9f..e72f607030 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -600,6 +600,105 @@ fn effect_params_are_the_effects_own() { assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok()); } +/// An effect parameter that only the effect's params description names, with no dialog default, +/// is held to the type the effect reads: the earlier 3D Perspective and light, Scribble's +/// `variation` and an Outline Stroke's `width` are numbers, and a Gaussian Blur's `linear` is true +/// or false. The `style` of Crop Marks is `roman` or `japanese`. Every such key of every effect is +/// listed, or has a check of its own. A key left out keeps its default. +#[test] +fn an_effect_parameter_without_a_default_is_held_to_its_type() { + use crate::cmd::params::undefaulted_type; + use vectorcraft_render::effects::effect_catalog; + // Read by checks of their own: a stored texture or a texture to load, an SVG filter, a Warp's + // map and the style of Crop Marks. + let own_check = |effect: &str, key: &str| match key { + "textureImage" | "name" | "xml" => true, + "version" => effect.starts_with("warp."), + "style" => effect == "cropMarks", + _ => false, + }; + let mut unlisted = vec![]; + for e in effect_catalog() { + for key in doc_keys(e.params).unwrap().keys { + if e.defaults.get(key).is_none() && undefaulted_type(e.id, key).is_none() && !own_check(e.id, key) { + unlisted.push(format!("{}.{key}", e.id)); + } + } + } + assert!(unlisted.is_empty(), "parameters without a default or a listed type: {unlisted:?}"); + let apply = |effect: &str, params| json!({"effect": effect, "params": params}); + for (effect, params, why) in [ + ("threeD.revolve", json!({"perspective": true}), "`perspective` must be a number"), + ("threeD.extrude", json!({"lightAzimuth": "30"}), "`lightAzimuth` must be a number"), + ("threeD.rotate", json!({"lightElevation": null}), "`lightElevation` must be a number"), + ("threeD.inflate", json!({"perspective": "50%"}), "`perspective` must be a number"), + ("path.outlineStroke", json!({"width": "10 pt"}), "`width` must be a number"), + ("stylize.scribble", json!({"variation": false}), "`variation` must be a number"), + ("blur.gaussian", json!({"radius": 2, "linear": 1}), "`linear` must be true or false"), + ("cropMarks", json!({"style": "bogus"}), "`style` must be roman or japanese"), + ("cropMarks", json!({"style": 1}), "`style` must be roman or japanese"), + ("cropMarks", json!({"style": null}), "`style` must be roman or japanese"), + ] { + assert_eq!(refusal("effect.apply", apply(effect, params.clone())), why, "{effect} {params}"); + assert_eq!(refusal("appearance.addEffect", apply(effect, params.clone())), why, "{effect} {params}"); + } + for (effect, params) in [ + ("threeD.revolve", json!({"perspective": 40, "lightAzimuth": 30.5, "lightElevation": 0})), + ("path.outlineStroke", json!({"width": 10})), + ("stylize.scribble", json!({"variation": 2})), + ("blur.gaussian", json!({"linear": true})), + ("blur.gaussian", json!({})), + ("cropMarks", json!({"style": "japanese"})), + ("cropMarks", json!({})), + ] { + assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}"); + } +} + +/// `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse such a parameter of the +/// wrong type when they run, and so does a step of a `command.batch` whose effect a step before it +/// added, with the document and its undo history as they were. A saved step still plays with it, +/// as with a key the effect no longer takes. +#[test] +fn effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.history.undo.len(), st.doc.clone()) + }; + let before = state(&s); + let apply = |effect: &str, params| json!({"effect": effect, "params": params}); + for (id, params, why) in [ + ("effect.apply", apply("path.outlineStroke", json!({"width": "10 pt"})), "`width` must be a number"), + ("appearance.addEffect", apply("blur.gaussian", json!({"linear": 1})), "`linear` must be true or false"), + ("effect.apply", apply("threeD.revolve", json!({"perspective": "50"})), "`perspective` must be a number"), + ] { + let e = s.execute(id, ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{id} {params}: {e}"); + assert_eq!(state(&s), before, "{id} {params}"); + } + let batch = json!({"commands": [ + {"command": "effect.apply", "params": apply("path.outlineStroke", json!({"width": 3}))}, + {"command": "effect.setParams", "params": {"index": 0, "params": {"width": "10 pt"}}}, + ]}); + let e = s.execute("command.batch", &batch).unwrap_err().to_string(); + assert!(e.contains("`width` must be a number"), "{e}"); + assert_eq!(state(&s), before); + s.execute("effect.apply", &apply("path.outlineStroke", json!({"width": 3}))).unwrap(); + let applied = state(&s); + for params in [json!({"width": "10 pt"}), json!({"width": null}), json!({"width": true})] { + let e = s.execute("effect.setParams", &json!({"index": 0, "params": params.clone()})).unwrap_err().to_string(); + assert!(e.contains("`width` must be a number"), "{params}: {e}"); + assert_eq!(state(&s), applied, "{params}"); + } + s.execute("effect.setParams", &json!({"index": 0, "params": {"width": 4}})).unwrap(); + s.replay("effect.setParams", &json!({"index": 0, "params": {"retired": "x", "width": "10 pt"}})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params["width"], json!("10 pt")); +} + /// The Transform effect (`distort.transform`) is held to its params description: `effect.apply` /// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX: /// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 0224d59b38..cc4460d250 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -235,8 +235,16 @@ fn live_effect_plugins_reshape_editable_art_and_expand() { run(&mut s, "select.all", json!({})); run(&mut s, "object.move", json!({"dx": 50, "dy": 0})); assert_eq!(effected(&s), *node(&s, a).path_data().unwrap()); + // A parameter of another type than the manifest gives it is refused. + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let wrong = json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": "4 pt"}}); + let e = s.execute("effect.apply", &wrong).unwrap_err().to_string(); + assert!(e.contains("`size` must be a number"), "{e}"); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before); run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}})); assert_eq!(effected(&s), triangle()); + let e = s.execute("effect.setParams", &json!({"ids": [a.0], "index": 1, "params": {"size": true}})).unwrap_err().to_string(); + assert!(e.contains("`size` must be a number"), "{e}"); assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4})); // Uninstalled: the geometry is drawn as it is. run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"})); diff --git a/docs/mcp.md b/docs/mcp.md index 53a792d5a6..840aa9c7ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1483,14 +1483,33 @@ refused before the command runs: Only the top level is checked, except for the export and save commands below: the keys of an object inside a value (`artboardLayout: {…}`) are the command's to read. Another exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` check them against the effect's own entry in `effect.list`, its `defaults` and the keys its `params` text names. A key -the effect doesn't take, or a value of another type than its default (a string for a number, a number for a switch), -is refused the same way: +the effect doesn't take, or a value of another type than the effect reads (a string for a number, a number for a +switch), is refused the same way: ```json {"id": "effect.apply", "params": {"effect": "blur.gaussian", "params": {"blurr": 3}}} -→ invalid parameters for `effect.apply`: unknown parameter `blurr`; the command takes: radius +→ invalid parameters for `effect.apply`: unknown parameter `blurr`; the command takes: radius, linear ``` +A key the effect has a default for takes a value of its default's type. A key only the `params` text names takes the +type the effect reads: the earlier 3D `perspective`, `lightAzimuth` and `lightElevation` and Scribble's `variation` are +numbers, an Outline Stroke's `width` is a number and a Gaussian Blur's `linear` is true or false. A number written as +text (`"10 pt"`), `true` for a number, `1` for a switch and `null` for either are refused; a key left out keeps its +default. The `style` of Crop Marks must be `roman` or `japanese`. An installed plug-in effect's parameters take the +types its manifest gives them. A step of `command.batch` gets the same error, and the document and its undo history +stay as they were: + +```json +{"id": "effect.apply", "params": {"effect": "path.outlineStroke", "params": {"width": "10 pt"}}} +→ invalid parameters for `effect.apply`: `width` must be a number +``` + +A replayed journal or action still plays such a value, as it plays a key the effect no longer takes. + +`effect.setParams` with `warpStyle` checks `params` against the new Warp style, and with `threeDType` against the new +3D effect. Its own `visible` must be true or false (`null` leaves the visibility as it is); any other value is refused, +in a replayed journal or action too, and nothing changes. + The `color` of Drop Shadow, Inner Glow, Outer Glow and Shift to Color must be one the effect can read: a hex color (`#rrggbb`, `#rgb` or `#rrggbbaa`, with or without the `#`), `black`, `white`, `red`, `green` or `blue`, `[r, g, b]` (three numbers, each read on a 0 to 1 scale), or a color object (`{"model": "rgb", "r": 1, "g": 0, "b": 0}`). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ed2e6e180..0700fbca53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -793,6 +793,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. + - Effect parameter types: `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse an effect parameter of another type than the effect reads, for keys only the params text names too (the earlier 3D `perspective`, `lightAzimuth` and `lightElevation`, Scribble's `variation`, Outline Stroke's `width`, Gaussian Blur's `linear`), a Crop Marks `style` other than `roman` or `japanese`, and an installed plug-in effect's parameter of another type than its manifest gives it, in a `command.batch` step too, with the document and undo history unchanged; a replayed action still plays. `effect.setParams` checks the parameters against a Warp's new style, and refuses a `visible` that is not true or false, in a replayed action too. `crates/engine/src/tests/params.rs` (`an_effect_parameter_without_a_default_is_held_to_its_type`, `effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type`), `crates/engine/src/tests/plugins.rs` (`live_effect_plugins_reshape_editable_art_and_expand`), `crates/engine/src/cmd/effectcmd/tests.rs` (`set_params_refuses_a_visible_that_is_not_true_or_false`, `set_params_checks_the_effect_it_leaves`). - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). From fad257ce8277a6a7315f2ce75f612607352e30dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:27:27 -0800 Subject: [PATCH 07/34] Drawing tools: the measurement readout shows with the rubber band off Enable Rubber Band now hides only the preview segment in the Pen, the Curvature tool and the Smart Pen. With it off and Measurement Labels on, the three tools still show the D / Angle / dX / dY readout at the pointer while a path is drawn, and the Curvature tool also shows it when going on from the current end of a selected path. With Measurement Labels off, no readout shows either way. The click places the same anchor as before. Docs: docs/mcp.md (penRubberBand, curvatureRubberBand), docs/ui-parity.md, docs/target-app-parity.md. --- crates/tools/src/addons/smart_pen.rs | 11 +++-- crates/tools/src/addons/smart_pen/tests.rs | 29 +++++++++++++ crates/tools/src/draw2/curvature.rs | 40 ++++++++++-------- crates/tools/src/draw2/curvature/tests.rs | 48 ++++++++++++++++++++++ crates/tools/src/pen.rs | 20 +++++---- crates/tools/src/pen/tests.rs | 42 +++++++++++++++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 9 files changed, 164 insertions(+), 32 deletions(-) diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 6a287b9b54..cd72f1dd40 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -573,12 +573,15 @@ impl Tool for SmartPenTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; - if cx.pen_rubber_band - && self.press.is_none() + // The rubber band preference hides only the preview segment; Measurement Labels decides the readout. + if self.press.is_none() && let (Some((id, _, sp)), Some(h)) = (self.active(cx), self.hover) - && let Some(path) = self.rubber_band(sp, h) { - o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); + if cx.pen_rubber_band + && let Some(path) = self.rubber_band(sp, h) + { + o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); + } if let Some(last) = sp.anchors.last().map(|a| a.p) && cx.measurement_labels && last.distance(h) > 1e-9 diff --git a/crates/tools/src/addons/smart_pen/tests.rs b/crates/tools/src/addons/smart_pen/tests.rs index a7f792c22f..8bcc907f9d 100644 --- a/crates/tools/src/addons/smart_pen/tests.rs +++ b/crates/tools/src/addons/smart_pen/tests.rs @@ -315,6 +315,35 @@ fn the_rubber_band_shows_the_next_segment_and_ending_the_path() { assert!(t.overlays(&cx).is_empty()); } +/// With Enable Rubber Band for the Pen off, the preview segment is hidden but Measurement Labels +/// still shows the readout from the last anchor to the pointer. +#[test] +fn the_measurement_label_shows_with_the_rubber_band_off() { + let (d, _, s) = doc(line()); + let p = paint(); + let on = plain(&d, &s, &p); + let off = ToolContext { pen_rubber_band: false, ..plain(&d, &s, &p) }; + let (from, to) = (Point::new(200.0, 100.0), Point::new(300.0, 160.0)); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. })); + let mut t = drawing(&off, from); + t.pointer(&off, &ev(PointerKind::Move, to.x, to.y, NO)); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + for rubber in [true, false] { + let quiet = ToolContext { pen_rubber_band: rubber, measurement_labels: false, ..plain(&d, &s, &p) }; + assert!(measure(&t.overlays(&quiet)).is_empty()); + } + // Hovering exactly on the last anchor measures no segment. + t.pointer(&off, &ev(PointerKind::Move, from.x, from.y, NO)); + assert!(measure(&t.overlays(&off)).is_empty()); +} + #[test] fn an_alt_drag_away_from_paths_selects_the_points_inside_a_marquee() { let (d, id, s) = doc(SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)], false)); diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index c40607ee63..02087d997a 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -718,38 +718,44 @@ impl Tool for CurvatureTool { } fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = self.snap.guides().to_vec(); - let band = cx.curvature_rubber_band && self.drag.is_none(); + // The rubber band preference hides only the preview curve; Measurement Labels decides the readout. + let idle = self.drag.is_none(); + let band = cx.curvature_rubber_band; let mut bp = BezPath::new(); if self.active(cx).is_none() { // The curve on from the current end of a selected path: its last segment and the next. - if band + if idle && let (Some(e), Some(h)) = (current_end(cx), self.hover) && let Some(sp) = subpath(cx, e.id, e.si) { - let n = sp.anchors.len(); - let near = if e.start { - sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect() - } else { - sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect() - }; - let mut tail = SubPath::new(near, false); - curvature_extend(&mut tail, false, h, self.how(&e), false); - tail.to_bezpath_into(&mut bp); - o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + if band { + let n = sp.anchors.len(); + let near = if e.start { + sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect() + } else { + sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect() + }; + let mut tail = SubPath::new(near, false); + curvature_extend(&mut tail, false, h, self.how(&e), false); + tail.to_bezpath_into(&mut bp); + o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + } if cx.measurement_labels && e.p.distance(h) > 1e-9 { o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(e.p, h) }); } } return o; } - if band + if idle && !self.closed && let Some(h) = self.hover { - let mut pts = self.pts.clone(); - pts.push((h, false)); - curvature_spline(&pts, false).to_bezpath_into(&mut bp); - o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + if band { + let mut pts = self.pts.clone(); + pts.push((h, false)); + curvature_spline(&pts, false).to_bezpath_into(&mut bp); + o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + } if let Some((from, _)) = self.pts.last() && cx.measurement_labels && from.distance(h) > 1e-9 diff --git a/crates/tools/src/draw2/curvature/tests.rs b/crates/tools/src/draw2/curvature/tests.rs index f21deec016..50ef2281d1 100644 --- a/crates/tools/src/draw2/curvature/tests.rs +++ b/crates/tools/src/draw2/curvature/tests.rs @@ -539,3 +539,51 @@ fn clicks_go_on_from_a_current_end_and_the_other_end_closes() { let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false); assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}"); } + +/// With Enable Rubber Band for the Curvature tool off, the preview curve is hidden but +/// Measurement Labels still shows the readout, both while drawing a path and going on from the +/// current end of a selected one. +#[test] +fn the_measurement_label_shows_with_the_rubber_band_off() { + let p = paint(); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. })); + let to = pt(650.0, 60.0); + // Going on from the current end of a selected path. + let (d, id) = doc_with(wave()); + let s = selected(id, Some(4)); + let on = quiet(&d, &s, &p); + let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) }; + let mut t = CurvatureTool::default(); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y)); + let from = pt(450.0, 60.0); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + // A path being drawn. + let (d, id) = doc_with(SubPath::polyline(&[pt(100.0, 100.0), pt(200.0, 150.0)], false)); + let s = selected(id, None); + let on = quiet(&d, &s, &p); + let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) }; + let mut t = CurvatureTool { id: Some(id), pts: vec![(pt(100.0, 100.0), false), (pt(200.0, 150.0), false)], ..CurvatureTool::default() }; + assert!(t.active(&off).is_some()); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y)); + let from = pt(200.0, 150.0); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + for rubber in [true, false] { + let unlabeled = ToolContext { curvature_rubber_band: rubber, measurement_labels: false, ..quiet(&d, &s, &p) }; + assert!(measure(&t.overlays(&unlabeled)).is_empty()); + } + // Hovering exactly on the last point measures no segment. + t.pointer(&off, &PointerEvent::new(PointerKind::Move, from.x, from.y)); + assert!(measure(&t.overlays(&off)).is_empty()); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 0b625f2813..a0ab57ae87 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -495,19 +495,21 @@ impl Tool for PenTool { return t.overlays(cx); } let mut o = vec![]; - if cx.pen_rubber_band - && self.drag.is_none() + // The rubber band preference hides only the preview segment; Measurement Labels decides the readout. + if self.drag.is_none() && self.handle.is_none() && let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover) { - let mut bp = BezPath::new(); - bp.move_to(last); - if out.distance(last) > 1e-9 { - bp.quad_to(out, h); - } else { - bp.line_to(h); + if cx.pen_rubber_band { + let mut bp = BezPath::new(); + bp.move_to(last); + if out.distance(last) > 1e-9 { + bp.quad_to(out, h); + } else { + bp.line_to(h); + } + o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); } - o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); if cx.measurement_labels && last.distance(h) > 1e-9 { o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(last, h) }); } diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 478589fe9e..32c91318e6 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -773,6 +773,48 @@ fn anchors_snap_to_smart_guides() { assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]}))); } +/// With Enable Rubber Band off, the preview segment is hidden but Measurement Labels still shows +/// the D / Angle / dX / dY readout at the snapped point before the next click. +#[test] +fn measurement_label_shows_with_the_rubber_band_off() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + let on = cx(&d, &s, &p); + let off = ToolContext { pen_rubber_band: false, ..cx(&d, &s, &p) }; + let from = Point::new(60.0, 300.0); + let to = Point::new(150.0, 318.0); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let preview = |o: &[Overlay]| { + o.iter().any(|o| matches!(o, Overlay::Path { path, .. } if path.elements().first().and_then(|e| e.end_point()) == Some(from))) + }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!preview(&o), "{o:?}"); + // The rubber band on still shows both. + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(preview(&o), "{o:?}"); + // Measurement Labels off shows no readout either way. + for band in [true, false] { + let quiet = ToolContext { pen_rubber_band: band, measurement_labels: false, ..cx(&d, &s, &p) }; + assert!(measure(&t.overlays(&quiet)).is_empty()); + } + // The click places the same anchor as with the rubber band on. + let a = click(&mut t, &off, 152.0, 318.0, Mods::default()); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0}))); + // Hovering exactly on the last anchor measures no segment. + let mut t = PenTool { drawing: true, ..PenTool::default() }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 60.0, 300.0)); + assert!(measure(&t.overlays(&off)).is_empty()); + // With no path being drawn there is nothing to measure from. + let mut t = PenTool::default(); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); + assert!(measure(&t.overlays(&off)).is_empty()); +} + #[test] fn alt_over_a_shown_handle_or_an_anchor_converts() { // The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100). diff --git a/docs/mcp.md b/docs/mcp.md index 53a792d5a6..f4e699620d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5389,7 +5389,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture scales it too; off (the default) it stays where it is. `artboard.duplicate`, `artboard.copy` and `artboard.cut` copy or cut locked and hidden art only with it on. A `lockedAndHidden` param on any of these commands takes its place. - `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to - the pointer. + the pointer. With either off, the Pen, the Smart Pen (`penRubberBand`) and the Curvature tool (`curvatureRubberBand`) + still show the D / Angle / dX / dY readout at the pointer while `measurementLabels` is on. - `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt) shows and drags direction handles only while a single anchor is selected. On, the handles of the direct-selected anchors, or of every anchor of a path selected as a whole, show and drag. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ed2e6e180..55d21c00c1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -892,6 +892,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Four brightness themes, persistent preferences. - **Snapping quick access (#6164):** Control bar › Snapping includes Snap to Glyph (`view.snapToGlyph`) and Alignment Guides (`prefs.set`, `alignmentGuides`). The headless pointer/accessibility test `the_control_bar_snapping_menu_shares_glyph_and_alignment_settings` covers both commands, independent state, checked states after reopening, and all eight menu rows within a 1200 × 400-point window. Floating-panel parity remains unverified; scores and estimates unchanged. - **Measure tools and Snap to Glyph:** with Snap to Glyph, Smart Guides and Alignment Guides on, and Snap to Grid and Snap to Pixel off, both ends of a Measure or Measure & Inspect drag land within the snapping tolerance on the guides of live type that the Character panel's options allow, except Anchor Point, and a Shift-drag's end slides along its 45° step to them. The guide shows during the drag. This is VectorCraft's own rule (`Targets::for_measure`, `xform::measure::tests::its_ends_land_on_glyph_guides_with_snap_to_glyph`); scores and estimates unchanged. + - **Drawing readout without the rubber band:** with Enable Rubber Band for the Pen or Curvature tool off and Measurement Labels on, the Pen, the Smart Pen and the Curvature tool hide the preview segment but still show the D / Angle / dX / dY readout at the snapped pointer before the next click, and the Curvature tool also shows it when going on from the current end of a selected path. Measurement Labels show distance and placement while drawing ([the help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html)), and the rubber band preference turns off the path preview ([the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/preview-paths-drawn.html)). Showing the readout with the rubber band off is VectorCraft's own rule (`pen::tests::measurement_label_shows_with_the_rubber_band_off`, and `the_measurement_label_shows_with_the_rubber_band_off` in `draw2::curvature::tests` and `addons::smart_pen::tests`); scores and estimates unchanged. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (off by default: on, pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f10ea4841..bc9e1a3244 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -233,7 +233,7 @@ tools, never checked side by side) isn't captured row by row. | "intersect" label and snapping to path intersections | partial | #1658 (part of #1602): a point drawn with the Pen, the shape tools or the other drawing tools and an anchor dragged with Direct Selection snap to where paths and ruler guides cross; a moved selection's own anchors and the Width tool don't | | Construction guides at preference angles while drawing | done | #506 | | Line extension: the dragged end of a straight segment snaps to the segment's own direction at any angle, past its other end or short of it, with the guide along it | done | #2120; Direct Selection and Curvature, one anchor dragged (with several, the move has no single direction); nearer the horizontal or vertical through the other end, the point lines up with it ("align"), so a nearly level segment can still be straightened | -| Measurement labels while drawing, moving and resizing | done | sizes and offsets; the segment being drawn is labeled with its distance, angle and offsets by the Line Segment tool, and by the Pen, Curvature and Smart Pen tools with Enable Rubber Band on (#2983) | +| Measurement labels while drawing, moving and resizing | done | sizes and offsets; the segment being drawn is labeled with its distance, angle and offsets by the Line Segment tool, and by the Pen, Curvature and Smart Pen tools with Enable Rubber Band on or off (#2983) | | Spacing guides (equal distances) | partial | for moves and bounding-box resizes (#394); not while drawing | | Distance Guides: Alt/Option over another object, or an empty part of an artboard, shows the selection's distances to it (Smart Guides › Invoke Distance Guides) | done | Selection tool hover with Smart Guides on: the gap between facing edges, else the offsets of matching edges, to another object; the left, right, top and bottom distances to the artboard; labelled in the General unit. The selection is measured as a whole. Nothing while moving or nudging, which the public pages don't describe. [Use Distance Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html), [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) | | Snapping tolerance preference | done | | From a0e468071039c2c64fd820ae6570a8be48579cf7 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:29:02 -0500 Subject: [PATCH 08/34] Roadmap M4 row: the #6971 note goes in the Status cell --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 49794edaa1..b8168d070c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -150,7 +150,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | From 8987d42c19eb92c92adde93f599181005bc16ecf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:33:16 -0800 Subject: [PATCH 09/34] i18n: messages with escaped JSON braces match their catalog rows Catalog rows now read `{{` and `}}` as a literal `{` and `}`, in the English source and in the translation, as in a Rust format string. A `{` with no `}` after it is literal text instead of dropping the row. - The plugin.run errors for an importer, an exporter and a live effect (`document.open {"importer": ...}` and the like) now show translated with the plug-in's id and JSON kept, in the 22 catalogs that list them. Their rows were keyed as `{{"importer"}"}}`, which never matched. - The test that keys a format string keeps `{{`/`}}` and numbers only real placeholders, so its expected keys match the runtime text. Two junk rows it produced for the RTF error (hi, sv) are removed. --- crates/ui-egui/src/i18n.rs | 14 ++++- crates/ui-egui/src/i18n/ca.tsv | 4 +- crates/ui-egui/src/i18n/catalog.rs | 41 +++++++++++--- crates/ui-egui/src/i18n/cs.tsv | 4 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 4 +- crates/ui-egui/src/i18n/es.tsv | 4 +- crates/ui-egui/src/i18n/fi.tsv | 4 +- crates/ui-egui/src/i18n/fr.tsv | 4 +- crates/ui-egui/src/i18n/hi.tsv | 7 +-- crates/ui-egui/src/i18n/hu.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 4 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ka.tsv | 4 +- crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 4 +- crates/ui-egui/src/i18n/ru.tsv | 4 +- crates/ui-egui/src/i18n/sv.tsv | 7 +-- crates/ui-egui/src/i18n/tests.rs | 88 ++++++++++++++++++++++++++++- crates/ui-egui/src/i18n/tr.tsv | 4 +- crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- 25 files changed, 196 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ee09ee14e..eeb87a3318 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -677,13 +677,23 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { } /// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is. -/// Unknown placeholders are left as written. +/// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`. fn fill_once(template: &str, args: &[(&str, &str)]) -> String { let mut rest = template; let mut out = String::with_capacity(rest.len()); - while let Some(open) = rest.find('{') { + while let Some(open) = rest.find(['{', '}']) { out.push_str(&rest[..open]); let after = &rest[open + 1..]; + if rest[open..].starts_with('}') { + out.push('}'); + rest = after.strip_prefix('}').unwrap_or(after); + continue; + } + if let Some(escaped) = after.strip_prefix('{') { + out.push('{'); + rest = escaped; + continue; + } let filled = after.find('}').and_then(|close| args.iter().find(|(k, _)| *k == &after[..close]).map(|(_, v)| (*v, close))); match filled { Some((value, close)) => { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0399e6c04a..4e17e5bc34 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7135,7 +7135,9 @@ breakText Glyphs Glifs @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}` @msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador @msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador -@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}} +@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer": "{_2}"}} +@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` és un exportador: exporteu amb document.export {{"format": "{_2}"}} +@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` és un efecte dinàmic: apliqueu-lo amb effect.apply {{"effect": "{_2}"}} @msg the importer returned no objects l’importador no ha retornat cap objecte @msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}` @msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 138299643d..ac659ac4b2 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -47,23 +47,42 @@ const MAX_MESSAGE_LEN: usize = 2000; impl Template { /// `None` for a template that can't be matched reliably: no literal text, or two placeholders - /// in a row (where one ends and the next begins is unknowable). + /// in a row (where one ends and the next begins is unknowable). `{{` and `}}` are a literal `{` + /// and `}`, as in a Rust format string, and so is a `{` with no `}` after it. fn parse(source: &str, forms: Vec, counted: bool) -> Option